diff --git a/Cargo.lock b/Cargo.lock index 17fb454..5529fe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "document-features" version = "0.2.12" @@ -517,6 +538,27 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -713,6 +755,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "libsqlite3-sys" version = "0.37.0" @@ -850,6 +901,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -894,6 +954,25 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -909,6 +988,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "4.6.0" @@ -1244,12 +1329,16 @@ dependencies = [ "anyhow", "chumsky", "crossterm", + "directories", "futures-util", + "gethostname", "insta", "pretty_assertions", "rand 0.10.1", "ratatui", "rusqlite", + "sysinfo", + "tempfile", "thiserror 2.0.18", "tokio", "tracing", @@ -1265,6 +1354,17 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -1579,6 +1679,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sysinfo" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9f9fe3d2b7b75cf4f2805e5b9926e8ac47146667b16b86298c4a8bf08cc469" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2103,12 +2217,107 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2118,6 +2327,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index eaeb229..dd748f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,13 @@ publish = false anyhow = "1.0.102" chumsky = "0.13.0" crossterm = { version = "0.29.0", features = ["event-stream"] } +directories = "6.0.0" futures-util = "0.3.32" +gethostname = "1.1.0" rand = "0.10.1" ratatui = "0.30.0" rusqlite = { version = "0.39.0", features = ["bundled"] } +sysinfo = { version = "0.39.0", default-features = false, features = ["system"] } thiserror = "2.0.18" tokio = { version = "1.52.2", features = ["full"] } tracing = "0.1.44" @@ -24,6 +27,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } [dev-dependencies] insta = { version = "1.47.2", features = ["yaml"] } pretty_assertions = "1.4.1" +tempfile = "3.27.0" [lints.rust] unsafe_code = "forbid" diff --git a/src/app.rs b/src/app.rs index 9ce97d1..06c66da 100644 --- a/src/app.rs +++ b/src/app.rs @@ -101,6 +101,11 @@ pub struct App { /// logical OutputLines. Required for accurate scroll capping /// when long lines wrap to multiple display rows. pub last_output_total_wrapped: usize, + /// Prettified display name of the currently-open project, + /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` + /// during very-early startup before the runtime has opened a + /// project; otherwise always populated. + pub project_name: Option, } const PAGE_SCROLL_LINES: usize = 5; @@ -130,6 +135,7 @@ impl App { output_scroll: 0, last_output_visible: 0, last_output_total_wrapped: 0, + project_name: None, } } diff --git a/src/cli.rs b/src/cli.rs index 451a8d1..a935c12 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,6 +13,13 @@ use crate::theme::Theme; pub struct Args { pub theme: Theme, pub log_path: Option, + /// `--data-dir `: replace the OS-standard data root + /// for the duration of this run (ADR-0015 §1). + pub data_dir: Option, + /// Positional path argument: open an existing project at + /// this path (L1, ADR-0015 §1). Mutually exclusive with + /// `--resume` once that lands. + pub project_path: Option, } #[derive(Debug, thiserror::Error)] @@ -27,6 +34,8 @@ pub enum ArgsError { }, #[error("unknown argument: {0}")] Unknown(String), + #[error("only one project path may be supplied; got both `{first}` and `{second}`")] + MultiplePaths { first: String, second: String }, } impl Args { @@ -43,6 +52,8 @@ impl Args { { let mut theme = default_theme(); let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from); + let mut data_dir: Option = None; + let mut project_path: Option = None; let mut iter = iter.into_iter().map(Into::into); while let Some(arg) = iter.next() { match arg.as_str() { @@ -64,10 +75,30 @@ impl Args { let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?; log_path = Some(PathBuf::from(value)); } - other => return Err(ArgsError::Unknown(other.to_string())), + "--data-dir" => { + let value = iter.next().ok_or(ArgsError::MissingValue("data-dir"))?; + data_dir = Some(PathBuf::from(value)); + } + other if other.starts_with("--") => { + return Err(ArgsError::Unknown(other.to_string())); + } + other => { + if let Some(existing) = &project_path { + return Err(ArgsError::MultiplePaths { + first: existing.display().to_string(), + second: other.to_string(), + }); + } + project_path = Some(PathBuf::from(other)); + } } } - Ok(Self { theme, log_path }) + Ok(Self { + theme, + log_path, + data_dir, + project_path, + }) } } @@ -129,4 +160,54 @@ mod tests { let err = Args::parse(["--bogus"]).unwrap_err(); assert!(matches!(err, ArgsError::Unknown(s) if s == "--bogus")); } + + #[test] + fn data_dir_flag_parses() { + let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap(); + assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data"))); + } + + #[test] + fn data_dir_flag_missing_value() { + let err = Args::parse(["--data-dir"]).unwrap_err(); + assert!(matches!(err, ArgsError::MissingValue("data-dir"))); + } + + #[test] + fn positional_path_parses() { + let args = Args::parse(["/home/me/projects/MyProject"]).unwrap(); + assert_eq!( + args.project_path.as_deref(), + Some(std::path::Path::new("/home/me/projects/MyProject")) + ); + } + + #[test] + fn data_dir_and_positional_can_coexist() { + let args = Args::parse([ + "--data-dir", + "/tmp/data", + "/home/me/MyProject", + ]) + .unwrap(); + assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data"))); + assert_eq!( + args.project_path.as_deref(), + Some(std::path::Path::new("/home/me/MyProject")) + ); + } + + #[test] + fn two_positional_paths_error() { + let err = Args::parse(["/a", "/b"]).unwrap_err(); + assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}"); + } + + #[test] + fn unknown_double_dash_flag_errors_even_with_positional() { + // Make sure the path-vs-flag distinction is robust: + // unknown flags don't get silently swallowed as paths. + let err = Args::parse(["--bogus", "/some/path"]).unwrap_err(); + assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}"); + } } diff --git a/src/db.rs b/src/db.rs index 1905e07..ab1d6aa 100644 --- a/src/db.rs +++ b/src/db.rs @@ -279,7 +279,7 @@ impl Database { /// Open a database. The path may be a filesystem location /// or `":memory:"` for an ephemeral in-memory database. The /// connection is moved onto a dedicated worker thread. - pub fn open + Into>(path: P) -> Result { + pub fn open>(path: P) -> Result { let path_display = path.as_ref().to_string_lossy().into_owned(); let conn = match path.as_ref().to_str() { Some(":memory:") => Connection::open_in_memory(), diff --git a/src/lib.rs b/src/lib.rs index 48bf96a..186e364 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod dsl; pub mod event; pub mod logging; pub mod mode; +pub mod project; pub mod runtime; pub mod theme; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 9619e9e..b71ea8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ fn main() -> ExitCode { } }; - match tokio_rt.block_on(runtime::run(args.theme)) { + match tokio_rt.block_on(runtime::run(args)) { Ok(()) => ExitCode::SUCCESS, Err(e) => { tracing::error!(error = %e, "runtime exited with error"); diff --git a/src/project/lock.rs b/src/project/lock.rs new file mode 100644 index 0000000..d3b074f --- /dev/null +++ b/src/project/lock.rs @@ -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 { + 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 `|` — 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()); + } +} diff --git a/src/project/mod.rs b/src/project/mod.rs new file mode 100644 index 0000000..f5a2409 --- /dev/null +++ b/src/project/mod.rs @@ -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 { + 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()) +} + +/// `/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 { + 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 + /// `/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 { + 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 { + 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 { + 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 + /// `/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)); + } +} diff --git a/src/project/naming.rs b/src/project/naming.rs new file mode 100644 index 0000000..37630f6 --- /dev/null +++ b/src/project/naming.rs @@ -0,0 +1,240 @@ +//! Generate temp project directory names (P-NAME-1, ADR-0015 §2). +//! +//! Output pattern: `---` 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 +/// `/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}-{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(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") + } +} diff --git a/src/project/prettifier.rs b/src/project/prettifier.rs new file mode 100644 index 0000000..198972c --- /dev/null +++ b/src/project/prettifier.rs @@ -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::>() + .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 = 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 { + let mut words: Vec = Vec::new(); + let mut current = String::new(); + + let push = |current: &mut String, words: &mut Vec| { + if !current.is_empty() { + words.push(std::mem::take(current)); + } + }; + + let mut prev: Option = 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"); + } +} diff --git a/src/project/wordlist.txt b/src/project/wordlist.txt new file mode 100644 index 0000000..78404ca --- /dev/null +++ b/src/project/wordlist.txt @@ -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 diff --git a/src/runtime.rs b/src/runtime.rs index a89ef5f..a79f85f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -26,11 +26,13 @@ use tracing::{debug, error, info, warn}; use crate::action::Action; use crate::app::App; +use crate::cli::Args; use crate::db::{ DataResult, Database, DbError, DeleteResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::Command; use crate::event::AppEvent; +use crate::project::open_or_create; use crate::theme::Theme; use crate::ui; @@ -39,17 +41,22 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100); /// Run the application until a `Quit` action is enacted or the /// terminal closes. -pub async fn run(theme: Theme) -> Result<()> { - // For this iteration, every session uses a fresh in-memory - // database. Track 2 (project storage) wires up file-backed - // databases with proper lifecycle management. - let database = Database::open(":memory:").context("open database")?; +pub async fn run(args: Args) -> Result<()> { + let project = open_or_create(args.project_path.as_deref(), args.data_dir.as_deref()) + .context("open or create project")?; + let db_path = project.db_path(); + let display_name = project.display_name().to_string(); + let database = Database::open(db_path.as_path()).context("open database")?; + let mut terminal = setup_terminal().context("setup terminal")?; - let result = run_loop(&mut terminal, theme, database).await; + let result = run_loop(&mut terminal, args.theme, database, display_name).await; if let Err(e) = teardown_terminal(&mut terminal) { // Teardown failures should not mask the primary error. warn!(error = %e, "terminal teardown failed"); } + // `project` (and the lock it holds) is dropped here, releasing + // the lock file *after* the terminal has been restored. + drop(project); result } @@ -57,11 +64,13 @@ async fn run_loop( terminal: &mut Terminal>, theme: Theme, database: Database, + project_display_name: String, ) -> Result<()> { let (event_tx, mut event_rx) = mpsc::channel::(EVENT_CHANNEL_CAPACITY); let reader_handle = spawn_event_reader(event_tx.clone()); let mut app = App::new(); + app.project_name = Some(project_display_name); // Seed the table list with whatever the database currently // shows. For a fresh in-memory DB this is empty, but doing diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap index 3e19b32..303f167 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 421 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -17,7 +18,6 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ ADVANCED ────────────────────────────────────────╮ │ ││ │ @@ -25,4 +25,5 @@ expression: snapshot │ │╭ Hint ────────────────────────────────────────────╮ │ ││(no active hint) │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · mode simple switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index 1c21d39..be0d7af 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 404 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -17,7 +18,6 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││ │ @@ -25,4 +25,5 @@ expression: snapshot │ │╭ Hint ────────────────────────────────────────────╮ │ ││(no active hint) │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index 1c21d39..433560d 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 412 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -17,7 +18,6 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││ │ @@ -25,4 +25,5 @@ expression: snapshot │ │╭ Hint ────────────────────────────────────────────╮ │ ││(no active hint) │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index 53ae533..d2daacd 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 433 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -17,7 +18,6 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ Advanced: ───────────────────────────────────────╮ │ ││: sel │ @@ -25,4 +25,5 @@ expression: snapshot │ │╭ Hint ────────────────────────────────────────────╮ │ ││(no active hint) │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · Backspace cancel one-shot · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index 0f6651c..ce28705 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 492 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ @@ -17,7 +18,6 @@ expression: snapshot │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │ │╰──────────────────────────────────────────────────╯ │ │╭ SIMPLE ──────────────────────────────────────────╮ │ ││ │ @@ -25,4 +25,5 @@ expression: snapshot │ │╭ Hint ────────────────────────────────────────────╮ │ ││(no active hint) │ ╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index bd747ed..d478114 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -27,10 +27,16 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { let area = frame.area(); paint_background(theme, frame, area); - // Reserve a single row at the bottom for the shortcut/status bar. + // Reserve two rows at the bottom for status: + // - top row: "Project: " (P-NAME-3, ADR-0015 §2). + // - bottom row: mode-aware keyboard shortcuts. let outer = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(8), Constraint::Length(1)]) + .constraints([ + Constraint::Min(8), + Constraint::Length(1), + Constraint::Length(1), + ]) .split(area); let columns = Layout::default() @@ -40,7 +46,24 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { render_items_panel(app, theme, frame, columns[0]); render_right_column(app, theme, frame, columns[1]); - render_status_bar(app, theme, frame, outer[1]); + render_project_label(app, theme, frame, outer[1]); + render_status_bar(app, theme, frame, outer[2]); +} + +fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let label_style = Style::default().fg(theme.muted); + let value_style = Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD); + let bar_style = Style::default().bg(theme.bg).fg(theme.muted); + + let display = app.project_name.as_deref().unwrap_or("(no project)"); + let line = Line::from(vec![ + Span::styled("Project: ", label_style), + Span::styled(display.to_string(), value_style), + ]); + let paragraph = Paragraph::new(line).style(bar_style); + frame.render_widget(paragraph, area); } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { @@ -350,6 +373,13 @@ mod tests { use ratatui::backend::TestBackend; fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String { + // Snapshot tests need realistic state, not the boot + // fallback "(no project)" — every real session has a + // project. Set a representative name unless the test + // already set one. + if app.project_name.is_none() { + app.project_name = Some("Term Planner".to_string()); + } let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).expect("create terminal"); terminal diff --git a/tests/project_lifecycle.rs b/tests/project_lifecycle.rs new file mode 100644 index 0000000..81eb818 --- /dev/null +++ b/tests/project_lifecycle.rs @@ -0,0 +1,201 @@ +//! Iteration-1 integration tests: end-to-end project lifecycle +//! through the public API the runtime uses on startup. +//! +//! These tests do NOT run the Tokio loop or the terminal; they +//! exercise the same `project::open_or_create` entry point the +//! runtime calls, plus a `Database::open` against the resulting +//! path, to confirm the file-backed SQLite database actually +//! lands inside the project directory and is queryable. + +use std::fs; + +use rdbms_playground::cli::Args; +use rdbms_playground::db::Database; +use rdbms_playground::project::{ + self, GITIGNORE, HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, PROJECTS_SUBDIR, +}; + +fn tempdir() -> tempfile::TempDir { + tempfile::tempdir().expect("create tempdir") +} + +#[test] +fn no_args_creates_temp_project_under_data_root() { + let data = tempdir(); + let project = project::open_or_create(None, Some(data.path())) + .expect("open_or_create with empty CLI"); + + let path = project.path(); + assert!(path.exists(), "project dir should exist"); + assert!(path.starts_with(data.path())); + assert_eq!( + path.parent().and_then(|p| p.file_name()).map(|s| s.to_string_lossy().into_owned()), + Some(PROJECTS_SUBDIR.to_string()), + ); + + // Skeleton files. + assert!(path.join(PROJECT_YAML).exists()); + assert!(path.join("data").is_dir()); + assert!(path.join(HISTORY_LOG).exists()); + assert!(path.join(GITIGNORE).exists()); + assert!(path.join(".rdbms-playground.lock").exists()); + + // .gitignore must NOT include history.log (ADR-0007 amendment). + let gi = fs::read_to_string(path.join(GITIGNORE)).unwrap(); + assert!(!gi.contains("history.log")); +} + +#[test] +fn db_opens_inside_project_and_creates_the_file() { + let data = tempdir(); + let project = project::open_or_create(None, Some(data.path())).unwrap(); + let db_path = project.db_path(); + + // Before opening, the .db file does not exist. + assert!(!db_path.exists()); + let _db = Database::open(&db_path).expect("open db at project path"); + // After opening, sqlite has created the file. + assert!(db_path.exists()); + assert_eq!(db_path.parent(), Some(project.path())); +} + +#[test] +fn second_open_of_same_project_is_refused_by_lock() { + let data = tempdir(); + let first = project::open_or_create(None, Some(data.path())).unwrap(); + let path = first.path().to_path_buf(); + + let err = project::Project::open(&path).expect_err("second open should fail"); + let msg = format!("{err}"); + assert!( + msg.contains("already open"), + "expected lock-held error, got: {msg}" + ); +} + +#[test] +fn open_succeeds_after_first_project_is_dropped() { + let data = tempdir(); + let path = { + let p = project::open_or_create(None, Some(data.path())).unwrap(); + p.path().to_path_buf() + }; + // Lock should have been released; reopen succeeds. + let _reopened = project::Project::open(&path).expect("reopen after drop"); +} + +#[test] +fn positional_path_opens_existing_project() { + let data = tempdir(); + let path = { + let p = project::open_or_create(None, Some(data.path())).unwrap(); + p.path().to_path_buf() + }; + + // Now drive open_or_create with the path as if it were a + // CLI positional argument. + let project = project::open_or_create(Some(&path), None) + .expect("open via positional path"); + assert_eq!(project.path(), path); +} + +#[test] +fn positional_nonexistent_path_is_refused() { + let data = tempdir(); + let bogus = data.path().join("nope"); + let err = project::open_or_create(Some(&bogus), Some(data.path())) + .expect_err("must refuse nonexistent path"); + let msg = format!("{err}"); + assert!(msg.contains("does not exist"), "got: {msg}"); +} + +#[test] +fn cli_args_thread_through_to_project_creation() { + // End-to-end: CLI parsing → open_or_create → on-disk project. + let data = tempdir(); + let data_str = data.path().to_string_lossy().into_owned(); + let args = Args::parse(["--data-dir", data_str.as_str()]).expect("parse args"); + assert_eq!(args.data_dir.as_deref(), Some(data.path())); + assert!(args.project_path.is_none()); + + let project = project::open_or_create(args.project_path.as_deref(), args.data_dir.as_deref()) + .expect("create temp via parsed CLI"); + assert!(project.path().starts_with(data.path())); +} + +#[test] +fn data_dir_override_does_not_touch_default_os_dir() { + // Sanity check that --data-dir really replaces the default — + // creating two temp projects under the override should leave + // them both there, and the OS-standard data dir is not + // touched. We can't easily inspect the OS-standard dir + // without actually creating things in it, so we settle for + // confirming the override directory is the active one. + let data = tempdir(); + let p1 = project::open_or_create(None, Some(data.path())).unwrap(); + let p1_path = p1.path().to_path_buf(); + drop(p1); + let p2 = project::open_or_create(None, Some(data.path())).unwrap(); + let p2_path = p2.path().to_path_buf(); + + assert!(p1_path.starts_with(data.path())); + assert!(p2_path.starts_with(data.path())); + assert_ne!(p1_path, p2_path, "two temp projects must have distinct names"); +} + +#[test] +fn db_persists_across_open_close_cycles() { + // Iteration 1's headline UX win: quitting no longer loses + // work. With a file-backed database, data written in one + // session is visible after re-opening the project. + let data = tempdir(); + let project = project::open_or_create(None, Some(data.path())).unwrap(); + let path = project.path().to_path_buf(); + let db_path = project.db_path(); + + // Write something via SQLite directly. (The DSL/runtime path + // would do the same but isn't reachable from a sync test.) + { + let db = Database::open(&db_path).expect("open db"); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + db.create_table( + "Customers".to_string(), + vec![ + rdbms_playground::dsl::ColumnSpec { + name: "id".to_string(), + ty: rdbms_playground::dsl::Type::Serial, + }, + rdbms_playground::dsl::ColumnSpec { + name: "Name".to_string(), + ty: rdbms_playground::dsl::Type::Text, + }, + ], + vec!["id".to_string()], + ) + .await + .expect("create_table"); + }); + } + + // Drop the project (releases the lock). + drop(project); + + // Re-open and confirm the table is still there. + let reopened = project::Project::open(&path).expect("reopen"); + let db = Database::open(reopened.db_path()).expect("re-open db"); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let tables = rt.block_on(async { db.list_tables().await }).expect("list_tables"); + assert!(tables.iter().any(|t| t == "Customers"), "got: {tables:?}"); + + // Sanity: the project.yaml and history.log are still empty + // skeleton files (Iteration 2 will populate them). + assert!(reopened.path().join(PROJECT_YAML).exists()); + assert!(reopened.path().join(PLAYGROUND_DB).exists()); +}