//! 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, /// `--data-dir `: replace the OS-standard data root /// for the duration of this run (ADR-0015 §1). pub data_dir: Option, /// Positional path argument: open an existing project at /// this path (L1, ADR-0015 §1). Mutually exclusive with /// `--resume`. pub project_path: Option, /// `--resume`: open the most-recently-used project at /// startup (L1a, ADR-0015 §7). Reads the path from /// `/last_project`. Mutually exclusive with /// `` — 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, } /// 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::parse(env::args().skip(1)) } /// Parse `Args` from an arbitrary iterator (used by tests). pub fn parse(iter: I) -> Result where I: IntoIterator, S: Into, { let mut theme = default_theme(); let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from); let mut data_dir: Option = None; let mut project_path: Option = None; let mut resume = false; let mut help = 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; } "--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, }) } } 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 `;`). // 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::() { // 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 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:?}"); } }