6ca297579e
Completes the i18n sweep started in the previous commit. All
remaining hand-rolled user-facing English strings inside
thiserror #[error(...)] attributes have been moved into the
catalog. Drops the thiserror dependency entirely.
Twelve error types migrated:
- dsl::action::UnknownAction → parse.custom.unknown_action
- dsl::parser::ParseError → parse.error_wrapper + parse.empty
- dsl::value::ValueError → value.{type_mismatch,format}
- persistence::csv_io::CsvError → persistence.csv.*
- persistence::mod::PersistenceError → persistence.{io,encode}
- persistence::yaml::YamlError → persistence.yaml.*
- persistence::migrations::MigrateError → persistence.migrate.*
- project::lock::LockError → project.lock.*
- project::naming::NamingError → project.naming.*
- project::naming::UserNameError → project.user_name.*
- project::mod::ProjectError → project.{path_not_found,...}
- project::mod::SafeDeleteError → project.safe_delete.*
- archive::ArchiveError → archive.*
- cli::ArgsError → cli.*
- db::DbError → db.error.*
Pattern per type: drop thiserror::Error derive, write manual
Display calling crate::t!(), keep #[from] semantics via
explicit From impls, override Error::source() where applicable
so #[source]-style chaining is preserved.
Why this matters (user rationale): "fine to have fallbacks for
errors that are purely technical, but lift the output to a
place where it can be localized later and where an adjustment
with friendly text is easily possible if any of them become
part of the happy path." All surface strings now live in
en-US.yaml and can be reworded or localized without touching
Rust source.
Tests: 769 passing, 0 failed, 1 ignored. Clippy clean with
-D warnings. Cargo.toml: drop thiserror = "2.0.18".
311 lines
9.9 KiB
Rust
311 lines
9.9 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,
|
|
}
|
|
|
|
/// 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 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 `<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 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:?}");
|
|
}
|
|
}
|