Skip to main content

brows3r_lib/
ids.rs

1//! Opaque identity newtypes used throughout the application.
2//!
3//! Using distinct types for `ProfileId`, `BucketId`, and `ObjectKey` prevents
4//! accidental substitution (e.g. passing a bucket name where an object key is
5//! expected) at the type level.
6//!
7//! # Design choices
8//! - Backed by `String` rather than a parsed `Uuid` so that compat providers
9//!   with non-UUID profile identifiers work without special-casing.
10//! - `ProfileId::new_v4()` is the *default* mint strategy, not a constraint.
11//! - `From<&str>` and `From<String>` for ergonomic construction.
12//! - `Deref<Target=str>` is intentionally NOT implemented — auto-coercion
13//!   would mask type-level bugs.
14
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use uuid::Uuid;
18
19// ---------------------------------------------------------------------------
20// Macro to reduce boilerplate across the three newtypes
21// ---------------------------------------------------------------------------
22
23macro_rules! string_id_newtype {
24    ($(#[$meta:meta])* $vis:vis struct $name:ident;) => {
25        $(#[$meta])*
26        #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27        #[serde(transparent)]
28        $vis struct $name(String);
29
30        impl $name {
31            /// Create a new instance from any `Into<String>` value.
32            pub fn new(s: impl Into<String>) -> Self {
33                Self(s.into())
34            }
35
36            /// Borrow the inner string slice.
37            pub fn as_str(&self) -> &str {
38                &self.0
39            }
40        }
41
42        impl fmt::Display for $name {
43            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44                f.write_str(&self.0)
45            }
46        }
47
48        impl From<&str> for $name {
49            fn from(s: &str) -> Self {
50                Self(s.to_owned())
51            }
52        }
53
54        impl From<String> for $name {
55            fn from(s: String) -> Self {
56                Self(s)
57            }
58        }
59    };
60}
61
62// ---------------------------------------------------------------------------
63// ProfileId
64// ---------------------------------------------------------------------------
65
66string_id_newtype! {
67    /// Stable internal identifier for an AWS credential profile.
68    ///
69    /// Minted as a UUID v4 (`ProfileId::new_v4()`) on first registration and
70    /// persisted in the local settings store. Two profiles may share a display
71    /// name but never share a `ProfileId`.
72    pub struct ProfileId;
73}
74
75impl ProfileId {
76    /// Mint a new `ProfileId` backed by a UUID v4.
77    pub fn new_v4() -> Self {
78        Self(Uuid::new_v4().to_string())
79    }
80}
81
82// ---------------------------------------------------------------------------
83// BucketId
84// ---------------------------------------------------------------------------
85
86string_id_newtype! {
87    /// Identifies an S3 bucket by its canonical name.
88    pub struct BucketId;
89}
90
91// ---------------------------------------------------------------------------
92// ObjectKey
93// ---------------------------------------------------------------------------
94
95string_id_newtype! {
96    /// Identifies an object within a bucket by its full S3 key path.
97    pub struct ObjectKey;
98}
99
100// ---------------------------------------------------------------------------
101// Tests
102// ---------------------------------------------------------------------------
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::collections::HashSet;
108
109    // --- ProfileId ---
110
111    #[test]
112    fn profile_id_new_v4_produces_valid_uuid() {
113        let id = ProfileId::new_v4();
114        let parsed =
115            Uuid::parse_str(id.as_str()).expect("ProfileId::new_v4 must yield a valid UUID");
116        assert_eq!(parsed.get_version_num(), 4, "must be a v4 UUID");
117    }
118
119    #[test]
120    fn profile_id_new_v4_produces_unique_values() {
121        let a = ProfileId::new_v4();
122        let b = ProfileId::new_v4();
123        assert_ne!(a, b, "two consecutive new_v4() calls must not collide");
124    }
125
126    #[test]
127    fn profile_id_from_str_round_trips() {
128        let raw = "my-profile";
129        let id = ProfileId::from(raw);
130        assert_eq!(id.as_str(), raw);
131    }
132
133    #[test]
134    fn profile_id_from_string_round_trips() {
135        let raw = String::from("my-profile");
136        let id = ProfileId::from(raw.clone());
137        assert_eq!(id.as_str(), &raw);
138    }
139
140    #[test]
141    fn profile_id_display_prints_inner() {
142        let id = ProfileId::new("display-test");
143        assert_eq!(id.to_string(), "display-test");
144    }
145
146    #[test]
147    fn profile_id_hash_works_in_hashset() {
148        let mut set: HashSet<ProfileId> = HashSet::new();
149        let id = ProfileId::new("abc");
150        set.insert(id.clone());
151        assert!(set.contains(&id));
152        assert_eq!(set.len(), 1);
153        // Inserting the same value again must not grow the set.
154        set.insert(ProfileId::new("abc"));
155        assert_eq!(set.len(), 1);
156    }
157
158    // --- BucketId ---
159
160    #[test]
161    fn bucket_id_from_and_as_str() {
162        let id = BucketId::from("my-bucket");
163        assert_eq!(id.as_str(), "my-bucket");
164    }
165
166    #[test]
167    fn bucket_id_display() {
168        let id = BucketId::new("my-bucket");
169        assert_eq!(id.to_string(), "my-bucket");
170    }
171
172    #[test]
173    fn bucket_id_hash_works_in_hashset() {
174        let mut set: HashSet<BucketId> = HashSet::new();
175        set.insert(BucketId::new("bucket-a"));
176        set.insert(BucketId::new("bucket-b"));
177        assert_eq!(set.len(), 2);
178        set.insert(BucketId::new("bucket-a"));
179        assert_eq!(set.len(), 2);
180    }
181
182    // --- ObjectKey ---
183
184    #[test]
185    fn object_key_from_and_as_str() {
186        let key = ObjectKey::from("path/to/object.txt");
187        assert_eq!(key.as_str(), "path/to/object.txt");
188    }
189
190    #[test]
191    fn object_key_display() {
192        let key = ObjectKey::new("folder/file.bin");
193        assert_eq!(key.to_string(), "folder/file.bin");
194    }
195
196    #[test]
197    fn object_key_hash_works_in_hashset() {
198        let mut set: HashSet<ObjectKey> = HashSet::new();
199        set.insert(ObjectKey::new("key1"));
200        set.insert(ObjectKey::new("key2"));
201        set.insert(ObjectKey::new("key1")); // duplicate
202        assert_eq!(set.len(), 2);
203    }
204
205    // --- Serde round-trips ---
206
207    #[test]
208    fn profile_id_serde_transparent() {
209        let id = ProfileId::new("serde-test");
210        let json = serde_json::to_string(&id).unwrap();
211        assert_eq!(json, r#""serde-test""#);
212        let back: ProfileId = serde_json::from_str(&json).unwrap();
213        assert_eq!(back, id);
214    }
215
216    #[test]
217    fn bucket_id_serde_transparent() {
218        let id = BucketId::new("my-bucket");
219        let json = serde_json::to_string(&id).unwrap();
220        assert_eq!(json, r#""my-bucket""#);
221        let back: BucketId = serde_json::from_str(&json).unwrap();
222        assert_eq!(back, id);
223    }
224
225    #[test]
226    fn object_key_serde_transparent() {
227        let key = ObjectKey::new("a/b/c.txt");
228        let json = serde_json::to_string(&key).unwrap();
229        assert_eq!(json, r#""a/b/c.txt""#);
230        let back: ObjectKey = serde_json::from_str(&json).unwrap();
231        assert_eq!(back, key);
232    }
233}