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",
|
"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]]
|
[[package]]
|
||||||
name = "document-features"
|
name = "document-features"
|
||||||
version = "0.2.12"
|
version = "0.2.12"
|
||||||
@@ -517,6 +538,27 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -713,6 +755,15 @@ version = "0.2.186"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -850,6 +901,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ntapi"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -894,6 +954,25 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.37.3"
|
version = "0.37.3"
|
||||||
@@ -909,6 +988,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-float"
|
name = "ordered-float"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
@@ -1244,12 +1329,16 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"chumsky",
|
"chumsky",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"directories",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"gethostname",
|
||||||
"insta",
|
"insta",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
|
"sysinfo",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1265,6 +1354,17 @@ dependencies = [
|
|||||||
"bitflags 2.11.1",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -1579,6 +1679,20 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -2103,12 +2217,107 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -2118,6 +2327,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ publish = false
|
|||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
chumsky = "0.13.0"
|
chumsky = "0.13.0"
|
||||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||||
|
directories = "6.0.0"
|
||||||
futures-util = "0.3.32"
|
futures-util = "0.3.32"
|
||||||
|
gethostname = "1.1.0"
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
||||||
|
sysinfo = { version = "0.39.0", default-features = false, features = ["system"] }
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.2", features = ["full"] }
|
tokio = { version = "1.52.2", features = ["full"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
@@ -24,6 +27,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1.47.2", features = ["yaml"] }
|
insta = { version = "1.47.2", features = ["yaml"] }
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ pub struct App {
|
|||||||
/// logical OutputLines. Required for accurate scroll capping
|
/// logical OutputLines. Required for accurate scroll capping
|
||||||
/// when long lines wrap to multiple display rows.
|
/// when long lines wrap to multiple display rows.
|
||||||
pub last_output_total_wrapped: usize,
|
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;
|
const PAGE_SCROLL_LINES: usize = 5;
|
||||||
@@ -130,6 +135,7 @@ impl App {
|
|||||||
output_scroll: 0,
|
output_scroll: 0,
|
||||||
last_output_visible: 0,
|
last_output_visible: 0,
|
||||||
last_output_total_wrapped: 0,
|
last_output_total_wrapped: 0,
|
||||||
|
project_name: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+83
-2
@@ -13,6 +13,13 @@ use crate::theme::Theme;
|
|||||||
pub struct Args {
|
pub struct Args {
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
pub log_path: Option<PathBuf>,
|
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)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -27,6 +34,8 @@ pub enum ArgsError {
|
|||||||
},
|
},
|
||||||
#[error("unknown argument: {0}")]
|
#[error("unknown argument: {0}")]
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
|
#[error("only one project path may be supplied; got both `{first}` and `{second}`")]
|
||||||
|
MultiplePaths { first: String, second: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
@@ -43,6 +52,8 @@ impl Args {
|
|||||||
{
|
{
|
||||||
let mut theme = default_theme();
|
let mut theme = default_theme();
|
||||||
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
|
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);
|
let mut iter = iter.into_iter().map(Into::into);
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
@@ -64,10 +75,30 @@ impl Args {
|
|||||||
let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?;
|
let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?;
|
||||||
log_path = Some(PathBuf::from(value));
|
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();
|
let err = Args::parse(["--bogus"]).unwrap_err();
|
||||||
assert!(matches!(err, ArgsError::Unknown(s) if s == "--bogus"));
|
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
|
/// Open a database. The path may be a filesystem location
|
||||||
/// or `":memory:"` for an ephemeral in-memory database. The
|
/// or `":memory:"` for an ephemeral in-memory database. The
|
||||||
/// connection is moved onto a dedicated worker thread.
|
/// 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 path_display = path.as_ref().to_string_lossy().into_owned();
|
||||||
let conn = match path.as_ref().to_str() {
|
let conn = match path.as_ref().to_str() {
|
||||||
Some(":memory:") => Connection::open_in_memory(),
|
Some(":memory:") => Connection::open_in_memory(),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod dsl;
|
|||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod mode;
|
pub mod mode;
|
||||||
|
pub mod project;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod ui;
|
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,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "runtime exited with error");
|
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::action::Action;
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::cli::Args;
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
DataResult, Database, DbError, DeleteResult, InsertResult, TableDescription, UpdateResult,
|
DataResult, Database, DbError, DeleteResult, InsertResult, TableDescription, UpdateResult,
|
||||||
};
|
};
|
||||||
use crate::dsl::Command;
|
use crate::dsl::Command;
|
||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
|
use crate::project::open_or_create;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
use crate::ui;
|
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
|
/// Run the application until a `Quit` action is enacted or the
|
||||||
/// terminal closes.
|
/// terminal closes.
|
||||||
pub async fn run(theme: Theme) -> Result<()> {
|
pub async fn run(args: Args) -> Result<()> {
|
||||||
// For this iteration, every session uses a fresh in-memory
|
let project = open_or_create(args.project_path.as_deref(), args.data_dir.as_deref())
|
||||||
// database. Track 2 (project storage) wires up file-backed
|
.context("open or create project")?;
|
||||||
// databases with proper lifecycle management.
|
let db_path = project.db_path();
|
||||||
let database = Database::open(":memory:").context("open database")?;
|
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 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) {
|
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||||
// Teardown failures should not mask the primary error.
|
// Teardown failures should not mask the primary error.
|
||||||
warn!(error = %e, "terminal teardown failed");
|
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
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +64,13 @@ async fn run_loop(
|
|||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
database: Database,
|
database: Database,
|
||||||
|
project_display_name: String,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
app.project_name = Some(project_display_name);
|
||||||
|
|
||||||
// Seed the table list with whatever the database currently
|
// Seed the table list with whatever the database currently
|
||||||
// shows. For a fresh in-memory DB this is empty, but doing
|
// shows. For a fresh in-memory DB this is empty, but doing
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
|
assertion_line: 421
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -17,7 +18,6 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ ADVANCED ────────────────────────────────────────╮
|
│ │╭ ADVANCED ────────────────────────────────────────╮
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -25,4 +25,5 @@ expression: snapshot
|
|||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││(no active hint) │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
|
Project: Term Planner
|
||||||
Enter submit · mode simple switch · Ctrl-C quit
|
Enter submit · mode simple switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
|
assertion_line: 404
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -17,7 +18,6 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -25,4 +25,5 @@ expression: snapshot
|
|||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││(no active hint) │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
|
assertion_line: 412
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -17,7 +18,6 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -25,4 +25,5 @@ expression: snapshot
|
|||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││(no active hint) │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
|
assertion_line: 433
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -17,7 +18,6 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ Advanced: ───────────────────────────────────────╮
|
│ │╭ Advanced: ───────────────────────────────────────╮
|
||||||
│ ││: sel │
|
│ ││: sel │
|
||||||
@@ -25,4 +25,5 @@ expression: snapshot
|
|||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││(no active hint) │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
|
Project: Term Planner
|
||||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
|
assertion_line: 492
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
@@ -17,7 +18,6 @@ expression: snapshot
|
|||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
│ ││ │
|
||||||
│ ││ │
|
|
||||||
│ │╰──────────────────────────────────────────────────╯
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||||
│ ││ │
|
│ ││ │
|
||||||
@@ -25,4 +25,5 @@ expression: snapshot
|
|||||||
│ │╭ Hint ────────────────────────────────────────────╮
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
│ ││(no active hint) │
|
│ ││(no active hint) │
|
||||||
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
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();
|
let area = frame.area();
|
||||||
paint_background(theme, 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()
|
let outer = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Min(8), Constraint::Length(1)])
|
.constraints([
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let columns = Layout::default()
|
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_items_panel(app, theme, frame, columns[0]);
|
||||||
render_right_column(app, theme, frame, columns[1]);
|
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) {
|
fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
@@ -350,6 +373,13 @@ mod tests {
|
|||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
fn render_to_string(app: &mut App, theme: &Theme, width: u16, height: u16) -> String {
|
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 backend = TestBackend::new(width, height);
|
||||||
let mut terminal = Terminal::new(backend).expect("create terminal");
|
let mut terminal = Terminal::new(backend).expect("create terminal");
|
||||||
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