use clap::builder::ValueHint;
use clap::parser::ValueSource;
use clap::{Arg, ArgAction, Command};
use serde::Serialize;
use crate::output::{OutputFormat, OutputSpec};
pub const SCHEMA_VERSION_HELP: u32 = 3;
#[derive(Debug, Serialize)]
pub struct HelpData {
pub schema_version_help: u32,
#[serde(flatten)]
pub command: CommandNode,
}
#[derive(Debug, Serialize)]
pub struct CommandNode {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub long_about: Option<String>,
pub aliases: Vec<String>,
pub hidden: bool,
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecation_note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub flags: Vec<FlagInfo>,
pub positionals: Vec<PositionalInfo>,
pub subcommands: Vec<SubcommandEntry>,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum SubcommandEntry {
Full(CommandNode),
Summary(SubcommandSummary),
}
#[derive(Debug, Serialize)]
pub struct SubcommandSummary {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
pub aliases: Vec<String>,
pub hidden: bool,
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecation_note: Option<String>,
pub has_subcommands: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HelpDepth {
Bounded(usize),
Tree,
}
impl Default for HelpDepth {
fn default() -> Self {
Self::Bounded(1)
}
}
pub fn parse_help_depth(s: &str) -> Result<HelpDepth, String> {
match s {
"tree" | "full" => Ok(HelpDepth::Tree),
_ => match s.parse::<usize>() {
Ok(n) if n >= 1 => Ok(HelpDepth::Bounded(n)),
Ok(_) => Err(format!(
"--depth expects a positive integer (>=1) or 'tree'/'full'; got '{s}'"
)),
Err(_) => Err(format!(
"--depth expects a positive integer or 'tree'/'full'; got '{s}'"
)),
},
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Serialize)]
pub struct FlagInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub long: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub short: Option<String>,
pub long_aliases: Vec<String>,
pub short_aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
pub value_names: Vec<String>,
pub takes_value: bool,
pub multiple: bool,
pub required: bool,
pub is_global: bool,
pub hidden: bool,
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecation_note: Option<String>,
pub defaults: Vec<String>,
pub accepted_values: Vec<String>,
pub accepts_file_paths: bool,
pub conflicts_with: Vec<String>,
pub requires: Vec<String>,
pub required_unless_present: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub help_heading: Option<String>,
pub arity: Arity,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Arity {
pub min: usize,
pub max: Option<usize>,
pub repeated: bool,
pub multi_value: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value_delimiter: Option<String>,
pub require_equals: bool,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Serialize)]
pub struct PositionalInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub help: Option<String>,
pub value_names: Vec<String>,
pub index: usize,
pub required: bool,
pub multiple: bool,
pub accepted_values: Vec<String>,
pub accepts_file_paths: bool,
pub defaults: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env: Option<String>,
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecation_note: Option<String>,
}
const HELP_FLAG_ID: &str = "__octl_help_request";
pub(crate) const OUTPUT_ARG_ID: &str = "output";
fn is_output_arg(arg: &Arg) -> bool {
arg.get_id().as_str() == OUTPUT_ARG_ID
&& arg.get_long() == Some(OUTPUT_ARG_ID)
&& arg.is_global_set()
}
#[derive(Debug)]
pub enum HelpRequest {
None,
Render {
spec: OutputSpec,
path: Vec<String>,
depth: HelpDepth,
},
UnknownSubcommand { token: String },
InvalidDepth {
value: String,
},
}
const DEPTH_ARG_ID: &str = "__octl_help_depth";
#[must_use]
pub fn resolve_help_request(root: &Command, args: &[String]) -> HelpRequest {
let mut lenient = root
.clone()
.ignore_errors(true)
.disable_help_flag(true)
.allow_external_subcommands(true)
.arg(
Arg::new(HELP_FLAG_ID)
.long("help")
.short('h')
.action(ArgAction::SetTrue)
.global(true),
)
.arg(
Arg::new(DEPTH_ARG_ID)
.long("depth")
.num_args(1)
.action(ArgAction::Set)
.global(true),
);
enable_external_subcommands_recursively(&mut lenient);
let with_prog = std::iter::once(root.get_name().to_string()).chain(args.iter().cloned());
let Ok(matches) = lenient.try_get_matches_from_mut(with_prog) else {
return HelpRequest::None;
};
if !matches.get_flag(HELP_FLAG_ID) {
return HelpRequest::None;
}
let spec = match matches.value_source(OUTPUT_ARG_ID) {
Some(ValueSource::CommandLine) => matches.get_one::<OutputSpec>(OUTPUT_ARG_ID).cloned(),
_ => None,
};
let Some(spec) = spec else {
return HelpRequest::None;
};
if spec.format == OutputFormat::Text {
return HelpRequest::None;
}
let mut cur = root;
let mut path = Vec::new();
let mut node = &matches;
while let Some((name, sub)) = node.subcommand() {
match cur.find_subcommand(name) {
Some(child) => {
cur = child;
path.push(child.get_name().to_string());
node = sub;
}
None => {
return HelpRequest::UnknownSubcommand {
token: name.to_string(),
}
}
}
}
let depth = match matches.value_source(DEPTH_ARG_ID) {
Some(ValueSource::CommandLine) => {
let raw = matches
.get_one::<String>(DEPTH_ARG_ID)
.cloned()
.unwrap_or_default();
match parse_help_depth(&raw) {
Ok(d) => d,
Err(_) => return HelpRequest::InvalidDepth { value: raw },
}
}
_ => HelpDepth::default(),
};
HelpRequest::Render { spec, path, depth }
}
fn enable_external_subcommands_recursively(cmd: &mut Command) {
for sub in cmd.get_subcommands_mut() {
let owned = std::mem::replace(sub, Command::new("")).allow_external_subcommands(true);
*sub = owned;
enable_external_subcommands_recursively(sub);
}
}
#[must_use]
pub fn navigate_path<'a>(root: &'a Command, names: &[String]) -> (&'a Command, String) {
let mut cur = root;
let mut path = vec![root.get_name().to_string()];
for name in names {
let Some(sc) = cur.find_subcommand(name) else {
break;
};
cur = sc;
path.push(sc.get_name().to_string());
}
(cur, path.join(" "))
}
#[must_use]
pub fn build_help(cmd: &Command, command_path: &str, depth: HelpDepth) -> HelpData {
HelpData {
schema_version_help: SCHEMA_VERSION_HELP,
command: build_node(cmd, command_path, depth),
}
}
fn build_node(cmd: &Command, command_path: &str, depth: HelpDepth) -> CommandNode {
let mut flags: Vec<FlagInfo> = cmd
.get_arguments()
.filter(|a| !a.is_positional())
.map(|a| build_flag(cmd, a))
.collect();
flags.sort_by(|a, b| a.name.cmp(&b.name));
let mut positionals: Vec<PositionalInfo> =
cmd.get_positionals().map(build_positional).collect();
positionals.sort_by_key(|p| p.index);
let mut subcommands: Vec<SubcommandEntry> = cmd
.get_subcommands()
.map(|sc| {
let child_path = format!("{command_path} {}", sc.get_name());
match depth {
HelpDepth::Tree => {
SubcommandEntry::Full(build_node(sc, &child_path, HelpDepth::Tree))
}
HelpDepth::Bounded(n) if n >= 2 => {
SubcommandEntry::Full(build_node(sc, &child_path, HelpDepth::Bounded(n - 1)))
}
HelpDepth::Bounded(_) => SubcommandEntry::Summary(build_summary(sc, &child_path)),
}
})
.collect();
subcommands.sort_by(|a, b| subcommand_entry_path(a).cmp(subcommand_entry_path(b)));
let about_dep = parse_deprecation(cmd.get_about().map(ToString::to_string));
let long_dep = parse_deprecation(cmd.get_long_about().map(ToString::to_string));
let about = about_dep.text;
let long_about = long_dep.text.filter(|l| Some(l) != about.as_ref());
CommandNode {
command: command_path.to_string(),
about,
long_about,
aliases: cmd.get_visible_aliases().map(ToString::to_string).collect(),
hidden: cmd.is_hide_set(),
deprecated: about_dep.deprecated || long_dep.deprecated,
deprecation_note: about_dep.note.or(long_dep.note),
version: cmd.get_version().map(ToString::to_string),
flags,
positionals,
subcommands,
}
}
fn build_summary(cmd: &Command, command_path: &str) -> SubcommandSummary {
let about_dep = parse_deprecation(cmd.get_about().map(ToString::to_string));
let long_dep = parse_deprecation(cmd.get_long_about().map(ToString::to_string));
SubcommandSummary {
command: command_path.to_string(),
about: about_dep.text,
aliases: cmd.get_visible_aliases().map(ToString::to_string).collect(),
hidden: cmd.is_hide_set(),
deprecated: about_dep.deprecated || long_dep.deprecated,
deprecation_note: about_dep.note.or(long_dep.note),
has_subcommands: cmd.get_subcommands().next().is_some(),
}
}
fn subcommand_entry_path(entry: &SubcommandEntry) -> &str {
match entry {
SubcommandEntry::Full(n) => n.command.as_str(),
SubcommandEntry::Summary(s) => s.command.as_str(),
}
}
fn build_flag(cmd: &Command, arg: &Arg) -> FlagInfo {
let action = arg.get_action();
let takes_value = takes_value(action);
let dep = parse_deprecation(arg.get_help().map(ToString::to_string));
let (requires, required_unless_present) = requirement_edges(arg);
FlagInfo {
name: arg.get_id().as_str().to_string(),
long: arg.get_long().map(ToString::to_string),
short: arg.get_short().map(|c| c.to_string()),
long_aliases: long_aliases(arg),
short_aliases: short_aliases(arg),
help: dep.text,
value_names: if takes_value {
value_names(arg)
} else {
Vec::new()
},
takes_value,
multiple: multiple(arg, action),
required: arg.is_required_set(),
is_global: arg.is_global_set(),
hidden: arg.is_hide_set(),
deprecated: dep.deprecated,
deprecation_note: dep.note,
defaults: default_values(arg),
accepted_values: accepted_values(arg),
accepts_file_paths: accepts_file_paths(arg),
conflicts_with: conflicts_with(cmd, arg),
requires,
required_unless_present,
help_heading: arg.get_help_heading().map(ToString::to_string),
arity: arity(arg, action),
env: arg.get_env().map(|e| e.to_string_lossy().into_owned()),
}
}
fn build_positional(arg: &Arg) -> PositionalInfo {
let dep = parse_deprecation(arg.get_help().map(ToString::to_string));
PositionalInfo {
name: arg.get_id().as_str().to_string(),
help: dep.text,
value_names: value_names(arg),
index: arg
.get_index()
.expect("positional has an index after Command::build()"),
required: arg.is_required_set(),
multiple: multiple(arg, arg.get_action()),
accepted_values: accepted_values(arg),
accepts_file_paths: accepts_file_paths(arg),
defaults: default_values(arg),
env: arg.get_env().map(|e| e.to_string_lossy().into_owned()),
deprecated: dep.deprecated,
deprecation_note: dep.note,
}
}
fn value_names(arg: &Arg) -> Vec<String> {
arg.get_value_names()
.unwrap_or_default()
.iter()
.map(ToString::to_string)
.collect()
}
fn default_values(arg: &Arg) -> Vec<String> {
arg.get_default_values()
.iter()
.map(|v| v.to_string_lossy().into_owned())
.collect()
}
fn accepted_values(arg: &Arg) -> Vec<String> {
let from_parser: Vec<String> = arg
.get_possible_values()
.into_iter()
.filter(|p| !p.is_hide_set())
.map(|p| p.get_name().to_string())
.collect();
if from_parser.is_empty() {
if let Some(custom) = custom_accepted_values(arg) {
return custom;
}
}
from_parser
}
fn custom_accepted_values(arg: &Arg) -> Option<Vec<String>> {
is_output_arg(arg).then(|| vec!["json".to_string(), "jsonl".to_string(), "text".to_string()])
}
fn long_aliases(arg: &Arg) -> Vec<String> {
let mut v: Vec<String> = arg
.get_all_aliases()
.unwrap_or_default()
.into_iter()
.map(ToString::to_string)
.collect();
v.sort();
v
}
fn short_aliases(arg: &Arg) -> Vec<String> {
let mut v: Vec<String> = arg
.get_all_short_aliases()
.unwrap_or_default()
.into_iter()
.map(|c| c.to_string())
.collect();
v.sort();
v
}
fn conflicts_with(cmd: &Command, arg: &Arg) -> Vec<String> {
if arg.is_global_set() {
return Vec::new();
}
let mut v: Vec<String> = cmd
.get_arg_conflicts_with(arg)
.into_iter()
.map(|a| a.get_id().as_str().to_string())
.collect();
v.sort();
v.dedup();
v
}
fn requirement_edges(arg: &Arg) -> (Vec<String>, Vec<String>) {
let debug = format!("{arg:?}");
let requires = debug_field_list(&debug, "requires").map_or_else(Vec::new, |seg| {
sorted_dedup(
seg.match_indices("(IsPresent, \"")
.filter_map(|(i, m)| {
let rest = &seg[i + m.len()..];
rest.find('"').map(|end| rest[..end].to_string())
})
.collect(),
)
});
let required_unless_present = debug_field_list(&debug, "r_unless")
.map_or_else(Vec::new, |seg| sorted_dedup(quoted_tokens(seg)));
(requires, required_unless_present)
}
fn debug_field_list<'a>(debug: &'a str, field: &str) -> Option<&'a str> {
let needle = format!("{field}: [");
let bytes = debug.as_bytes();
let mut i = 0;
let mut in_quote = false;
let mut escaped = false;
while i < bytes.len() {
if in_quote {
match bytes[i] {
_ if escaped => escaped = false,
b'\\' => escaped = true,
b'"' => in_quote = false,
_ => {}
}
i += 1;
} else if bytes[i] == b'"' {
in_quote = true;
i += 1;
} else if debug[i..].starts_with(&needle) {
return bracket_payload(debug, i + needle.len());
} else {
i += 1;
}
}
None
}
fn bracket_payload(debug: &str, start: usize) -> Option<&str> {
let mut depth = 1usize;
for (i, c) in debug[start..].char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(&debug[start..start + i]);
}
}
_ => {}
}
}
None
}
fn quoted_tokens(seg: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = seg;
while let Some(open) = rest.find('"') {
let after = &rest[open + 1..];
let Some(close) = after.find('"') else { break };
out.push(after[..close].to_string());
rest = &after[close + 1..];
}
out
}
fn sorted_dedup(mut v: Vec<String>) -> Vec<String> {
v.sort();
v.dedup();
v
}
fn accepts_file_paths(arg: &Arg) -> bool {
if is_output_arg(arg) {
return true;
}
matches!(
arg.get_value_hint(),
ValueHint::AnyPath | ValueHint::FilePath | ValueHint::DirPath | ValueHint::ExecutablePath
)
}
fn arity(arg: &Arg, action: &ArgAction) -> Arity {
let range = arg.get_num_args();
let raw_max = range.map_or(0, |r| r.max_values());
let max = (raw_max != usize::MAX).then_some(raw_max);
Arity {
min: range.map_or(0, |r| r.min_values()),
max,
repeated: matches!(action, ArgAction::Append | ArgAction::Count),
multi_value: max.is_none_or(|m| m > 1),
value_delimiter: arg.get_value_delimiter().map(|c| c.to_string()),
require_equals: arg.is_require_equals_set(),
}
}
struct Deprecation {
deprecated: bool,
note: Option<String>,
text: Option<String>,
}
fn parse_deprecation(text: Option<String>) -> Deprecation {
let Some(text) = text else {
return Deprecation {
deprecated: false,
note: None,
text: None,
};
};
let trimmed = text.trim_start();
if let Some(rest) = trimmed.strip_prefix("[deprecated:") {
let (note, after) = match rest.find(']') {
Some(end) => (rest[..end].trim(), rest[end + 1..].trim_start()),
None => (rest.trim(), ""),
};
return Deprecation {
deprecated: true,
note: non_empty(note),
text: non_empty(after),
};
}
if let Some(rest) = trimmed.strip_prefix("[deprecated]") {
return Deprecation {
deprecated: true,
note: None,
text: non_empty(rest.trim_start()),
};
}
Deprecation {
deprecated: false,
note: None,
text: Some(text),
}
}
fn non_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn takes_value(action: &ArgAction) -> bool {
matches!(action, ArgAction::Set | ArgAction::Append)
}
fn multiple(arg: &Arg, action: &ArgAction) -> bool {
matches!(action, ArgAction::Append | ArgAction::Count)
|| arg.get_num_args().is_some_and(|r| r.max_values() > 1)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_root() -> Command {
Command::new("tool")
.arg(
Arg::new(OUTPUT_ARG_ID)
.long("output")
.global(true)
.default_value("jsonl")
.value_parser(crate::output::parse_output_value),
)
.subcommand(Command::new("run").subcommand(Command::new("create")))
}
fn args(parts: &[&str]) -> Vec<String> {
parts.iter().map(ToString::to_string).collect()
}
#[test]
fn render_resolves_leaf_path_and_output() {
let req = resolve_help_request(
&test_root(),
&args(&["run", "create", "--help", "--output", "json"]),
);
match req {
HelpRequest::Render { spec, path, depth } => {
assert_eq!(spec.format, OutputFormat::Json);
assert_eq!(path, vec!["run".to_string(), "create".to_string()]);
assert_eq!(depth, HelpDepth::default());
}
_ => panic!("expected Render"),
}
}
#[test]
fn output_can_precede_the_subcommand_path() {
let req =
resolve_help_request(&test_root(), &args(&["--output", "jsonl", "run", "--help"]));
match req {
HelpRequest::Render { spec, path, depth } => {
assert_eq!(spec.format, OutputFormat::Jsonl);
assert_eq!(path, vec!["run".to_string()]);
assert_eq!(depth, HelpDepth::default());
}
_ => panic!("expected Render"),
}
}
#[test]
fn bare_help_without_output_is_none() {
assert!(matches!(
resolve_help_request(&test_root(), &args(&["run", "create", "--help"])),
HelpRequest::None
));
}
#[test]
fn output_text_with_help_is_none() {
assert!(matches!(
resolve_help_request(&test_root(), &args(&["run", "--help", "--output", "text"])),
HelpRequest::None
));
}
#[test]
fn no_help_flag_is_none() {
assert!(matches!(
resolve_help_request(&test_root(), &args(&["run", "--output", "json"])),
HelpRequest::None
));
}
#[test]
fn double_dash_suppresses_detection() {
assert!(matches!(
resolve_help_request(
&test_root(),
&args(&["run", "--", "--help", "--output", "json"])
),
HelpRequest::None
));
}
#[test]
fn unknown_subcommand_after_flags_is_flagged() {
match resolve_help_request(
&test_root(),
&args(&["--help", "--output", "json", "bogus"]),
) {
HelpRequest::UnknownSubcommand { token } => assert_eq!(token, "bogus"),
other => panic!("expected UnknownSubcommand, got {other:?}"),
}
}
#[test]
fn deprecation_prefix_is_parsed_and_stripped() {
let d = parse_deprecation(Some("[deprecated] Old flag.".to_string()));
assert!(d.deprecated);
assert_eq!(d.note, None);
assert_eq!(d.text.as_deref(), Some("Old flag."));
let d = parse_deprecation(Some("[deprecated: use --kind] Spawn.".to_string()));
assert!(d.deprecated);
assert_eq!(d.note.as_deref(), Some("use --kind"));
assert_eq!(d.text.as_deref(), Some("Spawn."));
let d = parse_deprecation(Some("[deprecated]".to_string()));
assert!(d.deprecated);
assert_eq!(d.text, None);
let d = parse_deprecation(Some("Create a [deprecated] run.".to_string()));
assert!(!d.deprecated);
assert_eq!(d.text.as_deref(), Some("Create a [deprecated] run."));
}
#[test]
fn deprecation_surfaces_on_a_synthetic_flag() {
let mut cmd = Command::new("tool").arg(
Arg::new("legacy")
.long("legacy")
.action(ArgAction::SetTrue)
.help("[deprecated: use --modern] Old toggle."),
);
cmd.build();
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == "legacy")
.unwrap();
let flag = build_flag(&cmd, arg);
assert!(flag.deprecated);
assert_eq!(flag.deprecation_note.as_deref(), Some("use --modern"));
assert_eq!(flag.help.as_deref(), Some("Old toggle."));
}
#[test]
fn short_only_flag_is_included_with_id_fallback() {
let mut cmd = Command::new("tool").arg(
Arg::new("verbose")
.short('v')
.action(ArgAction::Count)
.help("Increase verbosity."),
);
cmd.build();
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == "verbose")
.unwrap();
let flag = build_flag(&cmd, arg);
assert_eq!(flag.name, "verbose");
assert_eq!(flag.long, None);
assert_eq!(flag.short.as_deref(), Some("v"));
assert!(flag.arity.repeated);
assert!(!flag.takes_value);
}
fn expect_full<'a>(subs: &'a [SubcommandEntry], path: &str) -> &'a CommandNode {
subs.iter()
.find_map(|e| match e {
SubcommandEntry::Full(n) if n.command == path => Some(n),
_ => None,
})
.unwrap_or_else(|| panic!("expected full subcommand {path:?} in {subs:?}"))
}
fn expect_summary<'a>(subs: &'a [SubcommandEntry], path: &str) -> &'a SubcommandSummary {
subs.iter()
.find_map(|e| match e {
SubcommandEntry::Summary(s) if s.command == path => Some(s),
_ => None,
})
.unwrap_or_else(|| panic!("expected summary subcommand {path:?} in {subs:?}"))
}
#[test]
fn deprecation_surfaces_on_a_synthetic_subcommand() {
let mut cmd =
Command::new("tool").subcommand(Command::new("old").about("[deprecated] Legacy verb."));
cmd.build();
let node = build_node(&cmd, "tool", HelpDepth::Tree);
let old = expect_full(&node.subcommands, "tool old");
assert!(old.deprecated);
assert_eq!(old.about.as_deref(), Some("Legacy verb."));
}
#[test]
fn command_deprecation_can_come_from_long_about() {
let mut cmd = Command::new("tool").subcommand(
Command::new("old")
.about("Legacy verb.")
.long_about("[deprecated: gone in 1.0] Legacy verb, more detail."),
);
cmd.build();
let node = build_node(&cmd, "tool", HelpDepth::Tree);
let old = expect_full(&node.subcommands, "tool old");
assert!(old.deprecated);
assert_eq!(old.deprecation_note.as_deref(), Some("gone in 1.0"));
assert_eq!(old.long_about.as_deref(), Some("Legacy verb, more detail."));
}
#[test]
fn default_depth_summarizes_immediate_children() {
let mut cmd = Command::new("tool").subcommand(
Command::new("noun")
.about("A noun.")
.subcommand(Command::new("verb").about("A verb.")),
);
cmd.build();
let node = build_node(&cmd, "tool", HelpDepth::Bounded(1));
let noun = expect_summary(&node.subcommands, "tool noun");
assert_eq!(noun.about.as_deref(), Some("A noun."));
assert!(noun.has_subcommands, "noun has a verb under it");
}
#[test]
fn depth_two_expands_one_more_level() {
let mut cmd = Command::new("tool").subcommand(
Command::new("noun")
.about("A noun.")
.subcommand(Command::new("verb").about("A verb.")),
);
cmd.build();
let node = build_node(&cmd, "tool", HelpDepth::Bounded(2));
let noun = expect_full(&node.subcommands, "tool noun");
let _ = expect_summary(&noun.subcommands, "tool noun verb");
}
#[test]
fn tree_depth_recurses_unbounded() {
let mut cmd = Command::new("tool").subcommand(
Command::new("noun")
.about("A noun.")
.subcommand(Command::new("verb").about("A verb.")),
);
cmd.build();
let node = build_node(&cmd, "tool", HelpDepth::Tree);
let noun = expect_full(&node.subcommands, "tool noun");
let _ = expect_full(&noun.subcommands, "tool noun verb");
}
#[test]
fn parse_help_depth_accepts_positive_int_and_tree_aliases() {
assert_eq!(parse_help_depth("1").unwrap(), HelpDepth::Bounded(1));
assert_eq!(parse_help_depth("7").unwrap(), HelpDepth::Bounded(7));
assert_eq!(parse_help_depth("tree").unwrap(), HelpDepth::Tree);
assert_eq!(parse_help_depth("full").unwrap(), HelpDepth::Tree);
assert!(parse_help_depth("0").is_err()); assert!(parse_help_depth("-1").is_err());
assert!(parse_help_depth("abc").is_err());
assert!(parse_help_depth("").is_err());
}
#[test]
fn resolve_help_request_carries_depth_from_flag() {
let req = resolve_help_request(
&test_root(),
&args(&["--help", "--output", "json", "--depth", "2"]),
);
match req {
HelpRequest::Render { depth, .. } => assert_eq!(depth, HelpDepth::Bounded(2)),
_ => panic!("expected Render"),
}
}
#[test]
fn resolve_help_request_recognises_tree() {
let req = resolve_help_request(
&test_root(),
&args(&["--help", "--output", "json", "--depth", "tree"]),
);
match req {
HelpRequest::Render { depth, .. } => assert_eq!(depth, HelpDepth::Tree),
_ => panic!("expected Render"),
}
}
#[test]
fn resolve_help_request_rejects_bad_depth_value() {
let req = resolve_help_request(
&test_root(),
&args(&["--help", "--output", "json", "--depth", "garbage"]),
);
match req {
HelpRequest::InvalidDepth { value } => assert_eq!(value, "garbage"),
other => panic!("expected InvalidDepth, got {other:?}"),
}
}
#[test]
fn malformed_deprecation_marker_is_not_leaked() {
let d = parse_deprecation(Some("[deprecated: use --modern".to_string()));
assert!(d.deprecated);
assert_eq!(d.note.as_deref(), Some("use --modern"));
assert_eq!(d.text, None);
}
#[test]
fn nested_unknown_subcommand_after_flags_is_flagged() {
match resolve_help_request(
&test_root(),
&args(&["--output", "json", "--help", "run", "bogus"]),
) {
HelpRequest::UnknownSubcommand { token } => assert_eq!(token, "bogus"),
other => panic!("expected UnknownSubcommand, got {other:?}"),
}
}
#[test]
fn requires_edge_is_recovered_from_a_synthetic_arg() {
let mut cmd = Command::new("tool")
.arg(Arg::new("a").long("a").requires("b"))
.arg(Arg::new("b").long("b"));
cmd.build();
let flag = |id: &str| {
let arg = cmd.get_arguments().find(|a| a.get_id() == id).unwrap();
build_flag(&cmd, arg)
};
assert_eq!(flag("a").requires, vec!["b".to_string()]);
assert!(flag("b").requires.is_empty());
}
#[test]
fn conditional_requires_if_is_excluded() {
let mut cmd = Command::new("tool")
.arg(Arg::new("a").long("a").requires_if("x", "b").requires("c"))
.arg(Arg::new("b").long("b"))
.arg(Arg::new("c").long("c"));
cmd.build();
let arg = cmd.get_arguments().find(|a| a.get_id() == "a").unwrap();
let flag = build_flag(&cmd, arg);
assert_eq!(flag.requires, vec!["c".to_string()]);
}
#[test]
fn required_unless_present_is_recovered_from_a_synthetic_arg() {
let mut cmd = Command::new("tool")
.arg(
Arg::new("a")
.long("a")
.required_unless_present_any(["c", "b"]),
)
.arg(Arg::new("b").long("b"))
.arg(Arg::new("c").long("c"));
cmd.build();
let arg = cmd.get_arguments().find(|a| a.get_id() == "a").unwrap();
let flag = build_flag(&cmd, arg);
assert_eq!(
flag.required_unless_present,
vec!["b".to_string(), "c".to_string()]
);
}
#[test]
fn requirement_edges_ignore_lookalike_help_text() {
let mut cmd = Command::new("tool")
.arg(
Arg::new("a")
.long("a")
.help("needs requires: [(IsPresent, \"ghost\")] and r_unless: [\"ghost\"]")
.requires("realdep")
.required_unless_present("alt"),
)
.arg(Arg::new("realdep").long("realdep"))
.arg(Arg::new("alt").long("alt"));
cmd.build();
let arg = cmd.get_arguments().find(|a| a.get_id() == "a").unwrap();
let flag = build_flag(&cmd, arg);
assert_eq!(flag.requires, vec!["realdep".to_string()]);
assert_eq!(flag.required_unless_present, vec!["alt".to_string()]);
}
#[test]
fn requirement_edges_default_empty() {
let mut cmd = Command::new("tool").arg(Arg::new("a").long("a"));
cmd.build();
let arg = cmd.get_arguments().find(|a| a.get_id() == "a").unwrap();
let flag = build_flag(&cmd, arg);
assert!(flag.requires.is_empty());
assert!(flag.required_unless_present.is_empty());
}
#[test]
fn unbounded_arity_max_is_null_not_usize_max() {
let mut cmd = Command::new("tool").arg(
Arg::new("items")
.long("items")
.num_args(1..)
.action(ArgAction::Append),
);
cmd.build();
let arg = cmd.get_arguments().find(|a| a.get_id() == "items").unwrap();
let flag = build_flag(&cmd, arg);
assert_eq!(flag.arity.max, None, "unbounded max must serialize as null");
assert!(flag.arity.multi_value);
}
}