From af8acbfbf28ca8044ca8e52e8b88bdbd2b4c792f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 19:47:32 +0100 Subject: [PATCH] test(maestro): verify umbrella binary routes each subcommand to its crate Closes #10 The compile_error guard and per-feature build already had CI coverage (the "feature-gate matrix" job). What was missing was proof that a subcommand actually reaches its own crate's dispatch at runtime, not just that clap parses it. A malformed --config file makes cli/daemon/tui fail identically at config load, before touching tmux, the network, or a terminal. That turns "clap parsed it but the handler never ran" (a usage error, exit 2) into a distinguishable outcome from "it dispatched into the real crate" (a `toml decode` runtime error via main's error printer, exit 1). The absence-side tests are feature-gated to only exist in a single-feature build, so they run under CI's existing per-feature matrix rather than the default all-features build. --- Cargo.lock | 1 + crates/maestro/Cargo.toml | 3 + crates/maestro/tests/subcommand_routing.rs | 102 +++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 crates/maestro/tests/subcommand_routing.rs diff --git a/Cargo.lock b/Cargo.lock index 12ea8dd..43e4d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1051,6 +1051,7 @@ dependencies = [ "maestro-cli", "maestro-daemon", "maestro-tui", + "tempfile", "tokio", "tracing-subscriber", ] diff --git a/crates/maestro/Cargo.toml b/crates/maestro/Cargo.toml index 3236e45..3b10f72 100644 --- a/crates/maestro/Cargo.toml +++ b/crates/maestro/Cargo.toml @@ -29,3 +29,6 @@ tracing-subscriber.workspace = true maestro-cli = { path = "../maestro-cli", optional = true, default-features = false } maestro-daemon = { path = "../maestro-daemon", optional = true, default-features = false } maestro-tui = { path = "../maestro-tui", optional = true } + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/maestro/tests/subcommand_routing.rs b/crates/maestro/tests/subcommand_routing.rs new file mode 100644 index 0000000..3c376f4 --- /dev/null +++ b/crates/maestro/tests/subcommand_routing.rs @@ -0,0 +1,102 @@ +//! Process-level routing for the umbrella `maestro` binary (#10): each +//! enabled top-level subcommand must reach its own crate's runtime dispatch, +//! not just parse successfully. +//! +//! A malformed `--config` file makes every crate's dispatch fail identically +//! at config load, before any subcommand touches tmux, the network, or a +//! terminal. That lets the assertions tell "clap parsed it but the handler +//! never ran" (a usage error, exit 2) apart from "it dispatched into the real +//! crate" (a `toml decode` runtime error via `main`'s `maestro: {e}` printer, +//! exit 1). +//! +//! The absence tests below only compile when the matching feature is off, so +//! they run under CI's per-feature-subset matrix (`--no-default-features +//! --features cli|daemon|tui`) rather than the default all-features build. + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_maestro")) +} + +fn bad_config() -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().expect("create temp config file"); + write!(f, "this is not valid toml {{{{{{").expect("write temp config"); + f +} + +fn run(args: &[&str]) -> Output { + Command::new(bin()) + .args(args) + .output() + .unwrap_or_else(|e| panic!("spawn `maestro {args:?}`: {e}")) +} + +/// Real dispatch happened: exit 1, `main`'s `"maestro: {e}"` prefix, and the +/// config crate's error surfacing through — never clap's own usage-error +/// page, which would mean the subcommand never reached its handler. +fn assert_dispatched(out: &Output) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}"); + assert!(stderr.starts_with("maestro: "), "stderr:\n{stderr}"); + assert!(stderr.contains("toml decode"), "stderr:\n{stderr}"); + assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}"); +} + +/// Never reached a handler: clap rejected the subcommand outright because the +/// feature that defines it is off. Only used by the `not(feature = "...")` +/// tests below, which themselves only exist in a single-feature build. +#[cfg(not(all(feature = "cli", feature = "daemon", feature = "tui")))] +fn assert_unrecognized(out: &Output, name: &str) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(2), "stderr:\n{stderr}"); + assert!( + stderr.contains("unrecognized subcommand"), + "stderr:\n{stderr}" + ); + assert!(stderr.contains(name), "stderr:\n{stderr}"); +} + +#[cfg(feature = "cli")] +#[test] +fn list_dispatches_into_maestro_cli() { + let cfg = bad_config(); + let out = run(&["list", "--config", cfg.path().to_str().unwrap()]); + assert_dispatched(&out); +} + +#[cfg(not(feature = "cli"))] +#[test] +fn list_is_unrecognized_without_cli_feature() { + assert_unrecognized(&run(&["list"]), "list"); +} + +#[cfg(feature = "daemon")] +#[test] +fn daemon_dispatches_into_maestro_daemon() { + let cfg = bad_config(); + let out = run(&["daemon", "--config", cfg.path().to_str().unwrap()]); + assert_dispatched(&out); +} + +#[cfg(not(feature = "daemon"))] +#[test] +fn daemon_is_unrecognized_without_daemon_feature() { + assert_unrecognized(&run(&["daemon"]), "daemon"); +} + +#[cfg(feature = "tui")] +#[test] +fn tui_dispatches_into_maestro_tui() { + let cfg = bad_config(); + let out = run(&["tui", "--config", cfg.path().to_str().unwrap()]); + assert_dispatched(&out); +} + +#[cfg(not(feature = "tui"))] +#[test] +fn tui_is_unrecognized_without_tui_feature() { + assert_unrecognized(&run(&["tui"]), "tui"); +}