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:
Generated
+218
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+83
-2
@@ -13,6 +13,13 @@ use crate::theme::Theme;
|
||||
pub struct Args {
|
||||
pub theme: Theme,
|
||||
pub log_path: Option<PathBuf>,
|
||||
/// `--data-dir <PATH>`: replace the OS-standard data root
|
||||
/// for the duration of this run (ADR-0015 §1).
|
||||
pub data_dir: Option<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
#[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<PathBuf> = None;
|
||||
let mut project_path: Option<PathBuf> = 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<P: AsRef<Path> + Into<String>>(path: P) -> Result<Self, DbError> {
|
||||
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, DbError> {
|
||||
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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
@@ -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
|
||||
+15
-6
@@ -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<CrosstermBackend<io::Stdout>>,
|
||||
theme: Theme,
|
||||
database: Database,
|
||||
project_display_name: String,
|
||||
) -> Result<()> {
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <Display Name>" (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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user