Iteration 1: file-backed projects with auto-named temps, lock file, and L1 CLI
Replaces the in-memory database with an on-disk project. Startup either opens a project at the positional CLI path (L1) or creates an auto-named temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard data directory or a --data-dir override. The new project::Project type owns the directory skeleton and a PID+hostname lock file with stale-lock takeover via sysinfo. The status bar now shows "Project: <Display Name>", derived by a small kebab/snake/camel prettifier. Per-command persistence to YAML/CSV/history.log is NOT yet wired -- that's Iteration 2; for now playground.db carries the state across quits. Tests: 257 passing (231 lib + 9 new integration + 17 existing), 0 failing, 0 skipped. Clippy clean with nursery lints.
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
//! Generate temp project directory names (P-NAME-1, ADR-0015 §2).
|
||||
//!
|
||||
//! Output pattern: `<YYYYMMDD>-<word>-<word>-<word>` where the
|
||||
//! three words are distinct picks from a small built-in
|
||||
//! wordlist compiled into the binary. 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::seq::IndexedRandom;
|
||||
use rand::Rng;
|
||||
|
||||
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, thiserror::Error)]
|
||||
pub enum NamingError {
|
||||
#[error("wordlist must contain at least 3 entries; found {0}")]
|
||||
WordlistTooSmall(usize),
|
||||
#[error("could not generate a non-colliding temp project name after {0} attempts")]
|
||||
TooManyCollisions(usize),
|
||||
}
|
||||
|
||||
/// Generate a fresh temp project directory name.
|
||||
///
|
||||
/// Checks for collisions against `parent_dir` (typically
|
||||
/// `<data-root>/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<R: Rng + ?Sized>(
|
||||
rng: &mut R,
|
||||
parent_dir: &Path,
|
||||
today: impl Fn() -> String,
|
||||
) -> Result<String, NamingError> {
|
||||
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}-{slug}");
|
||||
if !parent_dir.join(&candidate).exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(NamingError::TooManyCollisions(MAX_COLLISION_RETRIES))
|
||||
}
|
||||
|
||||
/// 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<R: Rng + ?Sized>(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)
|
||||
}
|
||||
|
||||
/// 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, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum UserNameError {
|
||||
#[error("project name cannot be empty")]
|
||||
Empty,
|
||||
#[error("project name cannot start with `.`")]
|
||||
LeadingDot,
|
||||
#[error("project name cannot contain `{0}`; use letters, digits, `-`, `_`, or `.` only")]
|
||||
InvalidChar(char),
|
||||
}
|
||||
|
||||
#[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-"), "got: {name}");
|
||||
let parts: Vec<&str> = name.splitn(4, '-').collect();
|
||||
assert_eq!(parts.len(), 4, "expected date + 3 words, got: {name}");
|
||||
let words_in_name: std::collections::HashSet<_> = parts[1..].iter().collect();
|
||||
assert_eq!(words_in_name.len(), 3, "words must be distinct: {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 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user