cuid2_rs/
lib.rs

1use rand::rngs::OsRng;
2use rand::{Rng, SeedableRng, TryRngCore};
3use rand_chacha::ChaCha20Rng;
4use sha3::{Digest, Sha3_512};
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8/// Default length for generated CUIDs
9pub const DEFAULT_LENGTH: usize = 24;
10/// Maximum length for generated CUIDs
11pub const MAX_LENGTH: usize = 32;
12/// Minimum length for valid CUIDs
13pub const MIN_LENGTH: usize = 2;
14/// Alphabet for generating random letters
15const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
16/// Counter for ensuring uniqueness
17static COUNTER: AtomicU64 = AtomicU64::new(0);
18
19/// Error type for CUID generation and validation
20#[derive(Debug)]
21pub enum CuidError {
22    InvalidLength(usize, usize, usize),
23    SystemTimeError(std::time::SystemTimeError),
24    RandChaChaError(rand_chacha::rand_core::OsError),
25}
26
27impl std::fmt::Display for CuidError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            CuidError::InvalidLength(len, min, max) => {
31                write!(
32                    f,
33                    "Invalid CUID length: {}, expected between {} and {}",
34                    len, min, max
35                )
36            }
37            CuidError::SystemTimeError(err) => {
38                write!(f, "System time error: {}", err)
39            }
40            CuidError::RandChaChaError(err) => {
41                write!(f, "ChaCha RNG error: {}", err)
42            }
43        }
44    }
45}
46
47impl std::error::Error for CuidError {}
48
49impl From<std::time::SystemTimeError> for CuidError {
50    fn from(err: std::time::SystemTimeError) -> Self {
51        CuidError::SystemTimeError(err)
52    }
53}
54
55impl From<rand_chacha::rand_core::OsError> for CuidError {
56    fn from(err: rand_chacha::rand_core::OsError) -> Self {
57        CuidError::RandChaChaError(err)
58    }
59}
60
61/// Result type for CUID operations
62pub type Result<T> = std::result::Result<T, CuidError>;
63
64/// Generates random alphanumeric entropy of a given length.
65fn generate_entropy(length: usize) -> Result<String> {
66    // Use OsRng to generate a random seed
67    let seed = OsRng.try_next_u64()?;
68    let mut rng = ChaCha20Rng::seed_from_u64(seed);
69
70    Ok((0..length)
71        .map(|_| char::from_digit(rng.random_range(0..36) as u32, 36).unwrap())
72        .collect())
73}
74
75/// Computes a SHA3-512 hash and returns a truncated hexadecimal string.
76fn compute_hash(input: &str, length: usize) -> String {
77    let mut hasher = Sha3_512::new();
78    hasher.update(input.as_bytes());
79    let result = hasher.finalize();
80    let hash_str = hex::encode(result);
81    hash_str[..length].to_string()
82}
83
84/// Generates a random lowercase letter.
85fn generate_random_letter() -> Result<char> {
86    // Use OsRng to generate a random seed
87    let seed = OsRng.try_next_u64()?;
88    let mut rng = ChaCha20Rng::seed_from_u64(seed);
89
90    Ok(ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
91}
92
93/// Creates a fingerprint to help prevent collisions in distributed systems.
94fn generate_fingerprint() -> Result<String> {
95    let entropy = generate_entropy(MAX_LENGTH)?;
96    Ok(compute_hash(&entropy, MAX_LENGTH))
97}
98
99/// Generates a unique identifier similar to CUID2.
100///
101/// # Arguments
102/// * `length` - The desired length of the CUID
103///
104/// # Returns
105/// * `Result<String>` - The generated CUID or an error
106///
107/// # Examples
108/// ```
109/// use cuid2_rs::generate_cuid;
110///
111/// let id = generate_cuid(24).unwrap();
112/// assert_eq!(id.len(), 24);
113/// ```
114pub fn generate_cuid(length: usize) -> Result<String> {
115    if !(MIN_LENGTH..=MAX_LENGTH).contains(&length) {
116        return Err(CuidError::InvalidLength(length, MIN_LENGTH, MAX_LENGTH));
117    }
118
119    let first_letter = generate_random_letter()?;
120    let timestamp = SystemTime::now()
121        .duration_since(UNIX_EPOCH)?
122        .as_millis()
123        .to_string();
124    let counter_value = COUNTER.fetch_add(1, Ordering::SeqCst).to_string();
125    let salt = generate_entropy(length)?;
126    let fingerprint = generate_fingerprint()?;
127
128    let hash_input = format!("{}{}{}{}", timestamp, salt, counter_value, fingerprint);
129    let hashed = compute_hash(&hash_input, length);
130
131    Ok(format!("{}{}", first_letter, &hashed[1..length]))
132}
133
134/// Generate a CUID with the default length
135///
136/// # Returns
137/// * `Result<String>` - The generated CUID or an error
138///
139/// # Examples
140/// ```
141/// use cuid2_rs::generate;
142///
143/// let id = generate().unwrap();
144/// assert_eq!(id.len(), cuid2_rs::DEFAULT_LENGTH);
145/// ```
146pub fn generate() -> Result<String> {
147    generate_cuid(DEFAULT_LENGTH)
148}
149
150/// Validates whether a given ID conforms to CUID2 format.
151///
152/// # Arguments
153/// * `id` - The ID to validate
154/// * `min_length` - Minimum acceptable length
155/// * * `max_length` - Maximum acceptable length
156///
157/// # Returns
158/// * `bool` - Whether the ID is valid
159///
160/// # Examples
161/// ```
162/// use cuid2_rs::{generate, is_valid_cuid, MIN_LENGTH, MAX_LENGTH};
163///
164/// let id = generate().unwrap();
165/// assert!(is_valid_cuid(&id, MIN_LENGTH, MAX_LENGTH));
166/// ```
167pub fn is_valid_cuid(id: &str, min_length: usize, max_length: usize) -> bool {
168    if id.is_empty() {
169        return false;
170    }
171
172    let first_char = id.chars().next().unwrap();
173    let starts_with_letter = first_char.is_ascii_lowercase();
174    let valid_format = id
175        .chars()
176        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
177    let valid_length = id.len() >= min_length && id.len() <= max_length;
178
179    starts_with_letter && valid_format && valid_length
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_generate_cuid() {
188        let id = generate_cuid(DEFAULT_LENGTH).unwrap();
189        assert!(is_valid_cuid(&id, MIN_LENGTH, MAX_LENGTH));
190        assert_eq!(id.len(), DEFAULT_LENGTH);
191    }
192
193    #[test]
194    fn test_generate_default() {
195        let id = generate().unwrap();
196        assert!(is_valid_cuid(&id, MIN_LENGTH, MAX_LENGTH));
197        assert_eq!(id.len(), DEFAULT_LENGTH);
198    }
199
200    #[test]
201    fn test_is_valid_cuid() {
202        let id = generate_cuid(DEFAULT_LENGTH).unwrap();
203        assert!(is_valid_cuid(&id, MIN_LENGTH, MAX_LENGTH));
204    }
205
206    #[test]
207    fn test_invalid_cuid_length() {
208        assert!(!is_valid_cuid("a", MIN_LENGTH, MAX_LENGTH));
209        assert!(!is_valid_cuid(
210            "a123456789012345678901234567890123",
211            MIN_LENGTH,
212            MAX_LENGTH
213        ));
214    }
215
216    #[test]
217    fn test_invalid_cuid_format() {
218        assert!(!is_valid_cuid("1abc123", MIN_LENGTH, MAX_LENGTH)); // Must start with a letter
219        assert!(!is_valid_cuid("abc-123", MIN_LENGTH, MAX_LENGTH)); // No special characters allowed
220    }
221
222    #[test]
223    fn test_generate_entropy() {
224        let entropy = generate_entropy(10).unwrap();
225        assert_eq!(entropy.len(), 10);
226        assert!(entropy.chars().all(|c| c.is_ascii_alphanumeric()));
227    }
228
229    #[test]
230    fn test_generate_random_letter() {
231        let letter = generate_random_letter().unwrap();
232        assert!(ALPHABET.contains(&(letter as u8)));
233    }
234
235    #[test]
236    fn test_compute_hash() {
237        let input = "test_string";
238        let hashed = compute_hash(input, 16);
239        assert_eq!(hashed.len(), 16);
240    }
241
242    #[test]
243    fn test_invalid_length_error() {
244        let result = generate_cuid(MAX_LENGTH + 1);
245        assert!(result.is_err());
246        match result {
247            Err(CuidError::InvalidLength(len, min, max)) => {
248                assert_eq!(len, MAX_LENGTH + 1);
249                assert_eq!(min, MIN_LENGTH);
250                assert_eq!(max, MAX_LENGTH);
251            }
252            _ => panic!("Expected InvalidLength error"),
253        }
254    }
255
256    #[test]
257    fn test_first_char_is_letter() {
258        for _ in 0..100 {
259            let id = generate().unwrap();
260            assert!(id.chars().next().unwrap().is_ascii_lowercase());
261        }
262    }
263}