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:
claude@clouddev1
2026-05-07 11:17:58 +00:00
parent aebfc7dcba
commit 25a0f1260f
19 changed files with 3624 additions and 0 deletions
+132
View File
@@ -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"));
}
}