Files
rdbms-playground/src/cli.rs
T
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00

548 lines
18 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::mode::Mode;
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,
/// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`,
/// the single source of truth — ADR-0054) and exit. Checked
/// alongside `--help` before any other work.
pub version: 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,
/// `--mode simple|advanced`: start in this input mode,
/// overriding the project's stored mode (ADR-0015 mode-restore
/// amendment, issue #14). Precedence: `--mode` > stored project
/// mode > the default (`simple`). Combines with `--resume` and
/// a positional path; on collision the flag wins.
pub mode: Option<Mode>,
/// `--demo` (or `RDBMS_PLAYGROUND_DEMO` set truthy): enter
/// **demonstration mode** (ADR-0047, issue #22). Off by default,
/// zero footprint when off. When on, the app shows transient
/// on-screen badges for otherwise-invisible keys (Tab, Enter, …)
/// and enables the `Ctrl+]` stealth step-caption buffer — for
/// screencasts and live teaching. The flag wins over the env var.
pub demo: 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")
}
/// Version line printed by `--version` / `-V` and the in-app `version`
/// command (ADR-0054).
///
/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*`
/// release tag (the release CI guards that), so what the binary reports
/// always matches the downloaded artifact.
#[must_use]
pub fn version_text() -> String {
crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION"))
}
#[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 version = false;
let mut no_undo = false;
let mut mode: Option<Mode> = None;
// Demonstration mode (ADR-0047): the env var is the default,
// the `--demo` flag overrides it to on. Mirrors the
// env-then-flag layering used for the log file above.
let mut demo = env::var("RDBMS_PLAYGROUND_DEMO")
.ok()
.is_some_and(|v| demo_value_is_truthy(&v));
let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--help" | "-h" => {
help = true;
}
"--version" | "-V" => {
version = true;
}
"--resume" => {
resume = true;
}
"--no-undo" => {
no_undo = true;
}
"--demo" => {
demo = 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));
}
"--mode" => {
let value = iter.next().ok_or(ArgsError::MissingValue("mode"))?;
mode = Some(Mode::from_keyword(&value).ok_or_else(|| {
ArgsError::InvalidValue {
flag: "mode",
value: value.clone(),
expected: "simple, advanced",
}
})?);
}
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,
version,
no_undo,
mode,
demo,
})
}
}
/// Whether a `RDBMS_PLAYGROUND_DEMO` value enables demo mode.
///
/// Truthy for any value except the conventional "off" set
/// (`0`/`false`/`no`/`off`, case-insensitively, and the empty
/// string). So `RDBMS_PLAYGROUND_DEMO=1` and `=true` enable, while
/// `=0` / `=false` explicitly disable — letting a value of `0` turn
/// it off even if something upstream exported the variable.
fn demo_value_is_truthy(value: &str) -> bool {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
}
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")));
}
// ---- ADR-0015 mode-restore amendment (issue #14): --mode ----
#[test]
fn no_mode_flag_yields_none() {
// Absent `--mode` is "no startup override" — the runtime
// then falls back to the project's stored mode.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert_eq!(args.mode, None);
}
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(
Args::parse(["--mode", "simple"]).unwrap().mode,
Some(Mode::Simple)
);
assert_eq!(
Args::parse(["--mode", "advanced"]).unwrap().mode,
Some(Mode::Advanced)
);
// Case-insensitive, like the `mode` command.
assert_eq!(
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
Some(Mode::Advanced)
);
}
#[test]
fn mode_flag_invalid_value() {
let err = Args::parse(["--mode", "expert"]).unwrap_err();
assert!(matches!(err, ArgsError::InvalidValue { flag: "mode", .. }));
}
#[test]
fn mode_flag_missing_value() {
let err = Args::parse(["--mode"]).unwrap_err();
assert!(matches!(err, ArgsError::MissingValue("mode")));
}
#[test]
fn mode_flag_combines_with_resume() {
// `--mode` is not mutually exclusive with `--resume`; the
// flag is the startup override, resume picks the project.
let args = Args::parse(["--resume", "--mode", "advanced"]).unwrap();
assert!(args.resume);
assert_eq!(args.mode, Some(Mode::Advanced));
}
#[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"))
);
}
// ---- ADR-0047 (issue #22): --demo demonstration mode ----
#[test]
fn demo_flag_parses() {
let args = Args::parse(["--demo"]).unwrap();
assert!(args.demo);
}
#[test]
fn demo_defaults_off() {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(
!args.demo,
"demo is off unless --demo or the env var is given"
);
}
#[test]
fn demo_flag_coexists_with_positional_path() {
let args = Args::parse(["--demo", "/home/me/MyProject"]).unwrap();
assert!(args.demo);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
);
}
#[test]
fn demo_flag_combines_with_resume_and_mode() {
let args = Args::parse(["--resume", "--demo", "--mode", "advanced"]).unwrap();
assert!(args.demo);
assert!(args.resume);
assert_eq!(args.mode, Some(Mode::Advanced));
}
#[test]
fn demo_env_value_truthiness() {
// Enabling values.
for v in ["1", "true", "TRUE", "yes", "on", "anything", " 1 "] {
assert!(demo_value_is_truthy(v), "{v:?} should enable demo mode");
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(
!demo_value_is_truthy(v),
"{v:?} should not enable demo mode"
);
}
}
#[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:?}"
);
}
// ---- ADR-0054: --version / -V ----
#[test]
fn version_long_flag_parses() {
assert!(Args::parse(["--version"]).unwrap().version);
}
#[test]
fn version_short_flag_parses() {
assert!(Args::parse(["-V"]).unwrap().version);
}
#[test]
fn version_defaults_off() {
assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version);
}
#[test]
fn version_text_carries_the_cargo_version() {
// The binary's self-reported version IS Cargo.toml's (the
// single source of truth, ADR-0054) — and the release CI guards
// that the `v*` tag equals it.
let text = version_text();
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"version line should embed CARGO_PKG_VERSION; got {text:?}"
);
}
}