use clap::{Arg, Command};
use serde_json::json;
use serial_test::serial;
use standout::cli::{App, CommandContextInput, Output, RunResult};
use standout::input::{
env::MockStdin, reset_default_stdin_reader, set_default_stdin_reader, ArgSource, FlagSource,
InputChain, InputSourceKind, StdinSource,
};
use std::sync::Arc;
fn body_command() -> Command {
Command::new("test")
.subcommand(Command::new("create").arg(Arg::new("body").long("body").short('b')))
}
struct StdinGuard;
impl StdinGuard {
fn piped(content: &str) -> Self {
set_default_stdin_reader(Arc::new(MockStdin::piped(content)));
Self
}
fn terminal() -> Self {
set_default_stdin_reader(Arc::new(MockStdin::terminal()));
Self
}
}
impl Drop for StdinGuard {
fn drop(&mut self) {
reset_default_stdin_reader();
}
}
#[test]
fn arg_value_reaches_handler_via_ctx_input() {
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let body: &String = ctx.input("body").expect("body should be resolved");
Ok(Output::Render(json!({ "echo": body })))
},
|cfg| {
cfg.template("{{ echo }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("FALLBACK".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create", "--body", "hello"]);
match result {
RunResult::Handled(out) => assert_eq!(out, "hello"),
other => panic!("expected Handled, got {:?}", other),
}
}
#[test]
fn default_kicks_in_when_no_source_provides_value() {
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let body: &String = ctx.input("body").unwrap();
Ok(Output::Render(json!({ "echo": body })))
},
|cfg| {
cfg.template("{{ echo }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("FALLBACK".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create"]);
match result {
RunResult::Handled(out) => assert_eq!(out, "FALLBACK"),
other => panic!("expected Handled, got {:?}", other),
}
}
#[test]
fn input_source_reports_arg_kind() {
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let kind = ctx.input_source("body").unwrap();
Ok(Output::Render(json!({ "kind": kind.to_string() })))
},
|cfg| {
cfg.template("{{ kind }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("FALLBACK".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create", "--body", "x"]);
if let RunResult::Handled(out) = result {
assert_eq!(out, InputSourceKind::Arg.to_string());
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
fn input_source_reports_default_kind_when_falling_back() {
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let kind = ctx.input_source("body").unwrap();
Ok(Output::Render(json!({ "kind": kind.to_string() })))
},
|cfg| {
cfg.template("{{ kind }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("FALLBACK".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create"]);
if let RunResult::Handled(out) = result {
assert_eq!(out, InputSourceKind::Default.to_string());
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
#[serial(stdin)]
fn stdin_fallback_when_arg_absent() {
let _stdin = StdinGuard::piped("from stdin\n");
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let body: &String = ctx.input("body").unwrap();
let kind = ctx.input_source("body").unwrap();
Ok(Output::Render(json!({
"echo": body,
"kind": kind.to_string(),
})))
},
|cfg| {
cfg.template("{{ kind }}: {{ echo }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.try_source(StdinSource::new())
.default("FALLBACK".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create"]);
if let RunResult::Handled(out) = result {
assert_eq!(out, "stdin: from stdin");
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
#[serial(stdin)]
fn arg_wins_over_stdin_when_both_available() {
let _stdin = StdinGuard::terminal();
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let body: &String = ctx.input("body").unwrap();
let kind = ctx.input_source("body").unwrap();
Ok(Output::Render(json!({
"echo": body,
"kind": kind.to_string(),
})))
},
|cfg| {
cfg.template("{{ kind }}: {{ echo }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.try_source(StdinSource::new())
.default("FALLBACK".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create", "--body", "from arg"]);
if let RunResult::Handled(out) = result {
assert_eq!(out, "argument: from arg");
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
fn multiple_named_inputs_of_same_type_do_not_collide() {
let cmd = Command::new("test").subcommand(
Command::new("create")
.arg(Arg::new("body").long("body"))
.arg(Arg::new("title").long("title")),
);
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let body: &String = ctx.input("body").unwrap();
let title: &String = ctx.input("title").unwrap();
Ok(Output::Render(json!({
"body": body,
"title": title,
})))
},
|cfg| {
cfg.template("{{ title }} | {{ body }}")
.input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("nobody".to_string()),
)
.input(
"title",
InputChain::<String>::new()
.try_source(ArgSource::new("title"))
.default("untitled".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(
cmd,
vec![
"test",
"create",
"--body",
"the body",
"--title",
"the title",
],
);
if let RunResult::Handled(out) = result {
assert_eq!(out, "the title | the body");
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
fn mixed_types_string_and_bool_coexist() {
let cmd = Command::new("test").subcommand(
Command::new("create")
.arg(Arg::new("body").long("body"))
.arg(
Arg::new("force")
.long("force")
.action(clap::ArgAction::SetTrue),
),
);
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let body: &String = ctx.input("body").unwrap();
let force: &bool = ctx.input("force").unwrap();
Ok(Output::Render(json!({
"body": body,
"force": force,
})))
},
|cfg| {
cfg.template("body={{ body }} force={{ force }}")
.input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("default".to_string()),
)
.input(
"force",
InputChain::<bool>::new()
.try_source(FlagSource::new("force"))
.default(false),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(cmd, vec!["test", "create", "--body", "x", "--force"]);
if let RunResult::Handled(out) = result {
assert_eq!(out, "body=x force=true");
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
fn validation_failure_aborts_before_handler() {
let app = App::builder()
.command_with(
"create",
|_m, _ctx| -> standout::cli::HandlerResult<serde_json::Value> {
panic!("handler must not run when pre-dispatch validation fails");
},
|cfg| {
cfg.template("{{ echo }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.validate(|s| !s.trim().is_empty(), "body must not be empty"),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create", "--body", " "]);
let out = match result {
RunResult::Handled(s) => s,
other => panic!("expected Handled, got {:?}", other),
};
assert!(out.starts_with("Hook error:"), "unexpected output: {out}");
assert!(out.contains("body"), "error should name the input: {out}");
assert!(
out.contains("must not be empty"),
"error should surface validator message: {out}"
);
}
#[test]
fn handler_asking_for_unregistered_input_gets_missing_input_error() {
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let err = ctx.input::<String>("nonexistent").unwrap_err();
Ok(Output::Render(json!({ "error": err.to_string() })))
},
|cfg| {
cfg.template("{{ error }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("x".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create"]);
if let RunResult::Handled(out) = result {
assert!(out.contains("nonexistent"), "got: {out}");
assert!(out.contains("no input"), "got: {out}");
} else {
panic!("expected Handled, got {:?}", result);
}
}
#[test]
fn type_mismatch_lookup_returns_descriptive_error() {
let app = App::builder()
.command_with(
"create",
|_m, ctx| {
let err = ctx.input::<u32>("body").unwrap_err();
Ok(Output::Render(json!({ "error": err.to_string() })))
},
|cfg| {
cfg.template("{{ error }}").input(
"body",
InputChain::<String>::new()
.try_source(ArgSource::new("body"))
.default("x".to_string()),
)
},
)
.unwrap()
.build()
.unwrap();
let result = app.run_to_string(body_command(), vec!["test", "create"]);
if let RunResult::Handled(out) = result {
assert!(out.contains("body"), "got: {out}");
assert!(out.contains("u32"), "got: {out}");
} else {
panic!("expected Handled, got {:?}", result);
}
}