use super::*;
fn protocol() -> OutputSpec {
OutputSpec::protocol_finite(
["json", "yaml", "plain"],
["split", "stdout", "stderr"],
"json",
"split",
)
.file_sinks(["stdout", "stderr"])
}
fn sample_spec() -> Result<BuiltCliSpec, CliSpecError> {
CliSpec::new("demo", "1.0.0")
.lifecycle_output(protocol())
.command(CommandSpec::root())
.command(
CommandSpec::new(["query"])
.arg(ArgSpec::option_enum("--mode", ["cli", "pipe"]).default("cli"))
.arg(ArgSpec::option("--dsn", "DSN").sensitive())
.arg(ArgSpec::option("--sql", "SQL"))
.arg(ArgSpec::flag("--dry-run"))
.combination(
Combination::new("query")
.action("query")
.about("Run one statement")
.fixed("mode", "cli")
.required(["dsn", "sql"])
.optional(["dry_run"])
.output(protocol()),
)
.combination(
Combination::new("pipe")
.action("pipe")
.about("Serve framed requests")
.fixed("mode", "pipe")
.required(["dsn"])
.output(OutputSpec::protocol_stream(
["json"],
["stdout", "stderr"],
"json",
"stdout",
)),
),
)
.build()
}
#[test]
fn resolves_one_registered_combination() {
let spec = sample_spec().unwrap();
let outcome = spec
.resolve_from([
"demo",
"query",
"--dsn",
"secret",
"--sql",
"select 1",
"--dry-run",
])
.unwrap();
let CliOutcome::Run(invocation) = outcome else {
panic!("expected run");
};
assert_eq!(invocation.combination_id(), "query");
assert_eq!(invocation.action_id(), "query");
assert_eq!(invocation.required("dsn").as_str(), Some("secret"));
assert_eq!(invocation.required("mode").as_str(), Some("cli"));
assert!(invocation.required("misspelled").as_str().is_none());
assert_eq!(
invocation.output_plan().output_format(),
Some(OutputFormat::Json)
);
assert_eq!(invocation.output_plan().output_to(), Some(OutputTo::Split));
assert_eq!(invocation.output_plan().format(), Some("json"));
assert_eq!(invocation.output_plan().destination(), Some("split"));
}
#[test]
fn rejects_known_but_unregistered_argument_combination_without_values() {
let spec = sample_spec().unwrap();
let error = spec
.resolve_from([
"demo",
"query",
"--mode",
"pipe",
"--dsn",
"do-not-leak",
"--dry-run",
])
.unwrap_err();
assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
assert!(!error.message.contains("do-not-leak"));
}
#[test]
fn overlap_accounts_for_default_satisfying_fixed() {
let result = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.arg(ArgSpec::option_enum("--mode", ["cli", "pipe"]).default("cli"))
.combination(Combination::new("fixed").action("run").fixed("mode", "cli"))
.combination(Combination::new("absent").action("run")),
)
.build();
assert_eq!(result.unwrap_err().rule, "overlapping_combinations");
}
#[test]
fn non_default_fixed_does_not_overlap_absent() {
let result = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.arg(ArgSpec::option_enum("--mode", ["cli", "pipe"]).default("cli"))
.combination(
Combination::new("fixed")
.action("run")
.about("Explicit pipe mode")
.fixed("mode", "pipe"),
)
.combination(
Combination::new("absent")
.action("run")
.about("Mode left at its default"),
),
)
.build();
assert!(result.is_ok());
}
#[test]
fn help_is_generated_from_combinations() {
let spec = sample_spec().unwrap();
let outcome = spec.resolve_from(["demo", "query", "--help"]).unwrap();
let CliOutcome::Help(help) = outcome else {
panic!("expected help");
};
let shapes = &help.model().shapes;
assert_eq!(
shapes
.iter()
.map(|shape| shape.id.as_str())
.collect::<Vec<_>>(),
["query", "pipe"]
);
assert!(
shapes[0]
.usage
.starts_with("demo query [--mode cli] --dsn <DSN> --sql <SQL> [--dry-run]"),
"{}",
shapes[0].usage
);
assert_eq!(
help.model().defaults.get("--mode"),
Some(&CliValue::String("cli".to_string()))
);
assert!(shapes.iter().all(|shape| shape.about.is_some()));
}
#[test]
fn fixed_argument_the_default_cannot_satisfy_stays_required() {
let spec = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.arg(ArgSpec::option_enum("--mode", ["cli", "pipe"]).default("cli"))
.combination(
Combination::new("piped")
.action("run")
.fixed("mode", "pipe"),
),
)
.build()
.unwrap();
let CliOutcome::Help(help) = spec.resolve_from(["demo", "run", "--help"]).unwrap() else {
panic!("expected help");
};
assert!(
help.model().shapes[0].usage.contains("--mode pipe"),
"{}",
help.model().shapes[0].usage
);
assert!(!help.model().shapes[0].usage.contains("[--mode"));
assert!(!help.model().defaults.contains_key("--mode"));
}
#[test]
fn help_names_enum_values_and_keys_notes_by_spelling() {
let spec = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.arg(ArgSpec::positional("file", 0, "FILE").about("Input file"))
.arg(
ArgSpec::option_enum("--mode", ["cli", "pipe"])
.value_name("MODE")
.about("How to read input"),
)
.combination(
Combination::new("run")
.action("run")
.required(["file"])
.optional(["mode"]),
),
)
.build()
.unwrap();
let CliOutcome::Help(help) = spec.resolve_from(["demo", "run", "--help"]).unwrap() else {
panic!("expected help");
};
assert!(
help.model().shapes[0].usage.contains("[--mode <cli|pipe>]"),
"{}",
help.model().shapes[0].usage
);
let notes = &help.model().notes;
assert_eq!(
notes.get("--mode").map(String::as_str),
Some("How to read input")
);
assert_eq!(notes.get("FILE").map(String::as_str), Some("Input file"));
}
#[test]
fn output_contract_is_checked_after_shape_selection() {
let spec = sample_spec().unwrap();
let error = spec
.resolve_from([
"demo", "query", "--mode", "pipe", "--dsn", "secret", "--output", "yaml",
])
.unwrap_err();
assert_eq!(error.rule, CliErrorRule::InvalidArgumentValue);
assert!(error.message.contains("--output"), "{}", error.message);
}
#[test]
fn raw_output_rejects_format_as_unregistered() {
let spec = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["raw"]).combination(
Combination::new("raw")
.action("raw")
.output(OutputSpec::raw()),
),
)
.build()
.unwrap();
let error = spec
.resolve_from(["demo", "raw", "--output", "json"])
.unwrap_err();
assert_eq!(error.rule, CliErrorRule::UnregisteredCombination);
}
#[test]
fn projected_defaults_are_local_to_selected_combination() {
let spec = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.arg(ArgSpec::option_enum("--mode", ["one", "two"]))
.arg(ArgSpec::option("--only-one", "VALUE").default("default"))
.combination(
Combination::new("one")
.about("First shape")
.action("run")
.fixed("mode", "one")
.optional(["only_one"]),
)
.combination(
Combination::new("two")
.action("run")
.about("Second shape")
.fixed("mode", "two"),
),
)
.build()
.unwrap();
let CliOutcome::Run(invocation) = spec.resolve_from(["demo", "run", "--mode", "two"]).unwrap()
else {
panic!("expected run");
};
assert!(invocation.optional("only_one").is_none());
}
#[test]
fn every_synthetic_invocation_resolves_to_its_own_combination() {
let spec = sample_spec().unwrap();
let fixtures = spec.synthetic_invocations();
assert_eq!(fixtures.len(), 2);
for fixture in fixtures {
let CliOutcome::Run(invocation) = spec.resolve_from(fixture.argv).unwrap() else {
panic!("synthetic invocation did not resolve to Run");
};
assert_eq!(invocation.combination_id(), fixture.combination_id);
}
}
#[test]
fn every_optional_subset_is_accepted() {
let spec = sample_spec().unwrap();
for suffix in [Vec::<&str>::new(), vec!["--dry-run"]] {
let mut argv = vec!["demo", "query", "--dsn", "secret", "--sql", "select 1"];
argv.extend(suffix);
let CliOutcome::Run(invocation) = spec.resolve_from(argv).unwrap() else {
panic!("optional subset did not resolve");
};
assert_eq!(invocation.combination_id(), "query");
}
}
#[test]
fn action_binding_requires_exact_distinct_coverage() {
fn handler(_: &ResolvedInvocation) {}
let spec = sample_spec().unwrap();
let missing = spec.bind_actions([("query", handler as fn(&ResolvedInvocation))]);
assert!(matches!(
missing,
Err(CliSpecError {
rule: "action_handler_coverage",
..
})
));
let exact = spec.bind_actions([
("query", handler as fn(&ResolvedInvocation)),
("pipe", handler as fn(&ResolvedInvocation)),
]);
assert!(exact.is_ok());
}
#[test]
fn action_binding_rejects_an_invocation_from_another_registry() {
fn query(_: &ResolvedInvocation) -> &'static str {
"query"
}
fn pipe(_: &ResolvedInvocation) -> &'static str {
"pipe"
}
let first = sample_spec().unwrap();
let app = first
.bind_actions([
("query", query as fn(&ResolvedInvocation) -> &'static str),
("pipe", pipe as fn(&ResolvedInvocation) -> &'static str),
])
.unwrap();
let BoundOutcome::Run(own) = app
.resolve_from(["demo", "query", "--dsn", "secret", "--sql", "select 1"])
.unwrap()
else {
panic!("expected run");
};
assert_eq!(own.run(), "query");
}
#[test]
fn resolution_binds_the_handler_so_running_cannot_miss() {
fn query(_: &ResolvedInvocation) -> &'static str {
"query"
}
fn pipe(_: &ResolvedInvocation) -> &'static str {
"pipe"
}
let app = sample_spec()
.unwrap()
.bind_actions([
("query", query as fn(&ResolvedInvocation) -> &'static str),
("pipe", pipe as fn(&ResolvedInvocation) -> &'static str),
])
.unwrap();
let fixtures = sample_spec().unwrap().synthetic_invocations();
assert!(!fixtures.is_empty());
for fixture in fixtures {
let BoundOutcome::Run(invocation) = app.resolve_from(fixture.argv.clone()).unwrap() else {
continue;
};
assert!(invocation.output_plan().output_format().is_some());
let action = invocation.invocation().action_id().to_string();
assert_eq!(invocation.run(), action);
}
}
#[test]
#[should_panic(expected = "does not declare argument id `nonexistent`")]
fn call_every_combination_names_a_handler_reading_an_undeclared_id() {
fn reads_a_bad_id(invocation: &ResolvedInvocation) -> &'static str {
let _ = invocation.required("nonexistent");
"ok"
}
fn fine(_: &ResolvedInvocation) -> &'static str {
"ok"
}
let app = sample_spec()
.unwrap()
.bind_actions([
(
"query",
reads_a_bad_id as fn(&ResolvedInvocation) -> &'static str,
),
("pipe", fine as fn(&ResolvedInvocation) -> &'static str),
])
.unwrap();
app.call_every_combination();
}
#[test]
fn call_every_combination_passes_and_returns_each_handler_result() {
fn query(invocation: &ResolvedInvocation) -> &'static str {
let _ = invocation.required("dsn");
"query"
}
fn pipe(invocation: &ResolvedInvocation) -> &'static str {
let _ = invocation.required("dsn");
"pipe"
}
let app = sample_spec()
.unwrap()
.bind_actions([
("query", query as fn(&ResolvedInvocation) -> &'static str),
("pipe", pipe as fn(&ResolvedInvocation) -> &'static str),
])
.unwrap();
let results = app.call_every_combination();
assert!(!results.is_empty());
for (combination, produced) in &results {
assert!(
["query", "pipe"].contains(produced),
"combination `{combination}` produced {produced}"
);
}
}
#[test]
fn secret_values_never_enter_structured_cli_errors() {
let spec = sample_spec().unwrap();
let error = spec
.resolve_from([
"demo",
"query",
"--dsn",
"postgres://user:password@example.test/db",
"--sql",
"select 1",
"--unknown",
])
.unwrap_err();
let rendered = format!("{:?}|{}|{}", error.rule, error.message, error.hint);
assert!(!rendered.contains("password"));
assert_eq!(error.rule, CliErrorRule::UnknownArgument);
for argv in [
vec!["demo", "postgres://user:command-secret@example.test/db"],
vec!["demo", "query", "-short-secret"],
] {
let error = spec.resolve_from(argv).unwrap_err();
let rendered = format!("{:?}|{}|{}", error.rule, error.message, error.hint);
assert!(!rendered.contains("secret"), "{rendered}");
}
}
#[test]
fn separated_and_positional_negative_numbers_are_values() {
let mut positional = ArgSpec::positional("limit", 0, "LIMIT");
positional.value_type = ArgValueType::I64;
let spec = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option_i64("--offset", "OFFSET"))
.arg(positional)
.combination(
Combination::new("numbers")
.action("numbers")
.required(["offset", "limit"]),
),
)
.build()
.unwrap();
let CliOutcome::Run(invocation) = spec.resolve_from(["demo", "--offset", "-2", "-3"]).unwrap()
else {
panic!("expected run");
};
assert_eq!(invocation.required("offset").as_i64(), Some(-2));
assert_eq!(invocation.required("limit").as_i64(), Some(-3));
}
#[test]
fn malformed_specs_fail_before_resolution() {
let uncovered = CliSpec::new("demo", "1")
.command(CommandSpec::root().arg(ArgSpec::flag("--unused")))
.build();
assert_eq!(uncovered.unwrap_err().rule, "uncovered_argument");
let fixed_non_enum = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option("--mode", "MODE"))
.combination(Combination::new("bad").action("bad").fixed("mode", "cli")),
)
.build();
assert_eq!(fixed_non_enum.unwrap_err().rule, "invalid_fixed_argument");
let required_default = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option("--mode", "MODE").default("cli"))
.combination(Combination::new("bad").action("bad").required(["mode"])),
)
.build();
assert_eq!(
required_default.unwrap_err().rule,
"required_argument_default"
);
}
#[test]
fn json_arguments_keep_their_source_text() {
let spec = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option_json("--param", "JSON"))
.combination(
Combination::new("only")
.action("only")
.required(["param"])
.output(OutputSpec::protocol_finite(
["json"],
["split"],
"json",
"split",
)),
),
)
.build()
.unwrap();
let CliOutcome::Run(invocation) = spec
.resolve_from(["demo", "--param", r#"{"n":10000000000000000000000.5}"#])
.unwrap()
else {
panic!("expected a run outcome");
};
assert_eq!(
invocation.required("param").as_json_str(),
Some(r#"{"n":10000000000000000000000.5}"#)
);
for raw in ["[1,2]", "\"hi\"", "3", "null"] {
assert!(spec.resolve_from(["demo", "--param", raw]).is_ok(), "{raw}");
}
for raw in ["{", "1 2", "", "nope"] {
let error = spec.resolve_from(["demo", "--param", raw]).unwrap_err();
assert_eq!(error.rule, CliErrorRule::InvalidArgumentValue, "{raw}");
}
}
#[test]
fn a_lone_combination_needs_no_description_of_its_own() {
let spec = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.about("Run the only thing this command does")
.arg(ArgSpec::flag("--now"))
.combination(
Combination::new("only")
.action("run")
.optional(["now"])
.output(protocol()),
),
)
.build()
.unwrap();
let CliOutcome::Help(help) = spec.resolve_from(["demo", "run", "--help"]).unwrap() else {
panic!("expected help");
};
assert_eq!(
help.model().about.as_deref(),
Some("Run the only thing this command does")
);
let [shape] = help.model().shapes.as_slice() else {
panic!("expected one shape");
};
assert_eq!(shape.about, None, "a lone shape repeats nothing");
}
#[test]
fn sibling_combinations_must_say_how_they_differ() {
let error = CliSpec::new("demo", "1")
.command(CommandSpec::root())
.command(
CommandSpec::new(["run"])
.about("Run something")
.arg(ArgSpec::option_enum("--mode", ["one", "two"]))
.combination(
Combination::new("first")
.action("run")
.about("The first way")
.fixed("mode", "one"),
)
.combination(
Combination::new("second")
.action("run")
.fixed("mode", "two"),
),
)
.build()
.unwrap_err();
assert_eq!(error.rule, "undescribed_combination");
}
#[test]
fn docs_is_injected_and_costs_the_agent_nothing() {
let spec = sample_spec().unwrap();
let CliOutcome::Help(help) = spec.resolve_from(["demo", "--help"]).unwrap() else {
panic!("expected help");
};
assert!(!format!("{:?}", help.model()).contains("--docs"));
let CliOutcome::Docs(docs) = spec.resolve_from(["demo", "--docs"]).unwrap() else {
panic!("expected docs");
};
assert!(matches!(docs.output_plan(), OutputPlan::Raw { .. }));
for argv in [
vec!["demo", "query", "--docs"],
vec!["demo", "--docs", "--version"],
vec!["demo", "--docs", "--output", "json"],
] {
assert_eq!(
spec.resolve_from(argv.clone()).unwrap_err().rule,
CliErrorRule::UnregisteredCombination,
"{argv:?}"
);
}
}
fn declaring(path: &[&str], long: &str) -> Result<BuiltCliSpec, CliSpecError> {
let argument = ArgSpec::option(long, "VALUE");
let command = CommandSpec::new(path.to_vec())
.arg(argument.clone())
.combination(
Combination::new("only")
.action("only")
.required([argument.argument_id])
.output(protocol()),
);
let spec = CliSpec::new("demo", "1.0.0").lifecycle_output(protocol());
if path.is_empty() {
spec.command(command).build()
} else {
spec.command(CommandSpec::root()).command(command).build()
}
}
#[test]
fn a_subcommand_may_declare_a_name_the_root_reserves() {
let spec = declaring(&["release"], "--version").unwrap();
let CliOutcome::Run(invocation) = spec
.resolve_from(["demo", "release", "--version", "1.2.0"])
.unwrap()
else {
panic!("expected a run outcome");
};
assert_eq!(invocation.required("version").as_str(), Some("1.2.0"));
let CliOutcome::Version(version) = spec.resolve_from(["demo", "--version"]).unwrap() else {
panic!("expected version");
};
assert_eq!(version.version(), "1.0.0");
}
#[test]
fn reservation_reaches_exactly_as_far_as_injection() {
for long in [
"--help",
"--output",
"--output-to",
"--stdout-file",
"--stderr-file",
] {
for path in [&[][..], &["release"][..]] {
assert_eq!(
declaring(path, long).unwrap_err().rule,
"reserved_long_argument",
"{long} on {path:?}"
);
}
}
for long in ["--version", "--docs"] {
assert_eq!(
declaring(&[], long).unwrap_err().rule,
"reserved_long_argument",
"{long}"
);
let spec = declaring(&["release"], long).unwrap();
assert_eq!(
spec.resolve_from(["demo", "release", long])
.unwrap_err()
.rule,
CliErrorRule::MissingArgumentValue,
"{long}"
);
}
let spec = sample_spec().unwrap();
for long in ["--version", "--docs"] {
assert_eq!(
spec.resolve_from(["demo", "query", long]).unwrap_err().rule,
CliErrorRule::UnregisteredCombination,
"{long}"
);
}
}
#[test]
fn an_empty_registry_is_rejected() {
let error = CliSpec::new("demo", "1").build().unwrap_err();
assert_eq!(error.rule, "missing_commands");
}
#[test]
fn a_cli_cannot_redeclare_or_repeat_an_exit_code() {
let build = |codes: &[(u8, &str)]| {
let mut spec = CliSpec::new("demo", "1.0.0").lifecycle_output(protocol());
for (code, meaning) in codes {
spec = spec.exit_code(*code, *meaning);
}
spec.command(CommandSpec::root()).build()
};
for reserved in [0u8, 1, 2] {
assert_eq!(
build(&[(reserved, "mine now")]).unwrap_err().rule,
"reserved_exit_code",
"{reserved}"
);
}
assert_eq!(
build(&[(3, "partial"), (3, "also partial")])
.unwrap_err()
.rule,
"duplicate_exit_code"
);
assert_eq!(
build(&[(3, " ")]).unwrap_err().rule,
"empty_exit_code_meaning"
);
}
#[test]
fn a_shared_argument_is_accepted_at_every_command() {
fn handler(invocation: &ResolvedInvocation) -> String {
invocation
.optional("config")
.and_then(CliValue::as_str)
.unwrap_or("default")
.to_string()
}
let spec = CliSpec::new("demo", "1")
.shared_arg(ArgSpec::option("--config", "PATH").default("default"))
.command(
CommandSpec::root().combination(Combination::new("root").action("root").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.command(
CommandSpec::new(["sub"]).combination(Combination::new("sub").action("sub").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.build()
.unwrap();
let app = spec
.bind_actions([
("root", handler as fn(&ResolvedInvocation) -> String),
("sub", handler),
])
.unwrap();
for argv in [
vec!["demo", "--config", "here"],
vec!["demo", "sub", "--config", "here"],
] {
let BoundOutcome::Run(invocation) = app.resolve_from(argv.clone()).unwrap() else {
panic!("expected a run for {argv:?}");
};
assert_eq!(invocation.run(), "here", "{argv:?}");
}
let BoundOutcome::Run(invocation) = app.resolve_from(["demo", "sub"]).unwrap() else {
panic!("expected a run");
};
assert_eq!(invocation.run(), "default");
}
#[test]
fn redeclaring_a_shared_argument_fails_the_build() {
let error = CliSpec::new("demo", "1")
.shared_arg(ArgSpec::option("--config", "PATH"))
.command(
CommandSpec::root()
.arg(ArgSpec::option("--config", "PATH"))
.combination(
Combination::new("root")
.action("root")
.optional(["config"])
.output(OutputSpec::protocol_finite(
["json"],
["split"],
"json",
"split",
)),
),
)
.build()
.unwrap_err();
assert_eq!(error.rule, "shared_argument_redeclared");
}
#[test]
fn a_shared_argument_cannot_take_a_reserved_name() {
let error = CliSpec::new("demo", "1")
.shared_arg(ArgSpec::option("--output", "FORMAT"))
.command(
CommandSpec::root().combination(Combination::new("root").action("root").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.build()
.unwrap_err();
assert_eq!(error.rule, "reserved_long_argument");
}
#[test]
fn a_uuid_argument_is_validated_at_parse_time() {
let spec = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option("--analysis-id", "UUID").uuid())
.combination(
Combination::new("root")
.action("root")
.required(["analysis_id"])
.output(OutputSpec::protocol_finite(
["json"],
["split"],
"json",
"split",
)),
),
)
.build()
.unwrap();
let ok = spec
.resolve_from([
"demo",
"--analysis-id",
"e8414c67-dc1c-4da3-a6f7-69541b0841c7",
])
.unwrap();
let CliOutcome::Run(invocation) = ok else {
panic!("expected a run");
};
assert_eq!(
invocation.required("analysis_id").as_str(),
Some("e8414c67-dc1c-4da3-a6f7-69541b0841c7")
);
for bad in ["not-a-uuid", "e8414c67dc1c4da3a6f769541b0841c7", ""] {
let error = spec
.resolve_from(["demo", "--analysis-id", bad])
.unwrap_err();
assert_eq!(error.rule, CliErrorRule::InvalidArgumentValue, "{bad:?}");
assert_eq!(error.exit_code(), 2);
}
}
#[test]
fn an_integer_range_is_enforced_at_parse_time() {
let spec = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option("--limit", "N").range(1, 1000))
.combination(
Combination::new("root")
.action("root")
.required(["limit"])
.output(OutputSpec::protocol_finite(
["json"],
["split"],
"json",
"split",
)),
),
)
.build()
.unwrap();
for good in ["1", "1000", "500"] {
let CliOutcome::Run(invocation) = spec.resolve_from(["demo", "--limit", good]).unwrap()
else {
panic!("expected a run for {good}");
};
assert_eq!(
invocation.required("limit").as_i64(),
Some(good.parse().unwrap())
);
}
for bad in ["0", "1001", "-1", "99999999999"] {
let error = spec.resolve_from(["demo", "--limit", bad]).unwrap_err();
assert_eq!(error.rule, CliErrorRule::InvalidArgumentValue, "{bad:?}");
assert_eq!(error.exit_code(), 2);
}
}
#[test]
fn a_shared_argument_does_not_move_the_command_path() {
fn handler(_: &ResolvedInvocation) -> &'static str {
"ok"
}
let spec = CliSpec::new("demo", "1")
.shared_arg(ArgSpec::option("--config", "PATH").default("default"))
.command(
CommandSpec::root().combination(Combination::new("root").action("root").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.command(
CommandSpec::new(["sub"]).combination(Combination::new("sub").action("sub").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.build()
.unwrap();
let app = spec
.bind_actions([
("root", handler as fn(&ResolvedInvocation) -> &'static str),
("sub", handler),
])
.unwrap();
assert!(
app.resolve_from(["demo", "sub", "--config", "here"])
.is_ok_and(|outcome| matches!(outcome, BoundOutcome::Run(_)))
);
let error = app
.resolve_from(["demo", "--config", "here", "sub"])
.unwrap_err();
assert_eq!(error.rule, CliErrorRule::UnexpectedPositional);
}
#[test]
fn a_shared_argument_skips_a_help_only_command() {
let spec = CliSpec::new("demo", "1")
.shared_arg(ArgSpec::option("--config", "PATH").default("default"))
.command(
CommandSpec::root().combination(Combination::new("root").action("root").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.command(CommandSpec::new(["group"]))
.command(
CommandSpec::new(["group", "leaf"]).combination(
Combination::new("leaf")
.action("leaf")
.output(OutputSpec::protocol_finite(
["json"],
["split"],
"json",
"split",
)),
),
)
.build()
.unwrap();
assert!(matches!(
spec.resolve_from(["demo", "group", "leaf", "--config", "here"]),
Ok(CliOutcome::Run(_))
));
}
#[test]
fn a_default_outside_the_range_fails_the_build() {
let error = CliSpec::new("demo", "1")
.command(
CommandSpec::root()
.arg(ArgSpec::option("--limit", "N").range(1, 100).default_i64(0))
.combination(
Combination::new("root")
.action("root")
.optional(["limit"])
.output(OutputSpec::protocol_finite(
["json"],
["split"],
"json",
"split",
)),
),
)
.build()
.unwrap_err();
assert!(
error.message.contains("0..=100") || error.message.contains("1..=100"),
"{}",
error.message
);
}
#[test]
fn a_misordered_command_name_says_what_is_wrong() {
let spec = CliSpec::new("tool", "1")
.shared_arg(ArgSpec::option("--config", "PATH").default("cfg"))
.command(
CommandSpec::root().combination(Combination::new("root").action("root").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.command(
CommandSpec::new(["sub"]).combination(Combination::new("sub").action("sub").output(
OutputSpec::protocol_finite(["json"], ["split"], "json", "split"),
)),
)
.build()
.unwrap();
let error = spec
.resolve_from(["tool", "--config", "x", "sub"])
.unwrap_err();
assert_eq!(error.rule, CliErrorRule::UnexpectedPositional);
assert_eq!(error.message, "command name must come before its arguments");
let error = spec
.resolve_from(["tool", "--config", "x", "nonsense"])
.unwrap_err();
assert_eq!(error.message, "unexpected positional argument");
}