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:
claude@clouddev1
2026-05-07 20:21:52 +00:00
parent 4fca862c6c
commit 601d3b6c51
20 changed files with 1883 additions and 18 deletions
+240
View File
@@ -0,0 +1,240 @@
//! Project lock file (ADR-0015 §10).
//!
//! When a project is opened, we drop a `.rdbms-playground.lock`
//! file in its directory containing the owning process's PID
//! and hostname. On open, we either:
//!
//! - take the lock if no lock file exists,
//! - refuse if the existing lock points at a live PID on this
//! host (another rdbms-playground TUI is running on this
//! project),
//! - take over if the existing lock's PID is dead, or the
//! hostname differs from ours (clean handover from a crashed
//! prior instance, or a stale lock left on a shared
//! filesystem by a different machine).
//!
//! The lock is removed on clean exit (the `Drop` impl). A
//! crash leaves the lock file behind; the next open detects
//! the dead PID and reclaims it.
use std::fs;
use std::path::{Path, PathBuf};
use sysinfo::{Pid, System};
use tracing::{debug, warn};
/// On-disk lock file name. Lives directly under the project
/// directory.
const LOCK_FILE_NAME: &str = ".rdbms-playground.lock";
/// Acquired project lock. Releases on drop.
#[derive(Debug)]
pub struct Lock {
path: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum LockError {
#[error(
"project is already open in another rdbms-playground process \
(pid {pid} on host `{hostname}`); close that process or \
remove `{path}` if you're sure it's not running"
)]
AlreadyHeld {
pid: u32,
hostname: String,
path: PathBuf,
},
#[error("could not write lock file `{path}`: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("could not read existing lock file `{path}`: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
}
impl Lock {
/// Attempt to acquire the lock for `project_dir`. The
/// directory itself must exist; `Project::open` /
/// `Project::create_temp` create it before calling here.
pub fn acquire(project_dir: &Path) -> Result<Self, LockError> {
let path = project_dir.join(LOCK_FILE_NAME);
let our_pid = std::process::id();
let our_host = local_hostname();
match fs::read_to_string(&path) {
Ok(content) => {
let info = parse(&content);
if let Some((pid, host)) = info
&& host == our_host
&& pid_is_alive(pid)
{
return Err(LockError::AlreadyHeld {
pid,
hostname: host,
path,
});
}
debug!(?path, "reclaiming stale lock");
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// No existing lock; we'll create it below.
}
Err(e) => {
return Err(LockError::Read { path, source: e });
}
}
let body = format!("{our_pid}|{our_host}\n");
fs::write(&path, body).map_err(|e| LockError::Write {
path: path.clone(),
source: e,
})?;
Ok(Self { path })
}
/// Path of the underlying lock file (mainly for tests and
/// diagnostics).
#[cfg(test)]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Lock {
fn drop(&mut self) {
if let Err(e) = fs::remove_file(&self.path) {
warn!(path = %self.path.display(), error = %e, "failed to remove project lock file");
}
}
}
/// Parse a lock file body into `(pid, hostname)`. Tolerates a
/// trailing newline. Returns `None` if the format is anything
/// other than `<digits>|<hostname>` — in that case the lock is
/// treated as stale and reclaimed.
fn parse(content: &str) -> Option<(u32, String)> {
let line = content.lines().next()?.trim();
let (pid_str, host) = line.split_once('|')?;
let pid: u32 = pid_str.parse().ok()?;
if host.is_empty() {
return None;
}
Some((pid, host.to_string()))
}
/// Hostname of the machine, as a UTF-8 string. Falls back to
/// `"unknown-host"` if the OS won't tell us; a fallback string
/// only weakens the cross-host reclaim heuristic, it doesn't
/// break correctness.
fn local_hostname() -> String {
gethostname::gethostname()
.into_string()
.unwrap_or_else(|_| "unknown-host".to_string())
}
/// Is a process with this PID currently running on this host?
/// Uses `sysinfo` to query the OS process table.
fn pid_is_alive(pid: u32) -> bool {
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
sys.process(Pid::from_u32(pid)).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
#[test]
fn acquires_lock_when_none_exists() {
let dir = tempdir();
let lock = Lock::acquire(dir.path()).expect("lock");
assert!(lock.path().exists());
let content = fs::read_to_string(lock.path()).unwrap();
let (pid, host) = parse(&content).expect("parseable body");
assert_eq!(pid, std::process::id());
assert!(!host.is_empty());
}
#[test]
fn drop_removes_lock_file() {
let dir = tempdir();
let path = {
let lock = Lock::acquire(dir.path()).expect("lock");
lock.path().to_path_buf()
};
assert!(!path.exists(), "lock file should have been removed on drop");
}
#[test]
fn refuses_when_lock_points_at_live_self() {
let dir = tempdir();
let _first = Lock::acquire(dir.path()).expect("first lock");
// The first lock writes our own PID; a second attempt
// should refuse because the PID is alive on this host.
let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition");
assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}");
}
#[test]
fn reclaims_stale_lock_with_dead_pid() {
let dir = tempdir();
// PID 1 is init/launchd and is alive. We need a PID that
// is essentially guaranteed not to exist; use the maximum
// u32 value, which is far above any realistic PID.
let stale = format!("{pid}|{host}\n", pid = u32::MAX, host = local_hostname());
fs::write(dir.path().join(LOCK_FILE_NAME), stale).unwrap();
let lock = Lock::acquire(dir.path()).expect("should reclaim stale lock");
let body = fs::read_to_string(lock.path()).unwrap();
let (pid, _) = parse(&body).unwrap();
assert_eq!(pid, std::process::id());
}
#[test]
fn reclaims_lock_from_different_host() {
let dir = tempdir();
// A lock from another host: even if the PID happens to
// be live here, we reclaim because we can't tell whether
// *that* PID on *that* host is alive.
let foreign = "1|some-other-machine\n".to_string();
fs::write(dir.path().join(LOCK_FILE_NAME), foreign).unwrap();
let lock = Lock::acquire(dir.path()).expect("should reclaim cross-host lock");
let body = fs::read_to_string(lock.path()).unwrap();
let (_, host) = parse(&body).unwrap();
assert_eq!(host, local_hostname());
}
#[test]
fn reclaims_unparseable_lock() {
let dir = tempdir();
fs::write(dir.path().join(LOCK_FILE_NAME), "not a real lock\n").unwrap();
let lock = Lock::acquire(dir.path()).expect("should reclaim unparseable lock");
assert!(lock.path().exists());
}
#[test]
fn parses_valid_body() {
let (pid, host) = parse("12345|myhost\n").unwrap();
assert_eq!(pid, 12345);
assert_eq!(host, "myhost");
}
#[test]
fn rejects_bad_bodies() {
assert!(parse("").is_none());
assert!(parse("nopipe\n").is_none());
assert!(parse("notanumber|host\n").is_none());
assert!(parse("123|\n").is_none());
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Project lifecycle: data dir resolution, project creation,
//! project opening, lock-file ownership.
//!
//! This module is the home of the in-memory representation of
//! a project on disk. ADR-0015 is the spec; the iteration that
//! introduced this module (Iteration 1) builds the directory
//! skeleton, the file-backed SQLite database, the lock file,
//! and the display-name plumbing. Per-command persistence to
//! YAML / CSV / `history.log` lands in Iteration 2.
//!
//! Nothing here touches Tokio. Project creation and opening
//! are sync filesystem operations; the runtime calls them once
//! at startup and once per `load`/`new`/`save as`.
use std::fs;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use tracing::{debug, info};
pub mod lock;
pub mod naming;
pub mod prettifier;
use lock::{Lock, LockError};
use naming::NamingError;
/// File and directory names inside a project. Public so other
/// modules (db, runtime, future iterations) can reference them
/// without re-deriving paths.
pub const PROJECT_YAML: &str = "project.yaml";
pub const DATA_DIR: &str = "data";
pub const HISTORY_LOG: &str = "history.log";
pub const PLAYGROUND_DB: &str = "playground.db";
pub const GITIGNORE: &str = ".gitignore";
/// Sub-directory of the data root that holds projects.
pub const PROJECTS_SUBDIR: &str = "projects";
/// State file under the data root used by `--resume`.
///
/// Records the absolute path of the most-recently-opened
/// project (Iteration 6, ADR-0015 §7). Iteration 1 doesn't
/// read or write it yet; defining the constant now keeps
/// related code colocated.
pub const LAST_PROJECT_FILE: &str = "last_project";
/// Resolve the data root for this run.
///
/// - If `override_dir` is `Some`, that path is used verbatim
/// (CLI `--data-dir`, ADR-0015 §1).
/// - Otherwise the OS-standard application data directory is
/// used (Linux: `$XDG_DATA_HOME/rdbms-playground` or
/// `~/.local/share/rdbms-playground`; macOS:
/// `~/Library/Application Support/rdbms-playground`;
/// Windows: `%APPDATA%\rdbms-playground`).
pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, ProjectError> {
if let Some(p) = override_dir {
return Ok(p.to_path_buf());
}
let dirs = ProjectDirs::from("", "", "rdbms-playground").ok_or(
ProjectError::DataRootUnavailable,
)?;
Ok(dirs.data_dir().to_path_buf())
}
/// `<data-root>/projects`. Created on demand.
#[must_use]
pub fn projects_dir(data_root: &Path) -> PathBuf {
data_root.join(PROJECTS_SUBDIR)
}
/// Iteration-1 startup logic (ADR-0015 §1):
///
/// - If `project_path` is `Some`, open that project (refused if
/// it doesn't exist or doesn't look like one).
/// - Otherwise create a fresh auto-named temp project under the
/// active data root, resolved from `data_dir_override` plus
/// the OS-standard fallback.
///
/// Splits cleanly out of `runtime::run` so the same logic is
/// reachable from integration tests without booting a Tokio
/// runtime or a terminal.
pub fn open_or_create(
project_path: Option<&Path>,
data_dir_override: Option<&Path>,
) -> Result<Project, ProjectError> {
if let Some(path) = project_path {
Project::open(path)
} else {
let data_root = resolve_data_root(data_dir_override)?;
Project::create_temp(&data_root)
}
}
/// An opened project. Holds the lock for its lifetime.
#[derive(Debug)]
pub struct Project {
path: PathBuf,
display_name: String,
/// Held for the project's lifetime; released on drop.
_lock: Lock,
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectError {
#[error("could not determine the OS-standard data directory; pass --data-dir to override")]
DataRootUnavailable,
#[error("project path `{0}` does not exist")]
PathNotFound(PathBuf),
#[error(
"path `{0}` does not look like a project directory \
(no `project.yaml` and no `playground.db`)"
)]
NotAProject(PathBuf),
#[error("path `{0}` already exists; pick a different name or remove it first")]
AlreadyExists(PathBuf),
#[error("filesystem error at `{path}`: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
#[error(transparent)]
Naming(#[from] NamingError),
#[error(transparent)]
Lock(#[from] LockError),
}
impl Project {
/// Create a new auto-named temp project under
/// `<data-root>/projects/` and acquire its lock.
///
/// The data root is created on demand (parent dirs included).
/// The slug is checked for collisions; the project directory
/// has its skeleton populated (an empty `project.yaml` with
/// just `version` + `created_at`, an empty `data/`, an empty
/// `history.log`, and a `.gitignore` template).
pub fn create_temp(data_root: &Path) -> Result<Self, ProjectError> {
let parent = projects_dir(data_root);
ensure_dir(&parent)?;
let mut rng = rand::rng();
let slug = naming::generate_temp_name(&mut rng, &parent, naming::today_local)?;
let path = parent.join(&slug);
Self::initialize_skeleton(&path)?;
let display_name = prettifier::prettify(&slug);
let lock = Lock::acquire(&path)?;
info!(path = %path.display(), name = %display_name, "created temp project");
Ok(Self {
path,
display_name,
_lock: lock,
})
}
/// Create a *named* project at the chosen path. Refuses if
/// the path already exists (any kind of entry — directory,
/// file, symlink). The user should pick a different name or
/// remove the existing entry first (ADR-0015 §2).
///
/// The skeleton is initialized exactly like
/// `create_temp`. The display name is the prettified
/// directory name.
pub fn create_named(path: &Path) -> Result<Self, ProjectError> {
if path.exists() {
return Err(ProjectError::AlreadyExists(path.to_path_buf()));
}
Self::initialize_skeleton(path)?;
let dirname = directory_name(path);
let display_name = prettifier::prettify(&dirname);
let lock = Lock::acquire(path)?;
info!(path = %path.display(), name = %display_name, "created named project");
Ok(Self {
path: path.to_path_buf(),
display_name,
_lock: lock,
})
}
/// Open an existing project at `path`. Refuses if the path
/// does not exist or does not look like a project (no
/// `project.yaml` and no `playground.db` present).
///
/// Acquires the lock. The display name is the prettified
/// directory name.
pub fn open(path: &Path) -> Result<Self, ProjectError> {
if !path.exists() {
return Err(ProjectError::PathNotFound(path.to_path_buf()));
}
if !looks_like_project(path) {
return Err(ProjectError::NotAProject(path.to_path_buf()));
}
let dirname = directory_name(path);
let display_name = prettifier::prettify(&dirname);
let lock = Lock::acquire(path)?;
info!(path = %path.display(), name = %display_name, "opened project");
Ok(Self {
path: path.to_path_buf(),
display_name,
_lock: lock,
})
}
/// Build the on-disk skeleton for a fresh project: the
/// directory itself, an empty `data/`, an empty
/// `history.log`, a placeholder `project.yaml` with just
/// `version: 1` and `created_at`, and a `.gitignore`.
///
/// `playground.db` is not created here; it's created the
/// first time `Database::open` runs against the path
/// (sqlite creates the file on connect).
fn initialize_skeleton(path: &Path) -> Result<(), ProjectError> {
ensure_dir(path)?;
ensure_dir(&path.join(DATA_DIR))?;
// History log: empty file is fine.
write_if_missing(&path.join(HISTORY_LOG), "")?;
// project.yaml: minimal placeholder. Iteration 2 will
// actually populate `tables` / `relationships` on every
// schema mutation; for now we just ensure the file
// exists and carries the version + creation timestamp.
let yaml = format!(
"version: 1\nproject:\n created_at: {}\ntables: []\nrelationships: []\n",
iso8601_now(),
);
write_if_missing(&path.join(PROJECT_YAML), &yaml)?;
// .gitignore template (ADR-0015 §11). Excludes the
// derived `.db`, the per-process lock, and migration
// backups. `history.log` is intentionally NOT ignored
// (ADR-0007 amendment 1: per-user choice).
let gitignore = "/playground.db\n/.rdbms-playground.lock\n/project.yaml.v*.bak\n";
write_if_missing(&path.join(GITIGNORE), gitignore)?;
Ok(())
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn display_name(&self) -> &str {
&self.display_name
}
/// Path to the SQLite database for this project. Always
/// `<project>/playground.db`.
#[must_use]
pub fn db_path(&self) -> PathBuf {
self.path.join(PLAYGROUND_DB)
}
}
/// Heuristic for "does this directory look like an
/// rdbms-playground project?" — used by `Project::open` to
/// reject obviously-wrong CLI arguments before we try to
/// acquire a lock or touch SQLite.
fn looks_like_project(path: &Path) -> bool {
path.join(PROJECT_YAML).exists() || path.join(PLAYGROUND_DB).exists()
}
fn ensure_dir(path: &Path) -> Result<(), ProjectError> {
fs::create_dir_all(path).map_err(|e| ProjectError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn write_if_missing(path: &Path, body: &str) -> Result<(), ProjectError> {
if path.exists() {
debug!(path = %path.display(), "skeleton file already present, leaving as-is");
return Ok(());
}
fs::write(path, body).map_err(|e| ProjectError::Io {
path: path.to_path_buf(),
source: e,
})
}
fn directory_name(path: &Path) -> String {
path.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
/// Current UTC time as an ISO-8601 string with second
/// precision and a `Z` suffix. Mirrors the `history.log`
/// timestamp format (ADR-0015 §5).
fn iso8601_now() -> String {
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);
iso8601_from_unix_secs(secs)
}
fn iso8601_from_unix_secs(secs: i64) -> String {
let day_secs = secs.rem_euclid(86_400);
let h = day_secs / 3600;
let m = (day_secs % 3600) / 60;
let s = day_secs % 60;
let (y, mo, d) = naming_ymd(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
/// Wrapper that delegates to the same conversion the naming
/// module uses, kept private so we don't expose the helper
/// twice.
const fn naming_ymd(secs: i64) -> (u32, u32, u32) {
// Re-implement the same Howard-Hinnant conversion locally
// so we don't reach into another module's private fn.
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)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir")
}
#[test]
fn data_root_override_is_used_verbatim() {
let tmp = tempdir();
let resolved = resolve_data_root(Some(tmp.path())).unwrap();
assert_eq!(resolved, tmp.path());
}
#[test]
fn data_root_default_returns_some_path() {
// Can't assert the exact path (it depends on the host
// OS and env), but we can confirm we get *something*
// sensible-looking.
let resolved = resolve_data_root(None).unwrap();
let s = resolved.display().to_string();
assert!(
s.contains("rdbms-playground"),
"expected resolved path to mention the app name; got: {s}"
);
}
#[test]
fn create_temp_builds_skeleton() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).expect("create temp");
let path = project.path();
assert!(path.exists());
assert!(path.join(PROJECT_YAML).exists());
assert!(path.join(DATA_DIR).is_dir());
assert!(path.join(HISTORY_LOG).exists());
assert!(path.join(GITIGNORE).exists());
// playground.db is created lazily by SQLite, not by us.
assert!(!path.join(PLAYGROUND_DB).exists());
// Lock file must exist while we hold the project.
assert!(path.join(".rdbms-playground.lock").exists());
// YAML carries version + created_at.
let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap();
assert!(yaml.contains("version: 1"));
assert!(yaml.contains("created_at:"));
// .gitignore matches ADR-0015.
let gi = fs::read_to_string(path.join(GITIGNORE)).unwrap();
assert!(gi.contains("/playground.db"));
assert!(gi.contains("/.rdbms-playground.lock"));
assert!(!gi.contains("history.log"), "history.log should NOT be ignored");
}
#[test]
fn temp_project_lives_under_projects_subdir() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).expect("create temp");
let parent = project.path().parent().unwrap();
assert_eq!(parent.file_name().unwrap(), PROJECTS_SUBDIR);
}
#[test]
fn create_temp_display_name_is_prettified() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).expect("create temp");
// Name should not start with a digit (date prefix
// stripped) and each word capitalized.
let dn = project.display_name();
assert!(
dn.chars().next().map(char::is_uppercase).unwrap_or(false),
"expected title-cased display name, got: {dn}"
);
assert!(!dn.contains('-'));
assert!(!dn.starts_with(char::is_numeric));
}
#[test]
fn drop_releases_lock() {
let tmp = tempdir();
let path = {
let project = Project::create_temp(tmp.path()).expect("create temp");
project.path().to_path_buf()
};
assert!(!path.join(".rdbms-playground.lock").exists());
}
#[test]
fn create_named_refuses_existing_path() {
let tmp = tempdir();
let target = tmp.path().join("MyProject");
fs::create_dir(&target).unwrap();
let err = Project::create_named(&target).expect_err("must refuse");
assert!(matches!(err, ProjectError::AlreadyExists(_)), "got: {err:?}");
}
#[test]
fn create_named_builds_skeleton_at_arbitrary_path() {
let tmp = tempdir();
let target = tmp.path().join("TermPlanner");
let project = Project::create_named(&target).expect("create named");
assert_eq!(project.display_name(), "Term Planner");
assert!(target.join(PROJECT_YAML).exists());
}
#[test]
fn open_refuses_nonexistent_path() {
let tmp = tempdir();
let err = Project::open(&tmp.path().join("does-not-exist")).expect_err("must refuse");
assert!(matches!(err, ProjectError::PathNotFound(_)), "got: {err:?}");
}
#[test]
fn open_refuses_non_project_directory() {
let tmp = tempdir();
let dir = tmp.path().join("random");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("README.txt"), "hello").unwrap();
let err = Project::open(&dir).expect_err("must refuse");
assert!(matches!(err, ProjectError::NotAProject(_)), "got: {err:?}");
}
#[test]
fn open_succeeds_after_create() {
let tmp = tempdir();
let path = {
let project = Project::create_temp(tmp.path()).expect("create");
project.path().to_path_buf()
};
// Re-open after the original Project was dropped.
let reopened = Project::open(&path).expect("reopen");
assert_eq!(reopened.path(), path);
}
#[test]
fn db_path_points_inside_project() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).expect("create");
assert_eq!(project.db_path(), project.path().join(PLAYGROUND_DB));
}
}
+240
View File
@@ -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")
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Convert a project directory name into a human-readable
//! display name (P-NAME-2 from `requirements.md`, ADR-0015 §2).
//!
//! Rules:
//!
//! - Strip a leading `YYYYMMDD-` for temp projects.
//! - Split on `-` (kebab), `_` (snake), or case boundaries
//! (camelCase / PascalCase).
//! - Title-case each resulting word.
//!
//! Examples (covered by tests below):
//!
//! ```text
//! 20260507-water-buffalo-skating -> "Water Buffalo Skating"
//! MyOrders -> "My Orders"
//! customer_demo -> "Customer Demo"
//! exam-1-prep -> "Exam 1 Prep"
//! ```
/// Produce a display name from a project directory name.
#[must_use]
pub fn prettify(dirname: &str) -> String {
let trimmed = strip_date_prefix(dirname);
let words = split_into_words(trimmed);
words
.into_iter()
.map(title_case_word)
.collect::<Vec<_>>()
.join(" ")
}
/// Strip a leading `YYYYMMDD-` if present. Eight ASCII digits
/// followed by a single `-` are required; anything else is
/// returned unchanged.
fn strip_date_prefix(s: &str) -> &str {
if s.len() < 9 {
return s;
}
let (head, tail) = s.split_at(9);
let mut chars = head.chars();
let date_chars: Vec<char> = chars.by_ref().take(8).collect();
let separator = chars.next();
let date_ok = date_chars.len() == 8 && date_chars.iter().all(char::is_ascii_digit);
if date_ok && separator == Some('-') {
tail
} else {
s
}
}
/// Split a string into "words" using kebab, snake, and case
/// boundaries. Empty segments are dropped; leading/trailing
/// separators are tolerated.
fn split_into_words(s: &str) -> Vec<String> {
let mut words: Vec<String> = Vec::new();
let mut current = String::new();
let push = |current: &mut String, words: &mut Vec<String>| {
if !current.is_empty() {
words.push(std::mem::take(current));
}
};
let mut prev: Option<char> = None;
for c in s.chars() {
let is_separator = c == '-' || c == '_';
if is_separator {
push(&mut current, &mut words);
prev = None;
continue;
}
// Case-boundary detection: insert a split before an
// uppercase letter that follows a lowercase letter or
// digit (camelCase / PascalCase). Also split before an
// uppercase letter that begins a run after a lowercase
// letter (e.g. `MyOrders` -> `My Orders`).
if let Some(p) = prev
&& c.is_uppercase()
&& (p.is_lowercase() || p.is_ascii_digit())
{
push(&mut current, &mut words);
}
// Also split before a digit run after letters
// (e.g. `exam1prep` -> `exam 1 prep`).
if let Some(p) = prev
&& c.is_ascii_digit()
&& p.is_alphabetic()
{
push(&mut current, &mut words);
}
// And before letters following a digit (e.g.
// `1prep` -> `1 prep`).
if let Some(p) = prev
&& c.is_alphabetic()
&& p.is_ascii_digit()
{
push(&mut current, &mut words);
}
current.push(c);
prev = Some(c);
}
push(&mut current, &mut words);
words
}
/// Title-case a single word.
///
/// Uppercases the first character and leaves the rest
/// unchanged; empty strings pass through.
fn title_case_word(word: String) -> String {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_date_prefix_from_temp_project_names() {
assert_eq!(prettify("20260507-water-buffalo-skating"), "Water Buffalo Skating");
}
#[test]
fn handles_pascal_case() {
assert_eq!(prettify("MyOrders"), "My Orders");
}
#[test]
fn handles_camel_case() {
assert_eq!(prettify("myOrders"), "My Orders");
}
#[test]
fn handles_snake_case() {
assert_eq!(prettify("customer_demo"), "Customer Demo");
}
#[test]
fn handles_kebab_case_without_date_prefix() {
assert_eq!(prettify("customer-demo"), "Customer Demo");
}
#[test]
fn splits_at_digit_boundaries() {
assert_eq!(prettify("exam1prep"), "Exam 1 Prep");
}
#[test]
fn keeps_kebab_around_digits_intact() {
assert_eq!(prettify("exam-1-prep"), "Exam 1 Prep");
}
#[test]
fn does_not_strip_non_date_eight_chars() {
// Eight letters then `-` is not a date prefix.
assert_eq!(prettify("Customers-orders"), "Customers Orders");
}
#[test]
fn does_not_strip_when_no_separator_after_digits() {
// Eight digits but no `-` immediately after.
assert_eq!(prettify("12345678abc"), "12345678 Abc");
}
#[test]
fn handles_consecutive_separators() {
assert_eq!(prettify("a__b--c"), "A B C");
}
#[test]
fn handles_empty() {
assert_eq!(prettify(""), "");
}
#[test]
fn handles_single_word() {
assert_eq!(prettify("orders"), "Orders");
}
#[test]
fn handles_unicode_word() {
// Non-ASCII letters are preserved; first-char uppercase.
assert_eq!(prettify("café-règles"), "Café Règles");
}
#[test]
fn handles_mixed_separators_and_case() {
assert_eq!(prettify("MyTeam_lessonPlan-2026"), "My Team Lesson Plan 2026");
}
}
+161
View File
@@ -0,0 +1,161 @@
amber
ancient
arctic
azure
brave
bright
brisk
calm
clever
cosmic
crimson
curious
distant
dreamy
emerald
fearless
gentle
golden
graceful
hidden
humble
jade
lively
lucky
mighty
peaceful
quiet
restful
shining
silent
silver
sleepy
swift
twilight
vivid
wandering
wise
woven
badger
bison
buffalo
crane
deer
dolphin
eagle
falcon
finch
fox
grebe
hawk
heron
ibex
kestrel
lark
lynx
magpie
moose
otter
owl
panda
panther
puma
raven
robin
salmon
sparrow
swan
tiger
wolf
canyon
cave
delta
desert
fjord
forest
galaxy
garden
glacier
harbor
island
lagoon
lake
marsh
meadow
mountain
nebula
ocean
prairie
ravine
river
savanna
tundra
valley
volcano
comet
compass
journal
lantern
orchard
pavilion
quill
ribbon
shoreline
sunrise
thicket
beacon
bramble
cobble
crystal
ember
feather
glimmer
horizon
linen
mosaic
parchment
pebble
ripple
shimmer
silt
spruce
willow
zephyr
baking
beaming
building
chasing
climbing
dancing
drifting
dreaming
exploring
finding
gardening
gliding
hopping
journeying
leaping
mapping
mending
painting
planting
plotting
racing
reading
resting
roaming
rowing
sailing
singing
skating
sketching
soaring
strolling
swimming
talking
tracking
trekking
watching
weaving
writing