use clap::{Arg, ArgAction, Command};
use serde_json::json;
use serial_test::serial;
use standout::cli::{App, ExitStatus, HelpResult, Output, RunErrorKind, SuccessKind};
use standout_input::env::MockStdin;
use standout_input::{reset_default_stdin_reader, set_default_stdin_reader};
use standout_test::TestHarness;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn app_command() -> Command {
Command::new("app")
.version("1.2.3")
.arg(
Arg::new("loud")
.long("loud")
.global(true)
.action(ArgAction::SetTrue),
)
.subcommand(Command::new("list").alias("ls"))
.subcommand(Command::new("add"))
.subcommand(Command::new("db").subcommand(Command::new("migrate")))
.subcommand(Command::new("unhandled"))
}
fn register(builder: App) -> App {
builder
.command(
"list",
|m, _ctx| {
Ok(Output::Render(json!({
"cmd": "list",
"loud": m.get_flag("loud"),
})))
},
"{{ cmd }} loud={{ loud }}",
)
.unwrap()
.command(
"add",
|m, _ctx| {
use standout_input::env::{DefaultStdin, StdinReader};
let piped = DefaultStdin.read_to_string().unwrap_or_default();
Ok(Output::Render(json!({
"cmd": "add",
"stdin": piped.trim(),
"loud": m.get_flag("loud"),
})))
},
"{{ cmd }} stdin={{ stdin }} loud={{ loud }}",
)
.unwrap()
.command(
"db.migrate",
|_m, _ctx| Ok(Output::Render(json!({ "cmd": "db.migrate" }))),
"{{ cmd }}",
)
.unwrap()
}
fn piped_aware_app() -> App {
register(App::builder().default_command_with(|ctx| {
Some(if ctx.stdin_is_piped() { "add" } else { "list" }.to_string())
}))
.build()
.unwrap()
}
fn counting_app(calls: Arc<AtomicUsize>) -> App {
register(App::builder().default_command_with(move |ctx| {
calls.fetch_add(1, Ordering::SeqCst);
Some(if ctx.stdin_is_piped() { "add" } else { "list" }.to_string())
}))
.build()
.unwrap()
}
#[test]
#[serial]
fn terminal_stdin_selects_the_interactive_command() {
let result =
TestHarness::new()
.interactive_stdin()
.run(&piped_aware_app(), app_command(), ["app"]);
result.assert_success();
result.assert_stdout_eq("list loud=false");
}
#[test]
#[serial]
fn piped_stdin_with_data_selects_the_piped_command() {
let result = TestHarness::new().piped_stdin("ship the docs\n").run(
&piped_aware_app(),
app_command(),
["app"],
);
result.assert_success();
result.assert_stdout_eq("add stdin=ship the docs loud=false");
}
#[test]
#[serial]
fn piped_but_empty_stdin_still_selects_the_piped_command() {
let result = TestHarness::new()
.piped_stdin("")
.run(&piped_aware_app(), app_command(), ["app"]);
result.assert_success();
result.assert_stdout_eq("add stdin= loud=false");
}
#[test]
#[serial]
fn globals_survive_the_resolved_default() {
let result = TestHarness::new().interactive_stdin().run(
&piped_aware_app(),
app_command(),
["app", "--loud"],
);
result.assert_success();
result.assert_stdout_eq("list loud=true");
}
#[test]
#[serial]
fn resolver_reads_root_matches_and_app_state() {
struct Fallback(&'static str);
let app = register(
App::builder()
.app_state(Fallback("add"))
.default_command_with(|ctx| {
if ctx.matches().get_flag("loud") {
return Some("list".to_string());
}
ctx.app_state::<Fallback>().map(|f| f.0.to_string())
}),
)
.build()
.unwrap();
let from_flag =
TestHarness::new()
.interactive_stdin()
.run(&app, app_command(), ["app", "--loud"]);
from_flag.assert_stdout_eq("list loud=true");
drop(from_flag);
let from_state = TestHarness::new()
.interactive_stdin()
.run(&app, app_command(), ["app"]);
from_state.assert_stdout_eq("add stdin= loud=false");
}
#[test]
#[serial]
fn a_naked_invocation_runs_the_resolver_once() {
let calls = Arc::new(AtomicUsize::new(0));
let result = TestHarness::new().interactive_stdin().run(
&counting_app(calls.clone()),
app_command(),
["app"],
);
result.assert_stdout_eq("list loud=false");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[test]
#[serial]
fn an_explicit_command_takes_precedence() {
let calls = Arc::new(AtomicUsize::new(0));
let result = TestHarness::new().piped_stdin("would have meant add").run(
&counting_app(calls.clone()),
app_command(),
["app", "list"],
);
result.assert_success();
result.assert_stdout_eq("list loud=false");
assert_eq!(calls.load(Ordering::SeqCst), 0, "resolver must not run");
}
#[test]
#[serial]
fn a_nested_command_takes_precedence() {
let calls = Arc::new(AtomicUsize::new(0));
let result = TestHarness::new().piped_stdin("data").run(
&counting_app(calls.clone()),
app_command(),
["app", "db", "migrate"],
);
result.assert_success();
result.assert_stdout_eq("db.migrate");
assert_eq!(calls.load(Ordering::SeqCst), 0, "resolver must not run");
}
#[test]
#[serial]
fn help_is_unchanged() {
let calls = Arc::new(AtomicUsize::new(0));
let result = TestHarness::new().piped_stdin("data").run(
&counting_app(calls.clone()),
app_command(),
["app", "--help"],
);
assert_eq!(result.success_kind(), Some(SuccessKind::ClapHelp));
result.assert_stdout_contains("Usage:");
assert_eq!(calls.load(Ordering::SeqCst), 0, "resolver must not run");
}
#[test]
#[serial]
fn version_is_unchanged() {
let calls = Arc::new(AtomicUsize::new(0));
let result = TestHarness::new().piped_stdin("data").run(
&counting_app(calls.clone()),
app_command(),
["app", "--version"],
);
assert_eq!(result.success_kind(), Some(SuccessKind::ClapVersion));
result.assert_stdout_contains("1.2.3");
assert_eq!(calls.load(Ordering::SeqCst), 0, "resolver must not run");
}
#[test]
#[serial]
fn invalid_syntax_stays_a_clap_usage_error() {
let calls = Arc::new(AtomicUsize::new(0));
let result = TestHarness::new().piped_stdin("data").run(
&counting_app(calls.clone()),
app_command(),
["app", "--nonexistent"],
);
result.assert_error();
result.assert_error_kind(RunErrorKind::ClapUsage);
assert_eq!(
calls.load(Ordering::SeqCst),
0,
"resolution runs only after a successful naked parse"
);
}
#[test]
#[serial]
fn a_static_default_still_applies_on_its_own() {
let app = register(App::builder().default_command("list"))
.build()
.unwrap();
let result = TestHarness::new()
.piped_stdin("ignored — no resolver configured")
.run(&app, app_command(), ["app"]);
result.assert_success();
result.assert_stdout_eq("list loud=false");
}
#[test]
#[serial]
fn a_declining_resolver_falls_back_to_the_static_default() {
let app = register(
App::builder()
.default_command("list")
.default_command_with(|ctx| ctx.stdin_is_piped().then(|| "add".to_string())),
)
.build()
.unwrap();
let piped = TestHarness::new()
.piped_stdin("payload")
.run(&app, app_command(), ["app"]);
piped.assert_stdout_eq("add stdin=payload loud=false");
drop(piped);
let terminal = TestHarness::new()
.interactive_stdin()
.run(&app, app_command(), ["app"]);
terminal.assert_stdout_eq("list loud=false");
}
#[test]
#[serial]
fn no_default_configured_leaves_a_naked_invocation_alone() {
let app = register(App::builder()).build().unwrap();
let result = TestHarness::new()
.interactive_stdin()
.run(&app, app_command(), ["app"]);
result.assert_no_match();
}
#[test]
#[serial]
fn resolving_to_a_command_standout_does_not_handle_reports_no_match() {
let app = register(App::builder().default_command_with(|_ctx| Some("unhandled".to_string())))
.build()
.unwrap();
let result = TestHarness::new()
.interactive_stdin()
.run(&app, app_command(), ["app"]);
result.assert_no_match();
}
#[test]
#[serial]
fn resolving_to_an_unknown_command_is_a_typed_error_not_a_panic() {
let app = register(App::builder().default_command_with(|_ctx| Some("nope".to_string())))
.build()
.unwrap();
let result = TestHarness::new()
.interactive_stdin()
.run(&app, app_command(), ["app"]);
result.assert_error();
result.assert_error_kind(RunErrorKind::DefaultCommand);
result.assert_error_contains("default command resolver returned `nope`");
result.assert_exit_status(ExitStatus::FAILURE);
}
#[test]
#[serial]
fn get_matches_from_reports_an_unknown_command_as_a_clap_error() {
let app = register(App::builder().default_command_with(|_ctx| Some("nope".to_string())))
.build()
.unwrap();
with_stdin(MockStdin::terminal(), || {
match app.get_matches_from(app_command(), ["app"]) {
HelpResult::Error(e) => assert!(
e.to_string()
.contains("default command resolver returned `nope`"),
"{e}"
),
other => panic!("expected a clap error, got {other:?}"),
}
});
}
struct StdinGuard;
impl StdinGuard {
fn install(reader: MockStdin) -> Self {
set_default_stdin_reader(Arc::new(reader));
Self
}
}
impl Drop for StdinGuard {
fn drop(&mut self) {
reset_default_stdin_reader();
}
}
fn with_stdin<R>(reader: MockStdin, body: impl FnOnce() -> R) -> R {
let _guard = StdinGuard::install(reader);
body()
}
#[test]
#[serial]
fn get_matches_from_resolves_the_same_default_as_dispatch() {
let app = piped_aware_app();
with_stdin(MockStdin::terminal(), || {
match app.get_matches_from(app_command(), ["app"]) {
HelpResult::Matches(m) => assert_eq!(m.subcommand_name(), Some("list")),
other => panic!("expected matches, got {other:?}"),
}
});
with_stdin(MockStdin::piped("payload"), || {
match app.get_matches_from(app_command(), ["app"]) {
HelpResult::Matches(m) => assert_eq!(m.subcommand_name(), Some("add")),
other => panic!("expected matches, got {other:?}"),
}
});
with_stdin(MockStdin::piped_empty(), || {
match app.get_matches_from(app_command(), ["app"]) {
HelpResult::Matches(m) => assert_eq!(m.subcommand_name(), Some("add")),
other => panic!("expected matches, got {other:?}"),
}
});
}
#[test]
#[serial]
fn get_matches_from_leaves_invalid_syntax_a_clap_error() {
let app = piped_aware_app();
with_stdin(MockStdin::piped("data"), || {
match app.get_matches_from(app_command(), ["app", "--nonexistent"]) {
HelpResult::Error(_) => {}
other => panic!("expected a clap error, got {other:?}"),
}
});
}
#[test]
#[serial]
fn get_matches_from_applies_a_static_default() {
let app = register(App::builder().default_command("list"))
.build()
.unwrap();
match app.get_matches_from(app_command(), ["app", "--loud"]) {
HelpResult::Matches(m) => {
assert_eq!(m.subcommand_name(), Some("list"));
assert!(m.get_flag("loud"));
}
other => panic!("expected matches, got {other:?}"),
}
}
#[test]
#[serial]
fn get_matches_from_leaves_explicit_and_nested_commands_alone() {
let app = piped_aware_app();
match app.get_matches_from(app_command(), ["app", "db", "migrate"]) {
HelpResult::Matches(m) => {
let (name, sub) = m.subcommand().expect("db");
assert_eq!(name, "db");
assert_eq!(sub.subcommand_name(), Some("migrate"));
}
other => panic!("expected matches, got {other:?}"),
}
}