Skip to main content

nodedb_types/
id_gen.rs

1//! First-class ID type generation and validation.
2//!
3//! Supports: UUID v4, UUID v7, ULID, CUID2, NanoID.
4//!
5//! All IDs are represented as strings for JSON compatibility.
6//! UUID v7 and ULID are time-sortable (lexicographic order = chronological order).
7
8use rand::Rng;
9
10// ── UUID ──
11
12/// Generate a random UUID v4 (128-bit, not time-sortable).
13pub fn uuid_v4() -> String {
14    uuid::Uuid::new_v4().to_string()
15}
16
17/// Generate a time-sorted UUID v7 (128-bit, time-sortable).
18///
19/// Embeds a Unix millisecond timestamp in the high bits, so lexicographic
20/// sort order matches insertion order. Recommended for primary keys.
21pub fn uuid_v7() -> String {
22    uuid::Uuid::now_v7().to_string()
23}
24
25/// Validate whether a string is a valid UUID (any version).
26pub fn is_uuid(s: &str) -> bool {
27    uuid::Uuid::parse_str(s).is_ok()
28}
29
30/// Extract the version from a UUID string (1-7, or 0 if invalid).
31pub fn uuid_version(s: &str) -> u8 {
32    uuid::Uuid::parse_str(s)
33        .map(|u| u.get_version_num())
34        .unwrap_or(0) as u8
35}
36
37// ── ULID ──
38
39/// Generate a ULID (Universally Unique Lexicographically Sortable Identifier).
40///
41/// 128-bit: 48-bit timestamp (ms) + 80-bit random. Crockford Base32 encoded.
42/// Time-sortable: lexicographic order = chronological order.
43pub fn ulid() -> String {
44    ulid::Ulid::new().to_string()
45}
46
47/// Validate whether a string is a valid ULID.
48pub fn is_ulid(s: &str) -> bool {
49    ulid::Ulid::from_string(s).is_ok()
50}
51
52/// Extract the millisecond timestamp from a ULID.
53pub fn ulid_timestamp_ms(s: &str) -> Option<u64> {
54    ulid::Ulid::from_string(s).ok().map(|u| u.timestamp_ms())
55}
56
57// ── CUID2 ──
58
59/// Generate a CUID2 (Collision-resistant Unique Identifier v2).
60///
61/// Variable length (default 24 chars), cryptographically random, starts with
62/// a letter (safe for HTML IDs, CSS selectors, database keys).
63///
64/// Implemented inline — no external crate dependency.
65pub fn cuid2() -> String {
66    cuid2_with_length(24)
67}
68
69/// Generate a CUID2 with custom length (min 4, max 64).
70pub fn cuid2_with_length(length: usize) -> String {
71    let length = length.clamp(4, 64);
72    let mut rng = rand::rng();
73
74    // CUID2 always starts with a lowercase letter.
75    let first = (b'a' + rng.random_range(0..26)) as char;
76
77    // Remaining characters are from a base36-like alphabet.
78    const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
79    let rest: String = (1..length)
80        .map(|_| {
81            let idx = rng.random_range(0..ALPHABET.len());
82            ALPHABET[idx] as char
83        })
84        .collect();
85
86    format!("{first}{rest}")
87}
88
89/// Validate whether a string looks like a CUID2 (starts with letter, alphanumeric).
90pub fn is_cuid2(s: &str) -> bool {
91    if s.len() < 4 {
92        return false;
93    }
94    let first = s.as_bytes()[0];
95    if !first.is_ascii_lowercase() {
96        return false;
97    }
98    s.bytes().all(|b| b.is_ascii_alphanumeric())
99}
100
101// ── NanoID ──
102
103/// Generate a NanoID (URL-friendly unique string identifier).
104///
105/// Default 21 characters using `A-Za-z0-9_-` alphabet.
106/// ~149 bits of entropy — comparable to UUID v4.
107pub fn nanoid() -> String {
108    nanoid::nanoid!()
109}
110
111/// Generate a NanoID with custom length.
112pub fn nanoid_with_length(length: usize) -> String {
113    nanoid::nanoid!(length)
114}
115
116/// Validate whether a string looks like a NanoID (URL-safe characters, reasonable length).
117pub fn is_nanoid(s: &str) -> bool {
118    if s.is_empty() || s.len() > 128 {
119        return false;
120    }
121    s.bytes()
122        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
123}
124
125// ── Generic ID helpers ──
126
127/// Detect the type of a string ID.
128///
129/// Returns one of: "uuid", "ulid", "cuid2", "nanoid", "unknown".
130/// Checks in order of specificity (UUID and ULID have strict formats).
131pub fn detect_id_type(s: &str) -> &'static str {
132    if is_uuid(s) {
133        "uuid"
134    } else if is_ulid(s) {
135        "ulid"
136    } else if is_cuid2(s) && s.len() >= 20 {
137        "cuid2"
138    } else if is_nanoid(s) {
139        "nanoid"
140    } else {
141        "unknown"
142    }
143}
144
145/// Generate an ID by type name.
146///
147/// Supported types: `"uuidv7"`, `"uuidv4"`, `"ulid"`, `"cuid2"`, `"nanoid"`.
148/// Returns `None` for unknown types.
149pub fn generate_by_type(id_type: &str) -> Option<String> {
150    match id_type {
151        "uuidv7" => Some(uuid_v7()),
152        "uuidv4" => Some(uuid_v4()),
153        "ulid" => Some(ulid()),
154        "cuid2" => Some(cuid2()),
155        "nanoid" => Some(nanoid()),
156        _ => None,
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn uuid_v4_valid() {
166        let id = uuid_v4();
167        assert!(is_uuid(&id));
168        assert_eq!(uuid_version(&id), 4);
169        assert_eq!(id.len(), 36); // 8-4-4-4-12 with hyphens
170    }
171
172    #[test]
173    fn uuid_v7_valid_and_sortable() {
174        let id1 = uuid_v7();
175        std::thread::sleep(std::time::Duration::from_millis(2));
176        let id2 = uuid_v7();
177        assert!(is_uuid(&id1));
178        assert!(is_uuid(&id2));
179        assert_eq!(uuid_version(&id1), 7);
180        // v7 UUIDs are time-sortable: id1 < id2 lexicographically.
181        assert!(id1 < id2, "v7 should be time-sortable: {id1} < {id2}");
182    }
183
184    #[test]
185    fn ulid_valid_and_sortable() {
186        let id1 = ulid();
187        std::thread::sleep(std::time::Duration::from_millis(2));
188        let id2 = ulid();
189        assert!(is_ulid(&id1));
190        assert!(is_ulid(&id2));
191        assert_eq!(id1.len(), 26); // Crockford Base32
192        assert!(id1 < id2, "ULID should be time-sortable: {id1} < {id2}");
193    }
194
195    #[test]
196    fn ulid_timestamp() {
197        let id = ulid();
198        let ts = ulid_timestamp_ms(&id).unwrap();
199        let now_ms = std::time::SystemTime::now()
200            .duration_since(std::time::UNIX_EPOCH)
201            .unwrap()
202            .as_millis() as u64;
203        assert!(ts <= now_ms);
204        assert!(now_ms - ts < 1000); // within 1 second
205    }
206
207    #[test]
208    fn cuid2_valid() {
209        let id = cuid2();
210        assert!(is_cuid2(&id));
211        assert_eq!(id.len(), 24);
212        assert!(id.as_bytes()[0].is_ascii_lowercase()); // starts with letter
213    }
214
215    #[test]
216    fn cuid2_custom_length() {
217        let short = cuid2_with_length(8);
218        assert_eq!(short.len(), 8);
219        assert!(is_cuid2(&short));
220
221        let long = cuid2_with_length(48);
222        assert_eq!(long.len(), 48);
223        assert!(is_cuid2(&long));
224    }
225
226    #[test]
227    fn cuid2_uniqueness() {
228        let mut ids: Vec<String> = (0..1000).map(|_| cuid2()).collect();
229        ids.sort();
230        ids.dedup();
231        assert_eq!(ids.len(), 1000); // all unique
232    }
233
234    #[test]
235    fn nanoid_valid() {
236        let id = nanoid();
237        assert!(is_nanoid(&id));
238        assert_eq!(id.len(), 21);
239    }
240
241    #[test]
242    fn nanoid_custom_length() {
243        let id = nanoid_with_length(32);
244        assert!(is_nanoid(&id));
245        assert_eq!(id.len(), 32);
246    }
247
248    #[test]
249    fn detect_types() {
250        assert_eq!(detect_id_type(&uuid_v4()), "uuid");
251        assert_eq!(detect_id_type(&uuid_v7()), "uuid");
252        assert_eq!(detect_id_type(&ulid()), "ulid");
253        assert_eq!(detect_id_type("not-a-valid-id!@#"), "unknown");
254    }
255
256    #[test]
257    fn is_uuid_rejects_invalid() {
258        assert!(!is_uuid("not-a-uuid"));
259        assert!(!is_uuid(""));
260        assert!(!is_uuid("12345"));
261    }
262
263    #[test]
264    fn is_ulid_rejects_invalid() {
265        assert!(!is_ulid("not-a-ulid"));
266        assert!(!is_ulid(""));
267        assert!(!is_ulid(&uuid_v4())); // UUID is not ULID
268    }
269}