Skip to main content

memorable_ids/
lib.rs

1//! Memorable ID Generator
2//!
3//! A flexible library for generating human-readable, memorable identifiers.
4//! Uses combinations of adjectives, nouns, verbs, adverbs, and prepositions
5//! with optional numeric/custom suffixes.
6//!
7//! @author Aris Ripandi
8//! @license MIT
9
10use rand::RngExt;
11use serde::{Deserialize, Serialize};
12use std::time::{SystemTime, UNIX_EPOCH};
13use thiserror::Error;
14
15pub mod dictionary;
16
17use dictionary::{ADJECTIVES, ADVERBS, NOUNS, PREPOSITIONS, VERBS};
18
19/// Word arrays indexed by component position (adjective → noun → verb → adverb → preposition)
20const COMPONENT_ARRAYS: &[&[&str]] =
21    &[ADJECTIVES, NOUNS, VERBS, ADVERBS, PREPOSITIONS];
22
23/// Dictionary sizes for combination math (computed at compile time)
24const COMPONENT_SIZES: [u64; 5] = [
25    ADJECTIVES.len() as u64,
26    NOUNS.len() as u64,
27    VERBS.len() as u64,
28    ADVERBS.len() as u64,
29    PREPOSITIONS.len() as u64,
30];
31
32/// Error types for memorable ID operations
33#[derive(Error, Debug)]
34pub enum MemorableIdError {
35    #[error("Components must be between 1 and 5, got {0}")]
36    InvalidComponentCount(usize),
37    #[error("Invalid separator: cannot be empty")]
38    InvalidSeparator,
39    #[error("Failed to parse ID: {0}")]
40    ParseError(String),
41}
42
43/// Type alias for suffix generator function
44pub type SuffixGenerator = fn() -> Option<String>;
45
46/// Configuration options for ID generation
47#[derive(Debug, Clone)]
48pub struct GenerateOptions {
49    /// Number of word components (1-5, default: 2)
50    pub components: usize,
51    /// Suffix generator function (default: None)
52    pub suffix: Option<SuffixGenerator>,
53    /// Separator between parts (default: "-")
54    pub separator: String,
55}
56
57impl Default for GenerateOptions {
58    fn default() -> Self {
59        Self {
60            components: 2,
61            suffix: None,
62            separator: "-".to_string(),
63        }
64    }
65}
66
67/// Parsed ID components structure
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct ParsedId {
70    /// Array of word components
71    pub components: Vec<String>,
72    /// Suffix part if detected, None otherwise
73    pub suffix: Option<String>,
74}
75
76/// Collision scenario analysis
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CollisionScenario {
79    /// Number of IDs in scenario
80    pub ids: usize,
81    /// Collision probability (0-1)
82    pub probability: f64,
83    /// Formatted percentage string
84    pub percentage: String,
85}
86
87/// Collision analysis result
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CollisionAnalysis {
90    /// Total possible combinations
91    pub total_combinations: u64,
92    /// Array of collision scenarios
93    pub scenarios: Vec<CollisionScenario>,
94}
95
96/// Generate a memorable ID
97///
98/// # Arguments
99/// * `options` - Configuration options
100///
101/// # Returns
102/// Generated memorable ID
103///
104/// # Examples
105/// ```rust
106/// use memorable_ids::{generate, GenerateOptions, suffix_generators};
107///
108/// // Default: 2 components, no suffix
109/// let id = generate(GenerateOptions::default()).unwrap();
110/// // Example: "cute-rabbit"
111///
112/// // 3 components
113/// let id = generate(GenerateOptions {
114///     components: 3,
115///     ..Default::default()
116/// }).unwrap();
117/// // Example: "large-fox-swim"
118///
119/// // With numeric suffix
120/// let id = generate(GenerateOptions {
121///     components: 2,
122///     suffix: Some(suffix_generators::number),
123///     ..Default::default()
124/// }).unwrap();
125/// // Example: "quick-mouse-042"
126///
127/// // Custom separator
128/// let id = generate(GenerateOptions {
129///     components: 2,
130///     separator: "_".to_string(),
131///     ..Default::default()
132/// }).unwrap();
133/// // Example: "warm_duck"
134/// ```
135pub fn generate(options: GenerateOptions) -> Result<String, MemorableIdError> {
136    if options.components < 1 || options.components > 5 {
137        return Err(MemorableIdError::InvalidComponentCount(
138            options.components,
139        ));
140    }
141
142    if options.separator.is_empty() {
143        return Err(MemorableIdError::InvalidSeparator);
144    }
145
146    let mut rng = rand::rng();
147    let component_count = options.components;
148
149    // Pre-allocate: ~8 chars per word + separators
150    let mut result = String::with_capacity(
151        component_count * 8 + options.separator.len() * component_count,
152    );
153
154    for i in 0..component_count {
155        if i > 0 {
156            result.push_str(&options.separator);
157        }
158        let array = COMPONENT_ARRAYS[i];
159        let index = rng.random_range(0..array.len());
160        result.push_str(array[index]);
161    }
162
163    if let Some(suffix_fn) = options.suffix {
164        if let Some(suffix_value) = suffix_fn() {
165            result.push_str(&options.separator);
166            result.push_str(&suffix_value);
167        }
168    }
169
170    Ok(result)
171}
172
173/// Default suffix generator - random 3-digit number
174///
175/// # Returns
176/// Random number suffix (000-999)
177///
178/// # Examples
179/// ```rust
180/// use memorable_ids::default_suffix;
181///
182/// let suffix = default_suffix().unwrap(); // "042"
183/// let suffix = default_suffix().unwrap(); // "789"
184/// ```
185pub fn default_suffix() -> Option<String> {
186    suffix_generators::number()
187}
188
189/// Parse a memorable ID back to its components
190///
191/// # Arguments
192/// * `id` - The memorable ID to parse
193/// * `separator` - Separator used (default: "-")
194///
195/// # Returns
196/// Parsed components with structure
197///
198/// # Examples
199/// ```rust
200/// use memorable_ids::parse;
201///
202/// let parsed = parse("cute-rabbit-042", "-").unwrap();
203/// // ParsedId { components: ["cute", "rabbit"], suffix: Some("042") }
204///
205/// let parsed = parse("large-fox-swim", "-").unwrap();
206/// // ParsedId { components: ["large", "fox", "swim"], suffix: None }
207/// ```
208pub fn parse(id: &str, separator: &str) -> Result<ParsedId, MemorableIdError> {
209    if id.is_empty() {
210        return Err(MemorableIdError::ParseError(
211            "ID cannot be empty".to_string(),
212        ));
213    }
214
215    let parts: Vec<&str> = id.split(separator).collect();
216
217    if parts.is_empty() {
218        return Err(MemorableIdError::ParseError("No parts found".to_string()));
219    }
220
221    // Last part is suffix when fully numeric (e.g. "cute-rabbit-042")
222    if let Some(last) = parts.last() {
223        if last.chars().all(|c| c.is_ascii_digit()) {
224            return Ok(ParsedId {
225                components: parts[..parts.len() - 1]
226                    .iter()
227                    .map(|s| (*s).to_string())
228                    .collect(),
229                suffix: Some((*last).to_string()),
230            });
231        }
232    }
233
234    Ok(ParsedId {
235        components: parts.iter().map(|s| (*s).to_string()).collect(),
236        suffix: None,
237    })
238}
239
240/// Calculate total possible combinations for given configuration
241///
242/// # Arguments
243/// * `components` - Number of word components (1-5)
244/// * `suffix_range` - Range of suffix values (default: 1 for no suffix)
245///
246/// # Returns
247/// Total possible unique combinations
248///
249/// # Examples
250/// ```rust
251/// use memorable_ids::calculate_combinations;
252///
253/// let total = calculate_combinations(2, 1); // 5,304 (2 components, no suffix)
254/// let total = calculate_combinations(2, 1000); // 5,304,000 (2 components + 3-digit suffix)
255/// let total = calculate_combinations(3, 1); // 212,160 (3 components, no suffix)
256/// ```
257pub fn calculate_combinations(components: usize, suffix_range: u64) -> u64 {
258    let mut total = 1u64;
259    for &size in &COMPONENT_SIZES[..components.min(5)] {
260        total = total.saturating_mul(size);
261    }
262
263    total.saturating_mul(suffix_range)
264}
265
266/// Calculate collision probability using Birthday Paradox
267///
268/// # Arguments
269/// * `total_combinations` - Total possible combinations
270/// * `generated_ids` - Number of IDs to generate
271///
272/// # Returns
273/// Collision probability (0-1)
274///
275/// # Examples
276/// ```rust
277/// use memorable_ids::calculate_collision_probability;
278///
279/// // For 2 components (5,304 total), generating 100 IDs
280/// let prob = calculate_collision_probability(5304, 100); // ~0.0093 (0.93%)
281///
282/// // For 3 components (212,160 total), generating 10,000 IDs
283/// let prob = calculate_collision_probability(212160, 10000); // ~0.00235 (0.235%)
284/// ```
285pub fn calculate_collision_probability(
286    total_combinations: u64,
287    generated_ids: usize,
288) -> f64 {
289    if generated_ids >= total_combinations as usize {
290        return 1.0;
291    }
292    if generated_ids <= 1 {
293        return 0.0;
294    }
295
296    // Birthday paradox approximation: 1 - e^(-n²/2N)
297    let n = generated_ids as f64;
298    let total = total_combinations as f64;
299    let exponent = -(n * n) / (2.0 * total);
300    1.0 - exponent.exp()
301}
302
303/// Get collision analysis for different ID generation scenarios
304///
305/// # Arguments
306/// * `components` - Number of components
307/// * `suffix_range` - Suffix range (1 for no suffix)
308///
309/// # Returns
310/// Analysis with total combinations and collision probabilities
311///
312/// # Examples
313/// ```rust
314/// use memorable_ids::get_collision_analysis;
315///
316/// let analysis = get_collision_analysis(2, 1);
317/// // CollisionAnalysis {
318/// //   total_combinations: 5304,
319/// //   scenarios: [
320/// //     CollisionScenario { ids: 100, probability: 0.0093, percentage: "0.93%" },
321/// //     CollisionScenario { ids: 500, probability: 0.218, percentage: "21.8%" },
322/// //     ...
323/// //   ]
324/// // }
325/// ```
326pub fn get_collision_analysis(
327    components: usize,
328    suffix_range: u64,
329) -> CollisionAnalysis {
330    let total = calculate_combinations(components, suffix_range);
331    let test_sizes = [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000];
332
333    let scenarios: Vec<CollisionScenario> = test_sizes
334        .iter()
335        .filter(|&&size| (size as u64) < (total * 80 / 100)) // Only show realistic scenarios
336        .map(|&size| {
337            let probability = calculate_collision_probability(total, size);
338            CollisionScenario {
339                ids: size,
340                probability,
341                percentage: format!("{:.2}%", probability * 100.0),
342            }
343        })
344        .collect();
345
346    CollisionAnalysis {
347        total_combinations: total,
348        scenarios,
349    }
350}
351
352/// Collection of predefined suffix generators
353pub mod suffix_generators {
354    use super::*;
355
356    fn padded_decimal(value: u32, width: usize) -> String {
357        format!("{:0width$}", value, width = width)
358    }
359
360    /// Random 3-digit number (000-999)
361    /// Adds 1,000x multiplier to total combinations
362    pub fn number() -> Option<String> {
363        let mut rng = rand::rng();
364        Some(padded_decimal(rng.random_range(0..1000), 3))
365    }
366
367    /// Random 4-digit number (0000-9999)
368    /// Adds 10,000x multiplier to total combinations
369    pub fn number4() -> Option<String> {
370        let mut rng = rand::rng();
371        Some(padded_decimal(rng.random_range(0..10000), 4))
372    }
373
374    /// Random 2-digit hex (00-ff)
375    /// Adds 256x multiplier to total combinations
376    pub fn hex() -> Option<String> {
377        let mut rng = rand::rng();
378        Some(format!("{:02x}", rng.random_range(0..256)))
379    }
380
381    /// Last 4 digits of current timestamp
382    /// Adds ~10,000x multiplier (time-based, not truly random)
383    pub fn timestamp() -> Option<String> {
384        let now = SystemTime::now()
385            .duration_since(UNIX_EPOCH)
386            .unwrap_or_default()
387            .as_millis();
388        Some(padded_decimal((now % 10000) as u32, 4))
389    }
390
391    /// Random lowercase letter (a-z)
392    /// Adds 26x multiplier to total combinations
393    pub fn letter() -> Option<String> {
394        let mut rng = rand::rng();
395        let letter = (b'a' + rng.random_range(0..26)) as char;
396        Some(letter.to_string())
397    }
398}
399
400// Re-export dictionary for external use
401pub use dictionary::{
402    get_dictionary, get_dictionary_stats, Dictionary, DictionaryStats,
403};