TUI walking skeleton (Phase 4)
First implementation milestone: Cargo project, dependencies,
and a minimal but functional TUI shell built on Ratatui +
Crossterm + Tokio in the Elm-style update/view pattern
(Candidate A from Phase 2/3 selection).
Includes:
- Three-region layout: items list (left), output + input + hint
(right), bottom status bar with mode-aware shortcuts.
- Two themes (light, dark) plus COLORFGBG auto-detect, per
NFR-7. CLI: --theme {light,dark}, --log-file <path>.
- Input modes per ADR-0003: simple (default), advanced, with
the `:` one-shot escape including immediate prompt reaction
("Advanced:" label, advanced border) and auto-inserted space
after a leading `:` in simple mode.
- App-level commands: `quit`/`q`, `mode simple`/`mode advanced`
(canonical list per ADR-0003 — remaining commands land in
later iterations).
- File logging via tracing, defaulting to ~/.rdbms-playground/
playground.log so the TUI is not corrupted by stdio.
Testing per ADR-0008:
- Tier 1: 29 unit tests covering input handling, mode switch,
one-shot escape, auto-space, output buffering, CLI parsing.
- Tier 2: 4 insta snapshots (default simple/advanced/light,
one-shot active) of TestBackend frames.
- Tier 3: 7 integration tests driving synthetic events through
App::update + render path.
All green: 36 tests, 0 failures, 0 skips. Clippy clean with
nursery lints enabled.
This commit is contained in:
+132
@@ -0,0 +1,132 @@
|
||||
//! 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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ArgsError {
|
||||
#[error("missing value for --{0}")]
|
||||
MissingValue(&'static str),
|
||||
#[error("invalid value for --{flag}: {value} (expected one of: {expected})")]
|
||||
InvalidValue {
|
||||
flag: &'static str,
|
||||
value: String,
|
||||
expected: &'static str,
|
||||
},
|
||||
#[error("unknown argument: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
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 iter = iter.into_iter().map(Into::into);
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--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));
|
||||
}
|
||||
other => return Err(ArgsError::Unknown(other.to_string())),
|
||||
}
|
||||
}
|
||||
Ok(Self { theme, log_path })
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user