//! Generate temp project directory names (P-NAME-1, ADR-0015 ยง2). //! //! Output pattern: `-[temp]---` //! where the three words are distinct picks from a small //! built-in wordlist compiled into the binary. The literal //! `[temp]` segment is what marks a directory as an //! auto-named temporary project โ€” `validate_user_name` rejects //! `[` and `]` for user-supplied names, so the marker can //! never collide with a saved project's directory. //! //! Collisions against existing entries in the data root are //! detected and the slug is regenerated; we cap retries at a //! generous number to turn the theoretical never-give-up loop //! into a clean failure if something is profoundly wrong //! (e.g. wordlist inadvertently truncated to a handful of //! items). use std::path::Path; use rand::Rng; use rand::seq::IndexedRandom; const WORDLIST: &str = include_str!("wordlist.txt"); const MAX_COLLISION_RETRIES: usize = 100; /// All non-empty, non-comment lines from the wordlist. fn words() -> Vec<&'static str> { WORDLIST .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) .collect() } #[derive(Debug)] pub enum NamingError { WordlistTooSmall(usize), TooManyCollisions(usize), } impl std::fmt::Display for NamingError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::WordlistTooSmall(n) => { f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,)) } Self::TooManyCollisions(n) => f.write_str(&crate::t!( "project.naming.too_many_collisions", attempts = n, )), } } } impl std::error::Error for NamingError {} /// Generate a fresh temp project directory name. /// /// Checks for collisions against `parent_dir` (typically /// `/projects/`). The `today` callback returns the /// `YYYYMMDD` prefix; injecting it makes the function /// deterministic in tests. /// /// Returns `Err(WordlistTooSmall)` if the wordlist contains /// fewer than three entries; returns `Err(TooManyCollisions)` /// only if `MAX_COLLISION_RETRIES` regenerations all collided /// (effectively impossible with a healthy wordlist). pub fn generate_temp_name( rng: &mut R, parent_dir: &Path, today: impl Fn() -> String, ) -> Result { let pool = words(); if pool.len() < 3 { return Err(NamingError::WordlistTooSmall(pool.len())); } for _ in 0..MAX_COLLISION_RETRIES { let date = today(); let slug = three_distinct_words(rng, &pool); let candidate = format!("{date}-{TEMP_MARKER}-{slug}"); if !parent_dir.join(&candidate).exists() { return Ok(candidate); } } Err(NamingError::TooManyCollisions(MAX_COLLISION_RETRIES)) } /// Literal segment marking a directory as a temp project. /// /// Distinguishes auto-named temp projects from user-named /// ones in the directory listing. Brackets are reserved /// (rejected by `validate_user_name`) so this never collides /// with a saved project's name. pub const TEMP_MARKER: &str = "[temp]"; /// Pick three distinct words from the pool and join them with /// `-`. Uses `choose_multiple` so the picks are always distinct /// without needing manual deduplication. fn three_distinct_words(rng: &mut R, pool: &[&'static str]) -> String { let chosen: Vec<&str> = pool.sample(rng, 3).copied().collect(); chosen.join("-") } /// `YYYYMMDD` for the local date today. /// /// Suitable as the default `today` callback for production use. #[must_use] pub fn today_local() -> String { // We intentionally don't take a chrono dep just for this; // a SystemTime split into Y/M/D is enough. let now = std::time::SystemTime::now(); let secs = now .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let (y, m, d) = ymd_from_unix_secs(secs); format!("{y:04}{m:02}{d:02}") } /// Convert Unix seconds to a (year, month, day) tuple. /// /// Local time would be the proper choice; we use UTC to avoid /// pulling a timezone crate, accepting that on the day /// boundary a temp project may be tagged with the previous (or /// next) UTC day. Names are still unique and sortable. const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) { // Algorithm from Howard Hinnant's "civil_from_days" โ€” a // well-known closed-form conversion that doesn't need // chrono. https://howardhinnant.github.io/date_algorithms.html let days = secs.div_euclid(86_400); let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; (y as u32, m as u32, d as u32) } /// Is this an auto-named temp project directory? /// /// We look for the literal `[temp]` segment in the /// dash-separated dirname. Because brackets are reserved /// (rejected by `validate_user_name`), this is unambiguous: a /// user-named project can never produce a directory whose /// name contains `[temp]`. Dir-name-only check keeps the /// load picker fast โ€” no YAML parse needed per project. #[must_use] pub fn is_temp_dirname(dirname: &str) -> bool { dirname.split('-').any(|seg| seg == TEMP_MARKER) } /// Validate a user-supplied project directory name. /// /// Returns `Ok(())` if the name is acceptable, or an error /// describing why not. We deliberately stay conservative: /// alphanumerics, `-`, `_`, and `.` only. No path separators, /// no leading dot, no empty. pub fn validate_user_name(name: &str) -> Result<(), UserNameError> { if name.is_empty() { return Err(UserNameError::Empty); } if name.starts_with('.') { return Err(UserNameError::LeadingDot); } for c in name.chars() { if !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { return Err(UserNameError::InvalidChar(c)); } } Ok(()) } #[derive(Debug, PartialEq, Eq)] pub enum UserNameError { Empty, LeadingDot, InvalidChar(char), } impl std::fmt::Display for UserNameError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Empty => f.write_str(&crate::t!("project.user_name.empty")), Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")), Self::InvalidChar(c) => { f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,)) } } } } impl std::error::Error for UserNameError {} #[cfg(test)] mod tests { use super::*; use rand::SeedableRng; use rand::rngs::StdRng; use std::fs; #[test] fn wordlist_has_enough_entries() { let pool = words(); assert!( pool.len() >= 100, "wordlist suspiciously small: {} entries", pool.len() ); } #[test] fn wordlist_has_no_duplicates() { let pool = words(); let unique: std::collections::HashSet<_> = pool.iter().collect(); assert_eq!( unique.len(), pool.len(), "wordlist contains duplicate entries" ); } #[test] fn wordlist_is_lowercase_kebab_safe() { for w in words() { assert!( w.chars().all(|c| c.is_ascii_lowercase()), "wordlist entry {w:?} should be all-lowercase ASCII" ); } } #[test] fn generates_well_formed_name() { let tmp = tempdir(); let mut rng = StdRng::seed_from_u64(42); let name = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap(); assert!(name.starts_with("20260507-[temp]-"), "got: {name}"); // After the date and the [temp] marker, three distinct // word segments separated by `-`. let segments: Vec<&str> = name.split('-').collect(); assert_eq!(segments[0], "20260507"); assert_eq!(segments[1], "[temp]"); let words_in_name: std::collections::HashSet<_> = segments[2..].iter().collect(); assert_eq!(words_in_name.len(), 3, "words must be distinct: {name}"); assert!(is_temp_dirname(&name)); } #[test] fn detects_collision_and_regenerates() { let tmp = tempdir(); let mut rng = StdRng::seed_from_u64(1); let first = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap(); fs::create_dir(tmp.path().join(&first)).unwrap(); // Use the same seed: the first call would deterministically // produce `first` again. After the collision check it // regenerates and yields something different. let mut rng = StdRng::seed_from_u64(1); let second = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap(); assert_ne!(first, second, "should have regenerated past the collision"); } #[test] fn ymd_from_known_unix_seconds() { // 2026-05-07 00:00:00 UTC = 1778112000. assert_eq!(ymd_from_unix_secs(1_778_112_000), (2026, 5, 7)); // Epoch. assert_eq!(ymd_from_unix_secs(0), (1970, 1, 1)); // 2000-01-01. assert_eq!(ymd_from_unix_secs(946_684_800), (2000, 1, 1)); // 2024-02-29 (leap day, sanity check). assert_eq!(ymd_from_unix_secs(1_709_164_800), (2024, 2, 29)); } #[test] fn today_local_format() { let s = today_local(); assert_eq!(s.len(), 8); assert!(s.chars().all(|c| c.is_ascii_digit()), "today_local: {s}"); } #[test] fn is_temp_dirname_examples() { // Generated form. assert!(is_temp_dirname("20260507-[temp]-water-buffalo-skating")); assert!(is_temp_dirname("19991231-[temp]-amber-cosmic-flying")); // Named projects: cannot contain `[temp]` because // validate_user_name rejects brackets. assert!(!is_temp_dirname("MyOrders")); assert!(!is_temp_dirname("term_planner")); assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker assert!(!is_temp_dirname("temp-project")); // no brackets } #[test] fn validates_user_name() { assert!(validate_user_name("MyProject").is_ok()); assert!(validate_user_name("my-project").is_ok()); assert!(validate_user_name("my_project").is_ok()); assert!(validate_user_name("project.v2").is_ok()); assert_eq!(validate_user_name(""), Err(UserNameError::Empty)); assert_eq!( validate_user_name(".hidden"), Err(UserNameError::LeadingDot) ); assert!(matches!( validate_user_name("a/b"), Err(UserNameError::InvalidChar('/')) )); assert!(matches!( validate_user_name("a b"), Err(UserNameError::InvalidChar(' ')) )); } fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } }