64eee3ed6d
Step 1 (Cargo + CLI): - add the `backup` feature to rusqlite (online backup API) - `--no-undo` flag (test-first) + help-banner entry Step 2 (snapshot store, src/undo.rs): - SnapshotStore: a persisted undo ring + redo stack under <project>/.snapshots/ (index.yaml + per-snapshot payload dirs) - hybrid whole-project snapshot: db via backup API + project.yaml / data/*.csv copied as files; restore is text-first, db-last (ADR-0015 §6 commit-db-last) - stage/finalize/discard, undo/redo (each snapshots current to keep the inverse possible), N=50 eviction, redo-cleared-on-new-work, orphan/staging cleanup, monotonic ids - 12 Tier-1 tests; adds a crate-visible persistence::utc_iso8601_now No worker wiring yet (step 3). 1674 passed / 0 failed / 1 ignored; clippy clean.
344 lines
11 KiB
Rust
344 lines
11 KiB
Rust
//! CLI argument parsing.
|
|
//!
|
|
//! Walking-skeleton scope is small enough that a hand-rolled
|
|
//! parser is simpler than pulling in clap. When the CLI grows
|
|
//! (project loading per L1, L2 etc.) we will revisit.
|
|
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
|
|
use crate::theme::Theme;
|
|
|
|
#[derive(Debug, Clone)]
|
|
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`.
|
|
pub project_path: Option<PathBuf>,
|
|
/// `--resume`: open the most-recently-used project at
|
|
/// startup (L1a, ADR-0015 §7). Reads the path from
|
|
/// `<data-root>/last_project`. Mutually exclusive with
|
|
/// `<project-path>` — supplying both is an error rather
|
|
/// than silently picking one.
|
|
pub resume: bool,
|
|
/// `--help` / `-h`: print usage to stdout and exit. The
|
|
/// runtime checks this flag before doing any other work.
|
|
pub help: bool,
|
|
/// `--no-undo`: disable the auto-snapshot / undo machinery for
|
|
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
|
|
/// taken — zero per-command overhead — and `undo` / `redo`
|
|
/// report that undo is turned off. The escape hatch for small
|
|
/// hardware where per-command snapshotting is too heavy.
|
|
pub no_undo: bool,
|
|
}
|
|
|
|
/// Usage banner printed by `--help`.
|
|
///
|
|
/// Wraps the catalog lookup (`help.cli_banner`) so callers
|
|
/// don't have to spell out the key. The catalog body is the
|
|
/// single source of truth — see
|
|
/// `src/friendly/strings/en-US.yaml`.
|
|
#[must_use]
|
|
pub fn help_text() -> String {
|
|
crate::t!("help.cli_banner")
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ArgsError {
|
|
MissingValue(&'static str),
|
|
InvalidValue {
|
|
flag: &'static str,
|
|
value: String,
|
|
expected: &'static str,
|
|
},
|
|
Unknown(String),
|
|
MultiplePaths {
|
|
first: String,
|
|
second: String,
|
|
},
|
|
ResumeWithPath,
|
|
}
|
|
|
|
impl std::fmt::Display for ArgsError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::MissingValue(flag) => f.write_str(&crate::t!(
|
|
"cli.missing_value",
|
|
flag = flag,
|
|
)),
|
|
Self::InvalidValue {
|
|
flag,
|
|
value,
|
|
expected,
|
|
} => f.write_str(&crate::t!(
|
|
"cli.invalid_value",
|
|
flag = flag,
|
|
value = value,
|
|
expected = expected,
|
|
)),
|
|
Self::Unknown(arg) => f.write_str(&crate::t!(
|
|
"cli.unknown_argument",
|
|
arg = arg,
|
|
)),
|
|
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
|
|
"cli.multiple_paths",
|
|
first = first,
|
|
second = second,
|
|
)),
|
|
Self::ResumeWithPath => f.write_str(&crate::t!("cli.resume_with_path")),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ArgsError {}
|
|
|
|
impl Args {
|
|
/// Parse `Args` from the process command line.
|
|
pub fn from_env() -> Result<Self, ArgsError> {
|
|
Self::parse(env::args().skip(1))
|
|
}
|
|
|
|
/// Parse `Args` from an arbitrary iterator (used by tests).
|
|
pub fn parse<I, S>(iter: I) -> Result<Self, ArgsError>
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<String>,
|
|
{
|
|
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 resume = false;
|
|
let mut help = false;
|
|
let mut no_undo = false;
|
|
let mut iter = iter.into_iter().map(Into::into);
|
|
while let Some(arg) = iter.next() {
|
|
match arg.as_str() {
|
|
"--help" | "-h" => {
|
|
help = true;
|
|
}
|
|
"--resume" => {
|
|
resume = true;
|
|
}
|
|
"--no-undo" => {
|
|
no_undo = true;
|
|
}
|
|
"--theme" => {
|
|
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
|
|
theme = match value.as_str() {
|
|
"light" => Theme::light(),
|
|
"dark" => Theme::dark(),
|
|
other => {
|
|
return Err(ArgsError::InvalidValue {
|
|
flag: "theme",
|
|
value: other.to_string(),
|
|
expected: "light, dark",
|
|
});
|
|
}
|
|
};
|
|
}
|
|
"--log-file" => {
|
|
let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?;
|
|
log_path = Some(PathBuf::from(value));
|
|
}
|
|
"--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));
|
|
}
|
|
}
|
|
}
|
|
if resume && project_path.is_some() {
|
|
return Err(ArgsError::ResumeWithPath);
|
|
}
|
|
Ok(Self {
|
|
theme,
|
|
log_path,
|
|
data_dir,
|
|
project_path,
|
|
resume,
|
|
help,
|
|
no_undo,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn default_theme() -> Theme {
|
|
// NFR-7: support both backgrounds. For the walking skeleton we
|
|
// honour an explicit `--theme` flag and the COLORFGBG env var
|
|
// (which xterm/Konsole/iTerm export in the form `<fg>;<bg>`).
|
|
// True OSC-11 background querying is a later improvement.
|
|
if let Ok(value) = env::var("COLORFGBG")
|
|
&& let Some(bg) = value.split(';').next_back()
|
|
&& let Ok(code) = bg.trim().parse::<u8>()
|
|
{
|
|
// Standard convention: 0..=6 and 8 are dark backgrounds,
|
|
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
|
|
let is_dark = matches!(code, 0..=6 | 8);
|
|
return if is_dark { Theme::dark() } else { Theme::light() };
|
|
}
|
|
Theme::default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::theme::Background;
|
|
|
|
#[test]
|
|
fn no_args_yields_default_theme() {
|
|
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
|
|
// The default depends on environment; we only assert it parsed.
|
|
let _ = args.theme;
|
|
}
|
|
|
|
#[test]
|
|
fn theme_flag_light() {
|
|
let args = Args::parse(["--theme", "light"]).unwrap();
|
|
assert_eq!(args.theme.background, Background::Light);
|
|
}
|
|
|
|
#[test]
|
|
fn theme_flag_dark() {
|
|
let args = Args::parse(["--theme", "dark"]).unwrap();
|
|
assert_eq!(args.theme.background, Background::Dark);
|
|
}
|
|
|
|
#[test]
|
|
fn theme_flag_invalid() {
|
|
let err = Args::parse(["--theme", "neon"]).unwrap_err();
|
|
assert!(matches!(err, ArgsError::InvalidValue { flag: "theme", .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn theme_flag_missing_value() {
|
|
let err = Args::parse(["--theme"]).unwrap_err();
|
|
assert!(matches!(err, ArgsError::MissingValue("theme")));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_flag_errors() {
|
|
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 help_flag_long_form_sets_help() {
|
|
let args = Args::parse(["--help"]).unwrap();
|
|
assert!(args.help);
|
|
}
|
|
|
|
#[test]
|
|
fn help_flag_short_form_sets_help() {
|
|
let args = Args::parse(["-h"]).unwrap();
|
|
assert!(args.help);
|
|
}
|
|
|
|
#[test]
|
|
fn resume_flag_parses() {
|
|
let args = Args::parse(["--resume"]).unwrap();
|
|
assert!(args.resume);
|
|
assert!(args.project_path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn resume_with_positional_path_errors() {
|
|
let err = Args::parse(["--resume", "/some/path"]).unwrap_err();
|
|
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn positional_path_with_resume_errors_in_either_order() {
|
|
let err = Args::parse(["/some/path", "--resume"]).unwrap_err();
|
|
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_undo_flag_parses() {
|
|
let args = Args::parse(["--no-undo"]).unwrap();
|
|
assert!(args.no_undo);
|
|
}
|
|
|
|
#[test]
|
|
fn no_undo_defaults_off() {
|
|
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
|
|
assert!(!args.no_undo, "undo is enabled unless --no-undo is given");
|
|
}
|
|
|
|
#[test]
|
|
fn no_undo_coexists_with_positional_path() {
|
|
let args = Args::parse(["--no-undo", "/home/me/MyProject"]).unwrap();
|
|
assert!(args.no_undo);
|
|
assert_eq!(
|
|
args.project_path.as_deref(),
|
|
Some(std::path::Path::new("/home/me/MyProject"))
|
|
);
|
|
}
|
|
|
|
#[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:?}");
|
|
}
|
|
}
|