diff --git a/AGENTS.md b/AGENTS.md index 25a4b6c..4244280 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,13 @@ Read `README.md` and `docs/engineering-spec.md` before making non-trivial change - Parse external data once at a boundary. Do not scatter defensive fallbacks through readers. - Tests must not depend on real sleeps, wall-clock timing, real shell commands, or a real terminal. +## Quality Policy + +- `./scripts/check` is the local definition of done for code changes. Run it before calling work done. +- Behavior changes should include automated coverage: a unit test, an integration test (e.g., `tests/cli.rs`), example-config validation (`tests/examples.rs`), or a documented reason why no automated test fits. +- Docs-only changes must leave the repo green under `./scripts/check`. +- Do not add new CI requirements or local-tool dependencies in this issue set — that belongs to future CI work. + ## Current V1 Direction - Rust binary. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4776dbd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-05-27 + +The first usable release of `hud`, a programmable terminal cockpit for getting +oriented, finding the next thing that needs attention, and jumping into action +from the keyboard. + +### Added + +- Static TOML config with panel definitions, actions, and timeout overrides. +- Command-backed panels with plain text, structured table (`table-json`), and + metrics gauge (`metrics-json`) output protocols. +- Manual refresh for the focused panel (`r`) and all panels (`R`). +- Vim-like keyboard navigation (`h`/`j`/`k`/`l`, arrow keys, `Tab`/`Shift-Tab`) + through the dashboard card grid. +- Drill-in from the dashboard into panel detail views (`Enter`). +- Row selection (`j`/`k`) in detail views for `table-json` panels. +- Configured row drill-in commands with `{{Column Name}}` template substitution + from the selected row. +- Fire-and-forget focused-panel actions, shown in the footer and triggered by + configured single-key shortcuts. +- Help/actions overlay (`?`) showing available keybindings and panel actions. +- Bounded command execution with a 120-second default timeout and per-panel + `timeout_secs` overrides. +- Visible error states for command failures, timeouts, and malformed structured + output — broken panels cannot crash the app. +- `--demo` flag for a zero-config tour of the cockpit loop. +- `--check-config` flag to validate a config file without opening the TUI. +- `--config` flag to load a specific TOML file. +- tmux popup integration via `display-popup -E`. +- Example configs: `dogfood.toml` and `kitchen-sink.toml`. +- Source-install path: `cargo install --git git@github.com:tnezdev/hud.git`. +- CI pipeline: `cargo fmt`, `cargo clippy`, and `cargo test` on every PR and + push to `main`. + +### Known Limitations + +- Manual refresh only — no background polling, intervals, or live tails. +- Local command model — all panel sources and actions run shell commands on + the same machine. No remote data sources, network adapters, or plugin host. +- No persistent state — no dismissals, action history, caching, or session + recall between runs. +- No plugin host — adding panel types or output protocols requires source + changes, not a plugin API. +- Structured protocols are intentionally small — `table-json` and + `metrics-json` cover the initial use cases; additional semantic components + are reserved for later releases. +- Single dashboard — no multi-page or tabbed layouts. + +[0.1.0]: https://github.com/tnezdev/hud/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index d76647c..0d0b829 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,65 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "assert_cmd" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "castaway" version = "0.2.4" @@ -144,6 +191,12 @@ dependencies = [ "syn", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "document-features" version = "0.2.12" @@ -175,6 +228,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -208,7 +270,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "hud" version = "0.1.0" dependencies = [ + "assert_cmd", "crossterm", + "predicates", "ratatui", "serde", "serde_json", @@ -348,12 +412,27 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -398,6 +477,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -488,6 +597,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rustc_version" version = "0.4.1" @@ -667,6 +805,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -776,6 +920,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index 2c6fb8d..e33d6ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,10 @@ name = "hud" version = "0.1.0" edition = "2024" +description = "A programmable terminal cockpit for getting oriented and jumping into action" +license = "MIT" +repository = "https://github.com/tnezdev/hud" +readme = "README.md" [dependencies] crossterm = "0.29.0" @@ -9,3 +13,7 @@ ratatui = { version = "0.30.0", default-features = false, features = ["crossterm serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" toml = "1.1.2" + +[dev-dependencies] +assert_cmd = "2.2.2" +predicates = "3.1.4" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0cfd547 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Travis Nesland + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/kitchen-sink.toml b/examples/kitchen-sink.toml index 9f271ab..37610f3 100644 --- a/examples/kitchen-sink.toml +++ b/examples/kitchen-sink.toml @@ -1,10 +1,13 @@ +# hud kitchen-sink config — demonstrates every feature +# Run: hud --config examples/kitchen-sink.toml + title = "hud kitchen sink" default_timeout_secs = 10 [[panels]] id = "orientation" title = "Orientation" -command = "printf 'hud kitchen sink\n\nThis is a valid hud config, not a special app mode.\nUse it to dogfood layout, focus, actions, and panel density.\n\nKeys:\nq quit\nhjkl/arrows move\nr refresh focused\nR refresh all\n'" +command = "printf 'hud kitchen sink\n\nThis config demonstrates every feature:\nplain text, metrics, tables, row detail, and actions.\n\nKeys:\n q quit\n hjkl or arrows move focus\n r refresh focused panel\n R refresh all panels\n Enter drill into focused panel\n'" timeout_secs = 5 [[panels.actions]] @@ -13,7 +16,7 @@ label = "open README" command = "${EDITOR:-vi} README.md" [[panels]] -id = "status" +id = "metrics" title = "Status Metrics" command = "printf '{\"type\":\"metrics\",\"metrics\":[{\"label\":\"Budget\",\"value\":72,\"max\":100},{\"label\":\"Quota\",\"value\":43,\"max\":100},{\"label\":\"Open PRs\",\"value\":3,\"max\":10},{\"label\":\"Tasks\",\"value\":8,\"max\":20}]}\n'" output = "metrics-json" @@ -22,29 +25,24 @@ timeout_secs = 5 [[panels]] id = "table" title = "Structured Table" -command = "printf '{\"type\":\"table\",\"columns\":[\"Issue\",\"Title\",\"State\"],\"rows\":[[\"#12\",\"TUI design spike\",\"done\"],[\"#14\",\"Row selection\",\"done\"],[\"#15\",\"Structured output\",\"active\"],[\"#16\",\"Row drill-in\",\"next\"]]}\n'" +command = "printf '{\"type\":\"table\",\"columns\":[\"Service\",\"Status\",\"Region\"],\"rows\":[[\"api-gateway\",\"healthy\",\"us-east-1\"],[\"worker\",\"healthy\",\"us-west-2\"],[\"db-primary\",\"degraded\",\"us-east-1\"]]}\n'" output = "table-json" timeout_secs = 5 [panels.row_detail] -title = "Issue Detail" -command = "printf 'Issue: %s\nTitle: %s\nState: %s\n\nThis row-detail view was fetched through a configured command.\n' '{{Issue}}' '{{Title}}' '{{State}}'" - -[[panels.actions]] -key = "g" -label = "open GitHub" -command = "gh repo view tnezdev/hud --web" +title = "Service Detail" +command = "printf 'Service: %s\nStatus: %s\nRegion: %s\n\nDrill-in detail loaded via row_detail command.\n' '{{Service}}' '{{Status}}' '{{Region}}'" [[panels]] id = "long-output" title = "Long Output" -command = "for n in 01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20; do printf 'line %s scroll affordance candidate\n' \"$n\"; done" +command = "seq 1 20 | while read n; do printf 'line %02d scroll affordance\n' \"$n\"; done" timeout_secs = 5 [[panels]] id = "actions" title = "Actions" -command = "printf 'Panel-specific actions should not make the footer dense forever.\n\nThis config has actions on some panels to test discoverability.\nA future design can move them into an overlay using Ratatui Clear + Paragraph/List.\n'" +command = "printf 'Actions appear in the footer for panels that define them.\nThis panel has one: press e to edit this config.\n'" timeout_secs = 5 [[panels.actions]] @@ -55,5 +53,5 @@ command = "${EDITOR:-vi} examples/kitchen-sink.toml" [[panels]] id = "density" title = "Density" -command = "printf 'compact row A\ncompact row B\ncompact row C\n\nspacious block\n\n more breathing room\n clearer rhythm\n\nQuestion: should secondary panels be summaries, or should hud commit to one focused surface?\n'" +command = "printf 'compact row A\ncompact row B\ncompact row C\n\nspacious block\n\n more breathing room\n clearer rhythm\n'" timeout_secs = 5 diff --git a/examples/starter.toml b/examples/starter.toml new file mode 100644 index 0000000..c4c1092 --- /dev/null +++ b/examples/starter.toml @@ -0,0 +1,47 @@ +# hud starter config — safe for first-time users +# No external tools required; all commands use printf, date, or pwd. +# Run: hud --config examples/starter.toml + +title = "Getting started" +default_timeout_secs = 10 + +[[panels]] +id = "orientation" +title = "Welcome" +command = "printf 'Welcome to hud!\n\nThis starter config shows the basics.\nNo external tools or authentication needed.\n\nKeys:\n q quit\n hjkl or arrows move focus\n r refresh focused panel\n R refresh all panels\n'" +timeout_secs = 5 + +[[panels]] +id = "clock" +title = "Clock" +command = "date" +timeout_secs = 5 + +[[panels]] +id = "metrics" +title = "Sample Metrics" +command = "printf '{\"type\":\"metrics\",\"metrics\":[{\"label\":\"CPU\",\"value\":34,\"max\":100},{\"label\":\"Disk\",\"value\":62,\"max\":100},{\"label\":\"Memory\",\"value\":78,\"max\":100}]}\n'" +output = "metrics-json" +timeout_secs = 5 + +[[panels]] +id = "table" +title = "Sample Table" +command = "printf '{\"type\":\"table\",\"columns\":[\"Service\",\"Status\",\"Latency\"],\"rows\":[[\"api\",\"healthy\",\"12ms\"],[\"worker\",\"healthy\",\"8ms\"],[\"db\",\"degraded\",\"45ms\"]]}\n'" +output = "table-json" +timeout_secs = 5 + +[panels.row_detail] +title = "Service Detail" +command = "printf 'Service: %s\nStatus: %s\nLatency: %s\n' '{{Service}}' '{{Status}}' '{{Latency}}'" + +[[panels]] +id = "here" +title = "Working directory" +command = "pwd" +timeout_secs = 3 + +[[panels.actions]] +key = "e" +label = "edit config" +command = "${EDITOR:-vi} examples/starter.toml" diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..6588c02 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,90 @@ +use std::path::PathBuf; + +#[derive(Debug, Default)] +pub struct Cli { + pub config: Option, + pub check_config: bool, + pub demo: bool, + pub help: bool, + pub version: bool, +} + +impl Cli { + pub fn parse(args: impl IntoIterator) -> Result { + let mut cli = Cli::default(); + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--config" => { + let Some(path) = args.next() else { + return Err("--config requires a path".into()); + }; + cli.config = Some(path.into()); + } + "--check-config" => cli.check_config = true, + "--demo" => cli.demo = true, + "--help" | "-h" => cli.help = true, + "--version" | "-V" => cli.version = true, + unknown => return Err(format!("unknown argument: {unknown}")), + } + } + + Ok(cli) + } +} + +pub fn print_help() { + println!( + "hud {}\n\nUsage:\n hud [--config ] [--check-config]\n hud --demo\n hud --version\n\nKeys:\n q/Esc/Ctrl-C quit\n h/j/k/l or arrows move focus\n r refresh focused panel\n R refresh all panels\n panel action keys are shown in the footer", + env!("CARGO_PKG_VERSION") + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_empty_args() { + let cli = Cli::parse([] as [String; 0]).unwrap(); + assert!(cli.config.is_none()); + assert!(!cli.check_config); + assert!(!cli.demo); + assert!(!cli.help); + assert!(!cli.version); + } + + #[test] + fn parse_config_with_path() { + let cli = Cli::parse(["--config".into(), "my.toml".into()]).unwrap(); + assert_eq!(cli.config.unwrap().to_str().unwrap(), "my.toml"); + } + + #[test] + fn parse_config_without_path_returns_error() { + let err = Cli::parse(["--config".into()]).unwrap_err(); + assert!(err.contains("--config requires a path")); + } + + #[test] + fn parse_unknown_argument_returns_error() { + let err = Cli::parse(["--bogus".into()]).unwrap_err(); + assert!(err.contains("unknown argument")); + } + + #[test] + fn parse_flags() { + let cli = Cli::parse([ + "--demo".into(), + "--check-config".into(), + "--help".into(), + "--version".into(), + ]) + .unwrap(); + assert!(cli.demo); + assert!(cli.check_config); + assert!(cli.help); + assert!(cli.version); + } +} diff --git a/src/command.rs b/src/command.rs index 155016e..44fa70e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -231,4 +231,57 @@ mod tests { assert_eq!(result.stdout, "ok"); assert_eq!(result.status, CommandStatus::Exited(0)); } + + #[test] + fn shell_runner_captures_stdout() { + let runner = ShellCommandRunner; + let result = runner.run(CommandRequest { + command: "printf 'hello world'".into(), + timeout: Duration::from_secs(5), + }); + assert_eq!(result.stdout.trim(), "hello world"); + assert_eq!(result.status, CommandStatus::Exited(0)); + } + + #[test] + fn shell_runner_captures_stderr() { + let runner = ShellCommandRunner; + let result = runner.run(CommandRequest { + command: "printf 'err' >&2".into(), + timeout: Duration::from_secs(5), + }); + assert_eq!(result.stderr.trim(), "err"); + assert_eq!(result.status, CommandStatus::Exited(0)); + } + + #[test] + fn shell_runner_preserves_nonzero_exit() { + let runner = ShellCommandRunner; + let result = runner.run(CommandRequest { + command: "exit 42".into(), + timeout: Duration::from_secs(5), + }); + assert_eq!(result.status, CommandStatus::Exited(42)); + } + + #[test] + fn shell_runner_reports_timeout() { + let runner = ShellCommandRunner; + let result = runner.run(CommandRequest { + command: "sleep 10".into(), + timeout: Duration::from_millis(100), + }); + assert_eq!(result.status, CommandStatus::TimedOut); + } + + #[test] + fn shell_runner_reports_launch_failure() { + let runner = ShellCommandRunner; + let result = runner.run(CommandRequest { + command: "this_command_does_not_exist_abc123".into(), + timeout: Duration::from_secs(5), + }); + // The shell itself exits, so we get a non-zero exit rather than LaunchFailed + assert!(matches!(result.status, CommandStatus::Exited(_))); + } } diff --git a/src/lib.rs b/src/lib.rs index 9aef291..639180d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod action; pub mod app; +pub mod cli; pub mod command; pub mod config; pub mod panel; diff --git a/src/main.rs b/src/main.rs index 012ee77..211c186 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ use hud::{ app::{HudApp, run_terminal_app}, + cli::Cli, command::ShellCommandRunner, config::{HudConfig, demo_config}, }; -use std::{env, path::PathBuf, process}; +use std::{env, process}; fn main() { if let Err(error) = run() { @@ -16,7 +17,7 @@ fn run() -> Result<(), String> { let cli = Cli::parse(env::args().skip(1))?; if cli.help { - print_help(); + hud::cli::print_help(); return Ok(()); } @@ -41,44 +42,3 @@ fn run() -> Result<(), String> { let app = HudApp::new(config, ShellCommandRunner); run_terminal_app(app).map_err(|error| format!("terminal error: {error}")) } - -#[derive(Debug, Default)] -struct Cli { - config: Option, - check_config: bool, - demo: bool, - help: bool, - version: bool, -} - -impl Cli { - fn parse(args: impl IntoIterator) -> Result { - let mut cli = Cli::default(); - let mut args = args.into_iter(); - - while let Some(arg) = args.next() { - match arg.as_str() { - "--config" => { - let Some(path) = args.next() else { - return Err("--config requires a path".into()); - }; - cli.config = Some(path.into()); - } - "--check-config" => cli.check_config = true, - "--demo" => cli.demo = true, - "--help" | "-h" => cli.help = true, - "--version" | "-V" => cli.version = true, - unknown => return Err(format!("unknown argument: {unknown}")), - } - } - - Ok(cli) - } -} - -fn print_help() { - println!( - "hud {}\n\nUsage:\n hud [--config ] [--check-config]\n hud --demo\n hud --version\n\nKeys:\n q/Esc/Ctrl-C quit\n h/j/k/l or arrows move focus\n r refresh focused panel\n R refresh all panels\n panel action keys are shown in the footer", - env!("CARGO_PKG_VERSION") - ); -} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..9b2db1c --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,77 @@ +use assert_cmd::Command; + +fn hud() -> Command { + Command::cargo_bin("hud").unwrap() +} + +#[test] +fn help_exits_successfully() { + hud() + .arg("--help") + .assert() + .success() + .stdout(predicates::str::contains("Usage:")); +} + +#[test] +fn version_exits_successfully() { + hud() + .arg("--version") + .assert() + .success() + .stdout(predicates::str::contains("0.1.0")); +} + +#[test] +fn demo_check_config_exits_successfully() { + hud() + .args(["--demo", "--check-config"]) + .assert() + .success() + .stdout(predicates::str::contains("hud config OK")); +} + +#[test] +fn kitchen_sink_check_config_exits_successfully() { + hud() + .args(["--config", "examples/kitchen-sink.toml", "--check-config"]) + .assert() + .success() + .stdout(predicates::str::contains("hud config OK")); +} + +#[test] +fn dogfood_check_config_exits_successfully() { + hud() + .args(["--config", "examples/dogfood.toml", "--check-config"]) + .assert() + .success() + .stdout(predicates::str::contains("hud config OK")); +} + +#[test] +fn unknown_argument_exits_nonzero() { + hud() + .arg("--bogus") + .assert() + .failure() + .stderr(predicates::str::contains("unknown argument: --bogus")); +} + +#[test] +fn missing_config_path_exits_nonzero() { + hud() + .args(["--config", "/no/such/path.toml"]) + .assert() + .failure() + .stderr(predicates::str::contains("missing hud config")); +} + +#[test] +fn config_without_path_exits_nonzero() { + hud() + .arg("--config") + .assert() + .failure() + .stderr(predicates::str::contains("--config requires a path")); +} diff --git a/tests/examples.rs b/tests/examples.rs new file mode 100644 index 0000000..f488949 --- /dev/null +++ b/tests/examples.rs @@ -0,0 +1,37 @@ +use std::fs; + +use hud::config::HudConfig; + +/// Discover every committed `examples/*.toml` and verify each parses +/// successfully with the real config loader. A broken example config +/// will fail CI. +#[test] +fn committed_examples_parse_successfully() { + let entries = fs::read_dir("examples") + .expect("examples/ directory should exist") + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "toml") { + Some(path) + } else { + None + } + }) + .collect::>(); + + assert!( + !entries.is_empty(), + "expected at least one examples/*.toml file" + ); + + for path in &entries { + let result = HudConfig::load_from_path(path); + assert!( + result.is_ok(), + "example config {} should parse: {:?}", + path.display(), + result.err() + ); + } +}