Skip to main content

ix_id/
lib.rs

1use thiserror::Error;
2
3#[derive(Debug, Error)]
4pub enum IdError {
5    #[error("Invalid ID format: {0}")]
6    InvalidFormat(String),
7    #[error("Invalid hex in ID: {0}")]
8    InvalidHex(String),
9}
10
11const DEFAULT_HASH_BYTES: usize = 3;
12
13pub fn id_from_key(prefix: &str, key: &str) -> String {
14    id_from_key_with_length(prefix, key, DEFAULT_HASH_BYTES)
15}
16
17pub fn id_from_key_with_length(prefix: &str, key: &str, bytes: usize) -> String {
18    let hash = blake3::hash(key.as_bytes());
19    let hex = hex::encode(&hash.as_bytes()[..bytes]);
20    format!("{prefix}-{hex}")
21}
22
23pub fn id_from_parts(prefix: &str, parts: &[&str]) -> String {
24    id_from_parts_with_length(prefix, parts, DEFAULT_HASH_BYTES)
25}
26
27pub fn id_from_parts_with_length(prefix: &str, parts: &[&str], bytes: usize) -> String {
28    let key = parts.join(":");
29    id_from_key_with_length(prefix, &key, bytes)
30}
31
32pub fn id_random(prefix: &str) -> String {
33    id_random_with_length(prefix, DEFAULT_HASH_BYTES)
34}
35
36pub fn id_random_with_length(prefix: &str, bytes: usize) -> String {
37    let uuid = uuid::Uuid::new_v4();
38    let hash = blake3::hash(uuid.as_bytes());
39    let hex = hex::encode(&hash.as_bytes()[..bytes]);
40    format!("{prefix}-{hex}")
41}
42
43#[deprecated(
44    since = "0.2.0",
45    note = "Use id_random() for random IDs or id_from_key() for deterministic IDs"
46)]
47pub fn generate_id(prefix: &str) -> String {
48    id_random(prefix)
49}
50
51#[deprecated(
52    since = "0.2.0",
53    note = "Use id_random_with_length() for random IDs or id_from_key_with_length() for deterministic IDs"
54)]
55pub fn generate_id_with_length(prefix: &str, bytes: usize) -> String {
56    id_random_with_length(prefix, bytes)
57}
58
59#[deprecated(since = "0.2.0", note = "Use id_from_key() instead")]
60pub fn generate_content_id(prefix: &str, content: &str) -> String {
61    id_from_key(prefix, content)
62}
63
64#[deprecated(since = "0.2.0", note = "Use id_from_key_with_length() instead")]
65pub fn generate_content_id_with_length(prefix: &str, content: &str, bytes: usize) -> String {
66    id_from_key_with_length(prefix, content, bytes)
67}
68
69pub fn parse_id(id: &str) -> Result<(String, String), IdError> {
70    let parts: Vec<&str> = id.splitn(2, '-').collect();
71    if parts.len() != 2 {
72        return Err(IdError::InvalidFormat(id.to_string()));
73    }
74
75    let prefix = parts[0].to_string();
76    let hash = parts[1].to_string();
77
78    if hash.len() < 6 || hash.len() > 12 {
79        return Err(IdError::InvalidFormat(format!(
80            "Hash must be 6-12 characters, got {}",
81            hash.len()
82        )));
83    }
84
85    if !hash.chars().all(|c| c.is_ascii_hexdigit()) {
86        return Err(IdError::InvalidHex(hash));
87    }
88
89    Ok((prefix, hash))
90}
91
92#[macro_export]
93macro_rules! define_id {
94    ($name:ident, $prefix:expr) => {
95        #[derive(Debug, Clone, PartialEq, Eq, Hash)]
96        #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
97        pub struct $name(String);
98
99        impl $name {
100            /// Create ID from a natural key (deterministic - same key = same ID).
101            /// Use this for entities with unique identifiers like URLs, paths, or names.
102            ///
103            /// Example: `SourceId::from_key("facebook/react")`
104            pub fn from_key(key: &str) -> Self {
105                Self($crate::id_from_key($prefix, key))
106            }
107
108            /// Create ID from a natural key with custom hash length.
109            pub fn from_key_with_length(key: &str, bytes: usize) -> Self {
110                Self($crate::id_from_key_with_length($prefix, key, bytes))
111            }
112
113            /// Create ID from multiple key parts joined with `:` separator.
114            /// Use this for composite keys.
115            ///
116            /// Example: `DocId::from_parts(&[source_id.as_str(), "docs/intro.md"])`
117            pub fn from_parts(parts: &[&str]) -> Self {
118                Self($crate::id_from_parts($prefix, parts))
119            }
120
121            /// Create ID from multiple key parts with custom hash length.
122            pub fn from_parts_with_length(parts: &[&str], bytes: usize) -> Self {
123                Self($crate::id_from_parts_with_length($prefix, parts, bytes))
124            }
125
126            /// Generate a random ID (non-deterministic).
127            /// Use this only for entities without natural keys, like user-created items
128            /// where duplicates are intentionally allowed.
129            ///
130            /// Example: `IssueId::random()` for issues where same title is allowed.
131            pub fn random() -> Self {
132                Self($crate::id_random($prefix))
133            }
134
135            /// Generate a random ID with custom hash length.
136            pub fn random_with_length(bytes: usize) -> Self {
137                Self($crate::id_random_with_length($prefix, bytes))
138            }
139
140            /// Wrap an existing ID string. No validation performed.
141            pub fn from_string(s: impl Into<String>) -> Self {
142                Self(s.into())
143            }
144
145            /// Get the ID as a string slice.
146            pub fn as_str(&self) -> &str {
147                &self.0
148            }
149
150            /// Get the prefix for this ID type.
151            pub fn prefix() -> &'static str {
152                $prefix
153            }
154
155            // Deprecated methods for backwards compatibility
156
157            #[deprecated(
158                since = "0.2.0",
159                note = "Use random() for random IDs or from_key() for deterministic IDs"
160            )]
161            pub fn generate() -> Self {
162                Self::random()
163            }
164
165            #[deprecated(since = "0.2.0", note = "Use random_with_length() instead")]
166            pub fn generate_with_length(bytes: usize) -> Self {
167                Self::random_with_length(bytes)
168            }
169
170            #[deprecated(since = "0.2.0", note = "Use from_key() instead")]
171            pub fn from_content(content: &str) -> Self {
172                Self::from_key(content)
173            }
174
175            #[deprecated(since = "0.2.0", note = "Use from_key_with_length() instead")]
176            pub fn from_content_with_length(content: &str, bytes: usize) -> Self {
177                Self::from_key_with_length(content, bytes)
178            }
179        }
180
181        impl std::fmt::Display for $name {
182            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183                write!(f, "{}", self.0)
184            }
185        }
186
187        impl AsRef<str> for $name {
188            fn as_ref(&self) -> &str {
189                &self.0
190            }
191        }
192
193        impl From<$name> for String {
194            fn from(id: $name) -> Self {
195                id.0
196            }
197        }
198    };
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_id_from_key_deterministic() {
207        let id1 = id_from_key("src", "facebook/react");
208        let id2 = id_from_key("src", "facebook/react");
209        assert_eq!(id1, id2);
210    }
211
212    #[test]
213    fn test_id_from_key_different_keys() {
214        let id1 = id_from_key("src", "facebook/react");
215        let id2 = id_from_key("src", "vercel/next.js");
216        assert_ne!(id1, id2);
217    }
218
219    #[test]
220    fn test_id_from_key_format() {
221        let id = id_from_key("src", "facebook/react");
222        assert!(id.starts_with("src-"));
223        let expected_len = "src-".len() + 6;
224        assert_eq!(id.len(), expected_len);
225    }
226
227    #[test]
228    fn test_id_from_parts() {
229        let source_id = "src-abc123";
230        let path = "docs/intro.md";
231        let id = id_from_parts("doc", &[source_id, path]);
232        assert!(id.starts_with("doc-"));
233    }
234
235    #[test]
236    fn test_id_from_parts_deterministic() {
237        let id1 = id_from_parts("doc", &["src-abc123", "docs/intro.md"]);
238        let id2 = id_from_parts("doc", &["src-abc123", "docs/intro.md"]);
239        assert_eq!(id1, id2);
240    }
241
242    #[test]
243    fn test_id_from_parts_order_matters() {
244        let id1 = id_from_parts("doc", &["a", "b"]);
245        let id2 = id_from_parts("doc", &["b", "a"]);
246        assert_ne!(id1, id2);
247    }
248
249    #[test]
250    fn test_id_random_unique() {
251        let id1 = id_random("bd");
252        let id2 = id_random("bd");
253        assert_ne!(id1, id2);
254    }
255
256    #[test]
257    fn test_id_random_format() {
258        let id = id_random("bd");
259        assert!(id.starts_with("bd-"));
260        let expected_len = "bd-".len() + 6;
261        assert_eq!(id.len(), expected_len);
262    }
263
264    #[test]
265    fn test_id_with_length() {
266        let id = id_from_key_with_length("src", "test", 4);
267        let expected_len = "src-".len() + 8;
268        assert_eq!(id.len(), expected_len);
269    }
270
271    #[test]
272    fn test_parse_id() {
273        let (prefix, hash) = parse_id("bd-a1b2c3").unwrap();
274        assert_eq!(prefix, "bd");
275        assert_eq!(hash, "a1b2c3");
276    }
277
278    #[test]
279    fn test_parse_id_invalid_no_separator() {
280        assert!(parse_id("invalid").is_err());
281    }
282
283    #[test]
284    fn test_parse_id_invalid_hash_too_short() {
285        assert!(parse_id("bd-abc").is_err());
286    }
287
288    #[test]
289    fn test_parse_id_invalid_non_hex_chars() {
290        assert!(parse_id("bd-xyz123").is_err());
291    }
292
293    define_id!(SourceId, "src");
294    define_id!(DocId, "doc");
295    define_id!(IssueId, "bd");
296
297    #[test]
298    fn test_typed_id_from_key() {
299        let id = SourceId::from_key("facebook/react");
300        assert!(id.as_str().starts_with("src-"));
301    }
302
303    #[test]
304    fn test_typed_id_from_key_deterministic() {
305        let id1 = SourceId::from_key("facebook/react");
306        let id2 = SourceId::from_key("facebook/react");
307        assert_eq!(id1, id2);
308    }
309
310    #[test]
311    fn test_typed_id_from_parts() {
312        let source = SourceId::from_key("facebook/react");
313        let doc = DocId::from_parts(&[source.as_str(), "docs/hooks.md"]);
314        assert!(doc.as_str().starts_with("doc-"));
315    }
316
317    #[test]
318    fn test_typed_id_random() {
319        let id1 = IssueId::random();
320        let id2 = IssueId::random();
321        assert!(id1.as_str().starts_with("bd-"));
322        assert_ne!(id1, id2);
323    }
324
325    #[test]
326    fn test_typed_id_prefix() {
327        assert_eq!(SourceId::prefix(), "src");
328        assert_eq!(DocId::prefix(), "doc");
329        assert_eq!(IssueId::prefix(), "bd");
330    }
331}