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 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user