Iteration 1: file-backed projects with auto-named temps, lock file, and L1 CLI

Replaces the in-memory database with an on-disk project. Startup either
opens a project at the positional CLI path (L1) or creates an auto-named
temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard
data directory or a --data-dir override. The new project::Project type
owns the directory skeleton and a PID+hostname lock file with
stale-lock takeover via sysinfo. The status bar now shows
"Project: <Display Name>", derived by a small kebab/snake/camel
prettifier. Per-command persistence to YAML/CSV/history.log is NOT
yet wired -- that's Iteration 2; for now playground.db carries the
state across quits.

Tests: 257 passing (231 lib + 9 new integration + 17 existing),
0 failing, 0 skipped. Clippy clean with nursery lints.
This commit is contained in:
claude@clouddev1
2026-05-07 20:21:52 +00:00
parent 4fca862c6c
commit 601d3b6c51
20 changed files with 1883 additions and 18 deletions
+83 -2
View File
@@ -13,6 +13,13 @@ use crate::theme::Theme;
pub struct Args {
pub theme: Theme,
pub log_path: Option<PathBuf>,
/// `--data-dir <PATH>`: replace the OS-standard data root
/// for the duration of this run (ADR-0015 §1).
pub data_dir: Option<PathBuf>,
/// Positional path argument: open an existing project at
/// this path (L1, ADR-0015 §1). Mutually exclusive with
/// `--resume` once that lands.
pub project_path: Option<PathBuf>,
}
#[derive(Debug, thiserror::Error)]
@@ -27,6 +34,8 @@ pub enum ArgsError {
},
#[error("unknown argument: {0}")]
Unknown(String),
#[error("only one project path may be supplied; got both `{first}` and `{second}`")]
MultiplePaths { first: String, second: String },
}
impl Args {
@@ -43,6 +52,8 @@ impl Args {
{
let mut theme = default_theme();
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
let mut data_dir: Option<PathBuf> = None;
let mut project_path: Option<PathBuf> = None;
let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() {
match arg.as_str() {
@@ -64,10 +75,30 @@ impl Args {
let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?;
log_path = Some(PathBuf::from(value));
}
other => return Err(ArgsError::Unknown(other.to_string())),
"--data-dir" => {
let value = iter.next().ok_or(ArgsError::MissingValue("data-dir"))?;
data_dir = Some(PathBuf::from(value));
}
other if other.starts_with("--") => {
return Err(ArgsError::Unknown(other.to_string()));
}
other => {
if let Some(existing) = &project_path {
return Err(ArgsError::MultiplePaths {
first: existing.display().to_string(),
second: other.to_string(),
});
}
project_path = Some(PathBuf::from(other));
}
}
}
Ok(Self { theme, log_path })
Ok(Self {
theme,
log_path,
data_dir,
project_path,
})
}
}
@@ -129,4 +160,54 @@ mod tests {
let err = Args::parse(["--bogus"]).unwrap_err();
assert!(matches!(err, ArgsError::Unknown(s) if s == "--bogus"));
}
#[test]
fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
}
#[test]
fn data_dir_flag_missing_value() {
let err = Args::parse(["--data-dir"]).unwrap_err();
assert!(matches!(err, ArgsError::MissingValue("data-dir")));
}
#[test]
fn positional_path_parses() {
let args = Args::parse(["/home/me/projects/MyProject"]).unwrap();
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/projects/MyProject"))
);
}
#[test]
fn data_dir_and_positional_can_coexist() {
let args = Args::parse([
"--data-dir",
"/tmp/data",
"/home/me/MyProject",
])
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
);
}
#[test]
fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
}
#[test]
fn unknown_double_dash_flag_errors_even_with_positional() {
// Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
}
}