Skip to main content

agent_first_data/
help.rs

1use crate::cli::CliProtocolMode;
2use crate::formatting::{output_yaml_with_options, serialize_json_output};
3use crate::protocol::build_cli_error;
4use crate::redaction::{
5    OutputOptions, OutputStyle, RedactionContext, RedactionPolicy, Redactor, is_secret_flag_name,
6};
7use serde_json::Value;
8
9// ═══════════════════════════════════════════
10// Public API: CLI Help Rendering (optional)
11// ═══════════════════════════════════════════
12
13/// How much of a command tree a help request should render.
14///
15/// Requires the `cli-help` feature.
16#[cfg(feature = "cli-help")]
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum HelpScope {
19    /// Render only the selected command's own clap-style help.
20    ///
21    /// Clap's normal help still lists direct subcommands in the "Commands"
22    /// section, but descendant command detail is not expanded.
23    OneLevel,
24    /// Render the selected command and all visible descendant subcommands.
25    Recursive,
26}
27
28/// Output format for help rendering.
29///
30/// Requires the `cli-help` feature.
31#[cfg(feature = "cli-help")]
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum HelpFormat {
34    Plain,
35    Markdown,
36    Json,
37    Yaml,
38}
39
40#[cfg(feature = "cli-help")]
41impl HelpFormat {
42    fn parse(s: &str) -> Option<Self> {
43        match s {
44            "plain" => Some(Self::Plain),
45            "markdown" => Some(Self::Markdown),
46            "json" => Some(Self::Json),
47            "yaml" => Some(Self::Yaml),
48            _ => None,
49        }
50    }
51}
52
53/// Options for rendering CLI help.
54///
55/// Requires the `cli-help` feature.
56#[cfg(feature = "cli-help")]
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct HelpOptions {
59    pub scope: HelpScope,
60    pub format: HelpFormat,
61}
62
63#[cfg(feature = "cli-help")]
64impl HelpOptions {
65    /// Human-friendly current-level plain help.
66    pub const fn one_level_plain() -> Self {
67        Self {
68            scope: HelpScope::OneLevel,
69            format: HelpFormat::Plain,
70        }
71    }
72
73    /// Agent/doc-friendly recursive plain help.
74    pub const fn recursive_plain() -> Self {
75        Self {
76            scope: HelpScope::Recursive,
77            format: HelpFormat::Plain,
78        }
79    }
80}
81
82/// Configuration for pre-clap help handling.
83///
84/// The handler scans raw argv before `Cli::try_parse()` so applications can
85/// support requests such as `--help --output markdown` without clap exiting
86/// early with `DisplayHelp`.
87///
88/// Requires the `cli-help` feature.
89#[cfg(feature = "cli-help")]
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct HelpConfig {
92    /// Scope used for `--help` / `-h` when neither `--recursive` nor a
93    /// configured `recursive_flag` is present.
94    pub default_scope: HelpScope,
95    /// Format used for help when no explicit output flag is present.
96    pub default_format: HelpFormat,
97    /// Optional extra alias for the built-in `--recursive` scope modifier.
98    ///
99    /// `--recursive` is always recognized; set this only to accept an
100    /// additional custom flag name (for example `--full`). Like `--recursive`,
101    /// the alias is a *modifier* that selects recursive scope when `--help` is
102    /// present; on its own it does not trigger help.
103    pub recursive_flag: Option<&'static str>,
104    /// Optional output flag to read help format from, for example `--output`.
105    pub output_flag: Option<&'static str>,
106    /// Whether an explicit output flag can override `default_format`.
107    pub allow_output_format: bool,
108    /// Envelope mode for structured help output and early errors.
109    pub protocol_mode: CliProtocolMode,
110}
111
112#[cfg(feature = "cli-help")]
113impl HelpConfig {
114    /// Construct a custom help handler configuration.
115    pub const fn new(default_scope: HelpScope, default_format: HelpFormat) -> Self {
116        Self {
117            default_scope,
118            default_format,
119            recursive_flag: None,
120            output_flag: None,
121            allow_output_format: false,
122            protocol_mode: CliProtocolMode::Legacy,
123        }
124    }
125
126    /// Recommended preset for human-facing CLIs.
127    ///
128    /// `--help` renders one-level plain help by default. Scope and format are
129    /// orthogonal: `--recursive` expands the selected command subtree, while
130    /// `--output json|yaml|markdown` picks the format. So `--help --recursive`
131    /// is recursive plain text and `--help --recursive --output markdown` is a
132    /// recursive Markdown export.
133    pub const fn human_cli_default() -> Self {
134        Self {
135            default_scope: HelpScope::OneLevel,
136            default_format: HelpFormat::Plain,
137            recursive_flag: None,
138            output_flag: Some("--output"),
139            allow_output_format: true,
140            protocol_mode: CliProtocolMode::Legacy,
141        }
142    }
143
144    /// Recommended preset for agent-first CLIs that want full surface help by default.
145    pub const fn agent_cli_default() -> Self {
146        Self {
147            default_scope: HelpScope::Recursive,
148            default_format: HelpFormat::Plain,
149            recursive_flag: None,
150            output_flag: Some("--output"),
151            allow_output_format: true,
152            protocol_mode: CliProtocolMode::Legacy,
153        }
154    }
155
156    /// Return a copy with a different default scope.
157    pub const fn with_default_scope(mut self, scope: HelpScope) -> Self {
158        self.default_scope = scope;
159        self
160    }
161
162    /// Return a copy with a different default format.
163    pub const fn with_default_format(mut self, format: HelpFormat) -> Self {
164        self.default_format = format;
165        self
166    }
167
168    /// Return a copy with a different recursive-help flag.
169    pub const fn with_recursive_flag(mut self, flag: Option<&'static str>) -> Self {
170        self.recursive_flag = flag;
171        self
172    }
173
174    /// Return a copy with a different output flag.
175    pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
176        self.output_flag = flag;
177        self
178    }
179
180    /// Return a copy that enables or disables help format overrides.
181    pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
182        self.allow_output_format = enabled;
183        self
184    }
185
186    /// Return a copy that wraps JSON/YAML help in a protocol-v1 result event.
187    pub const fn with_protocol_v1(mut self) -> Self {
188        self.protocol_mode = CliProtocolMode::ProtocolV1;
189        self
190    }
191}
192
193/// Render help for a clap command tree with explicit scope and format.
194///
195/// Walks to the subcommand identified by `subcommand_path` (empty = root),
196/// then renders either the selected command only (`OneLevel`) or the selected
197/// command and all descendants (`Recursive`).
198///
199/// Requires the `cli-help` feature.
200#[cfg(feature = "cli-help")]
201pub fn cli_render_help_with_options(
202    cmd: &clap::Command,
203    subcommand_path: &[&str],
204    options: &HelpOptions,
205) -> String {
206    let target = walk_to_subcommand(cmd, subcommand_path);
207    let mut rendered = match options.format {
208        HelpFormat::Plain => {
209            let mut help = match options.scope {
210                HelpScope::OneLevel => render_help_one_level_plain(target),
211                HelpScope::Recursive => {
212                    let mut buf = String::new();
213                    render_help_recursive_plain(target, &[], &mut buf);
214                    buf
215                }
216            };
217            append_afdata_version_line(&mut help);
218            help
219        }
220        HelpFormat::Markdown => {
221            let mut help = render_help_markdown(cmd, subcommand_path, options.scope);
222            append_afdata_version_line(&mut help);
223            help
224        }
225        HelpFormat::Json => {
226            serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
227        }
228        HelpFormat::Yaml => output_yaml_with_options(
229            &build_help_schema(cmd, subcommand_path, options.scope),
230            &OutputOptions {
231                redaction: Redactor::new().policy(RedactionPolicy::RedactionNone),
232                style: OutputStyle::Raw,
233            },
234        ),
235    };
236    // Every format ends with exactly one trailing newline so `print!`-ing the
237    // result is clean across plain/markdown/json/yaml (JSON and raw YAML would
238    // otherwise have none).
239    while rendered.ends_with('\n') {
240        rendered.pop();
241    }
242    rendered.push('\n');
243    rendered
244}
245
246#[cfg(feature = "cli-help")]
247fn append_afdata_version_line(help: &mut String) {
248    const LINE: &str = concat!("AFDATA: ", env!("CARGO_PKG_VERSION"));
249    if help.lines().any(|line| line.trim() == LINE) {
250        return;
251    }
252    if !help.is_empty() && !help.ends_with('\n') {
253        help.push('\n');
254    }
255    help.push_str(LINE);
256    help.push('\n');
257}
258
259#[cfg(feature = "cli-help")]
260fn afdata_versions_value() -> Value {
261    serde_json::json!({ "afdata": env!("CARGO_PKG_VERSION") })
262}
263
264/// Render recursive plain-text help for a clap command tree.
265///
266/// Walks to the subcommand identified by `subcommand_path` (empty = root),
267/// then recursively expands all descendant subcommands into a single output.
268///
269/// Requires the `cli-help` feature.
270#[cfg(feature = "cli-help")]
271pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
272    cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
273}
274
275/// Render recursive Markdown help for a clap command tree.
276///
277/// Same tree walk as [`cli_render_help`], but outputs Markdown suitable for
278/// documentation generation (`myapp --help --recursive --output markdown > docs/cli.md`).
279///
280/// Requires the `cli-help-markdown` feature.
281#[cfg(feature = "cli-help-markdown")]
282pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
283    cli_render_help_with_options(
284        cmd,
285        subcommand_path,
286        &HelpOptions {
287            scope: HelpScope::Recursive,
288            format: HelpFormat::Markdown,
289        },
290    )
291}
292
293/// Render help from raw argv if a help flag is present; otherwise return `None`.
294///
295/// `raw_args` should be the full argv vector, including argv[0], as produced by
296/// `std::env::args()`. The helper intentionally runs before clap parsing so
297/// `--help --recursive` and `--help --output markdown` can select scope and
298/// format instead of being consumed by clap's built-in help handling. Scope
299/// (`--recursive`) and format (`--output`) are orthogonal.
300///
301/// A bare `--recursive` without `--help` is treated as a non-help request
302/// (`Ok(None)`), leaving the flag for the application's own parser.
303///
304/// Returns a standard [`build_cli_error`] value when the help request is
305/// malformed, for example `--help --output xml`.
306///
307/// Requires the `cli-help` feature.
308#[cfg(feature = "cli-help")]
309pub fn cli_handle_help_or_continue(
310    raw_args: &[String],
311    cmd: &clap::Command,
312    config: &HelpConfig,
313) -> Result<Option<String>, Value> {
314    let parsed = parse_help_request(raw_args, cmd, config);
315    if !parsed.help_requested {
316        return Ok(None);
317    }
318    if let Some(error) = parsed.output_error {
319        let event = build_cli_error(
320            &error,
321            Some("valid help output formats: plain, markdown, json, yaml"),
322        );
323        let mut event_value: Value = event.into();
324        if config.protocol_mode == CliProtocolMode::ProtocolV1
325            && let Some(obj) = event_value.as_object_mut()
326        {
327            obj.insert("trace".to_string(), serde_json::json!({}));
328        }
329        return Err(event_value);
330    }
331
332    let (scope, format) = resolve_help_options(&parsed, config);
333    let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
334    let options = HelpOptions { scope, format };
335    if config.protocol_mode == CliProtocolMode::ProtocolV1
336        && matches!(format, HelpFormat::Json | HelpFormat::Yaml)
337    {
338        #[allow(clippy::expect_used)]
339        let event = crate::protocol::json_result(serde_json::json!({
340            "code": "help",
341            "help": build_help_schema(cmd, &path, scope),
342        }))
343        .trace(serde_json::json!({}))
344        .build()
345        .expect("help builder failed");
346        let rendered = match format {
347            HelpFormat::Json => serialize_json_output(&event),
348            HelpFormat::Yaml => output_yaml_with_options(
349                &event,
350                &OutputOptions {
351                    redaction: Redactor::new().policy(RedactionPolicy::RedactionNone),
352                    style: OutputStyle::Raw,
353                },
354            ),
355            HelpFormat::Plain | HelpFormat::Markdown => unreachable!(),
356        };
357        return Ok(Some(format!("{rendered}\n")));
358    }
359    Ok(Some(cli_render_help_with_options(cmd, &path, &options)))
360}
361
362#[cfg(feature = "cli-help")]
363fn resolve_help_options(
364    parsed: &ParsedHelpRequest,
365    config: &HelpConfig,
366) -> (HelpScope, HelpFormat) {
367    // Scope and format are orthogonal: `--recursive` (or the configured
368    // recursive flag, or a recursive default_scope) decides one-level vs
369    // recursive, while `--output` independently decides the format.
370    let scope = if parsed.recursive_requested {
371        HelpScope::Recursive
372    } else {
373        config.default_scope
374    };
375    let format = if config.allow_output_format {
376        parsed.output_format.unwrap_or(config.default_format)
377    } else {
378        config.default_format
379    };
380    (scope, format)
381}
382
383#[cfg(feature = "cli-help")]
384fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
385    let mut current = cmd;
386    for name in path {
387        current = current.find_subcommand(name).unwrap_or(current);
388    }
389    current
390}
391
392#[cfg(feature = "cli-help")]
393fn walk_to_subcommand_with_names<'a>(
394    cmd: &'a clap::Command,
395    path: &[&str],
396) -> (&'a clap::Command, Vec<String>) {
397    let mut current = cmd;
398    let mut names = vec![cmd.get_name().to_string()];
399    for name in path {
400        if let Some(next) = current.find_subcommand(name) {
401            current = next;
402            names.push(next.get_name().to_string());
403        } else {
404            break;
405        }
406    }
407    (current, names)
408}
409
410#[cfg(feature = "cli-help")]
411fn render_help_one_level_plain(cmd: &clap::Command) -> String {
412    enriched_help_command(cmd).render_long_help().to_string()
413}
414
415#[cfg(feature = "cli-help")]
416fn redact_secret_help_defaults(mut cmd: clap::Command) -> clap::Command {
417    let context = RedactionContext::default();
418    let ids: Vec<String> = cmd
419        .get_arguments()
420        .filter(|arg| !arg.get_default_values().is_empty())
421        .filter(|arg| help_arg_is_secret(arg, &context))
422        .map(|arg| arg.get_id().to_string())
423        .collect();
424    for id in ids {
425        cmd = cmd.mut_arg(id, |arg| arg.default_value("***"));
426    }
427    cmd
428}
429
430#[cfg(feature = "cli-help")]
431fn help_arg_is_secret(arg: &clap::Arg, context: &RedactionContext) -> bool {
432    is_secret_flag_name(arg.get_id().as_ref(), context)
433        || arg
434            .get_long()
435            .is_some_and(|long| is_secret_flag_name(long, context))
436}
437
438/// Clone `cmd` and fold the afdata-handled help modifiers into clap's own
439/// `-h, --help` description.
440///
441/// Help is rendered by clap, which has no knowledge of the `--recursive` scope
442/// modifier or the `--output` help formats (afdata consumes both before clap
443/// parses). Rather than appending a separate section, we patch the description
444/// of the existing help flag so the help surface is documented in place — in
445/// every format, since plain/markdown render this flag and the JSON/YAML schema
446/// reads it. Commands with subcommands advertise `--recursive`; leaf commands
447/// only advertise the `--output` formats (they have nothing to expand).
448#[cfg(feature = "cli-help")]
449fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
450    let cmd = redact_secret_help_defaults(cmd.clone());
451    let description = if visible_subcommands(&cmd).next().is_some() {
452        HELP_FLAG_WITH_SUBCOMMANDS
453    } else {
454        HELP_FLAG_LEAF
455    };
456    // clap auto-generates `-h, --help` lazily during build, so `mut_arg` cannot
457    // reach it yet. Replace it with an explicit flag carrying the enriched
458    // description. This command is only rendered, never parsed (afdata handles
459    // `--help` before clap), so the action is immaterial.
460    cmd.disable_help_flag(true).arg(
461        clap::Arg::new("help")
462            .short('h')
463            .long("help")
464            .help(description)
465            .long_help(description)
466            .action(clap::ArgAction::Help),
467    )
468}
469
470/// Description for the `-h, --help` flag on commands that have subcommands.
471#[cfg(feature = "cli-help")]
472const HELP_FLAG_WITH_SUBCOMMANDS: &str = "Print help. Add --recursive to expand every nested subcommand; \
473     add --output json|yaml|markdown to render this help in another format.";
474
475/// Description for the `-h, --help` flag on leaf commands (no subcommands).
476#[cfg(feature = "cli-help")]
477const HELP_FLAG_LEAF: &str =
478    "Print help. Add --output json|yaml|markdown to render this help in another format.";
479
480#[cfg(feature = "cli-help")]
481fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
482    use std::fmt::Write;
483
484    // Build the full command path (e.g. "myapp service start")
485    let mut cmd_path = parent_path.to_vec();
486    cmd_path.push(cmd.get_name());
487    let path_str = cmd_path.join(" ");
488
489    // Separator between commands (skip for the first one)
490    if !buf.is_empty() {
491        let _ = writeln!(buf);
492        let _ = writeln!(buf, "{}", "═".repeat(60));
493    }
494
495    // Header: "myapp service start — description"
496    if let Some(about) = cmd.get_about() {
497        let _ = writeln!(buf, "{path_str} — {about}");
498    } else {
499        let _ = writeln!(buf, "{path_str}");
500    }
501    let _ = writeln!(buf);
502
503    // Render clap's built-in help for this command (usage, args, options).
504    // Only the target command (top of the recursion) advertises the help
505    // modifiers; repeating them on every descendant block would be pure noise.
506    let is_target = parent_path.is_empty();
507    let styled = if is_target {
508        enriched_help_command(cmd).render_long_help()
509    } else {
510        redact_secret_help_defaults(cmd.clone()).render_long_help()
511    };
512    let help_text = styled.to_string();
513    let _ = write!(buf, "{help_text}");
514
515    // Recurse into visible subcommands
516    for sub in cmd.get_subcommands() {
517        if sub.get_name() == "help" || sub.is_hide_set() {
518            continue; // skip clap's auto-generated "help" subcommand
519        }
520        render_help_recursive_plain(sub, &cmd_path, buf);
521    }
522}
523
524#[cfg(feature = "cli-help")]
525fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
526    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
527    let mut buf = String::new();
528    render_markdown_command(target, &names, &mut buf, 1, true);
529    if matches!(scope, HelpScope::Recursive) {
530        render_markdown_descendants(target, &names, &mut buf, 2);
531    }
532    buf
533}
534
535#[cfg(feature = "cli-help")]
536fn render_markdown_descendants(
537    cmd: &clap::Command,
538    parent_names: &[String],
539    buf: &mut String,
540    level: usize,
541) {
542    for sub in cmd.get_subcommands() {
543        if sub.get_name() == "help" || sub.is_hide_set() {
544            continue;
545        }
546        let mut names = parent_names.to_vec();
547        names.push(sub.get_name().to_string());
548        render_markdown_command(sub, &names, buf, level, false);
549        render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
550    }
551}
552
553#[cfg(feature = "cli-help")]
554fn render_markdown_command(
555    cmd: &clap::Command,
556    names: &[String],
557    buf: &mut String,
558    level: usize,
559    enrich: bool,
560) {
561    use std::fmt::Write;
562
563    if !buf.is_empty() {
564        let _ = writeln!(buf);
565    }
566    let heading_level = "#".repeat(level.max(1));
567    let path = names.join(" ");
568    if let Some(about) = cmd.get_about() {
569        let _ = writeln!(buf, "{heading_level} {path} - {about}");
570    } else {
571        let _ = writeln!(buf, "{heading_level} {path}");
572    }
573    if let Some(long_about) = markdown_long_about(cmd) {
574        let _ = writeln!(buf);
575        write_trimmed_help(buf, &long_about);
576    }
577    let _ = writeln!(buf);
578    let _ = writeln!(buf, "```text");
579    let help = markdown_help_block_command(cmd, enrich).render_long_help();
580    write_trimmed_help(buf, &help.to_string());
581    if !buf.ends_with('\n') {
582        let _ = writeln!(buf);
583    }
584    let _ = writeln!(buf, "```");
585}
586
587#[cfg(feature = "cli-help")]
588fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
589    let long_about = cmd.get_long_about()?.to_string();
590    let rendered = match cmd.get_about() {
591        Some(about) => {
592            let about_str = about.to_string();
593            if long_about.trim() == format!("{} - {}", cmd.get_name(), about_str) {
594                return None;
595            }
596            strip_leading_about_paragraph(&long_about, &about_str)
597        }
598        None => long_about.as_str(),
599    };
600    let rendered = rendered.trim_matches(['\r', '\n']);
601    if rendered.is_empty() {
602        None
603    } else {
604        Some(rendered.to_string())
605    }
606}
607
608#[cfg(feature = "cli-help")]
609fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
610    let long_about = long_about.trim_start_matches(['\r', '\n']);
611    let Some(rest) = long_about.strip_prefix(about) else {
612        return long_about;
613    };
614    if rest.is_empty() {
615        return "";
616    }
617    rest.strip_prefix("\r\n\r\n")
618        .or_else(|| rest.strip_prefix("\n\n"))
619        .unwrap_or(long_about)
620}
621
622#[cfg(feature = "cli-help")]
623fn markdown_help_block_command(cmd: &clap::Command, enrich: bool) -> clap::Command {
624    let cmd = if enrich {
625        enriched_help_command(cmd)
626    } else {
627        redact_secret_help_defaults(cmd.clone())
628    };
629    cmd.about(None::<&str>).long_about(None::<&str>)
630}
631
632#[cfg(feature = "cli-help")]
633fn write_trimmed_help(buf: &mut String, help: &str) {
634    use std::fmt::Write;
635
636    for line in help.lines() {
637        let _ = writeln!(buf, "{}", line.trim_end());
638    }
639}
640
641#[cfg(feature = "cli-help")]
642struct ParsedHelpRequest {
643    help_requested: bool,
644    recursive_requested: bool,
645    output_format: Option<HelpFormat>,
646    output_error: Option<String>,
647    subcommand_path: Vec<String>,
648}
649
650#[cfg(feature = "cli-help")]
651fn parse_help_request(
652    raw_args: &[String],
653    cmd: &clap::Command,
654    config: &HelpConfig,
655) -> ParsedHelpRequest {
656    let args = match raw_args.first() {
657        Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
658        _ => raw_args.get(1..).unwrap_or(&[]),
659    };
660    let mut help_requested = false;
661    let mut recursive_requested = false;
662    let mut output_format = None;
663    let mut output_error = None;
664    let mut subcommand_path = Vec::new();
665    let mut current = cmd;
666    let output_flag = config.output_flag.map(normalize_long_flag);
667    let recursive_flag = config.recursive_flag.map(normalize_long_flag);
668
669    let mut i = 0usize;
670    while i < args.len() {
671        let arg = args[i].as_str();
672        if arg == "--" {
673            break;
674        }
675
676        let (flag_name, inline_value) = split_flag(arg);
677        if matches!(arg, "--help" | "-h") {
678            help_requested = true;
679            i += 1;
680            continue;
681        }
682        // `--recursive` is a help *modifier*, not a help trigger: it only
683        // selects recursive scope when `--help` is also present. A bare
684        // `--recursive` leaves help_requested false so the full argv falls
685        // through to the application's own parser untouched.
686        if arg == "--recursive"
687            || flag_name
688                .zip(recursive_flag)
689                .is_some_and(|(seen, expected)| seen == expected)
690        {
691            recursive_requested = true;
692            i += 1;
693            continue;
694        }
695        if config.allow_output_format && arg == "--json" {
696            set_help_output_format(
697                &mut output_format,
698                HelpFormat::Json,
699                "--json",
700                &mut output_error,
701            );
702            i += 1;
703            continue;
704        }
705        if config.allow_output_format
706            && flag_name
707                .zip(output_flag)
708                .is_some_and(|(seen, expected)| seen == expected)
709        {
710            let value = inline_value.or_else(|| {
711                args.get(i + 1)
712                    .map(String::as_str)
713                    .filter(|next| !next.starts_with('-'))
714            });
715            if let Some(value) = value {
716                match HelpFormat::parse(value) {
717                    Some(format) => set_help_output_format(
718                        &mut output_format,
719                        format,
720                        &format!("--{} {value}", output_flag.unwrap_or("output")),
721                        &mut output_error,
722                    ),
723                    None => {
724                        output_error = Some(format!(
725                            "invalid --{} format '{}': expected plain, json, yaml, or markdown",
726                            output_flag.unwrap_or("output"),
727                            value
728                        ));
729                    }
730                }
731            } else {
732                output_error = Some(format!(
733                    "missing value for --{}: expected plain, json, yaml, or markdown",
734                    output_flag.unwrap_or("output")
735                ));
736            }
737            i += if inline_value.is_some() || value.is_none() {
738                1
739            } else {
740                2
741            };
742            continue;
743        }
744        if arg.starts_with('-') {
745            i += if inline_value.is_none() && flag_takes_value(current, arg) {
746                2
747            } else {
748                1
749            };
750            continue;
751        }
752        if let Some(sub) = current.find_subcommand(arg)
753            && sub.get_name() != "help"
754            && !sub.is_hide_set()
755        {
756            subcommand_path.push(sub.get_name().to_string());
757            current = sub;
758        }
759        i += 1;
760    }
761
762    ParsedHelpRequest {
763        help_requested,
764        recursive_requested,
765        output_format,
766        output_error,
767        subcommand_path,
768    }
769}
770
771#[cfg(feature = "cli-help")]
772fn set_help_output_format(
773    current: &mut Option<HelpFormat>,
774    next: HelpFormat,
775    source: &str,
776    output_error: &mut Option<String>,
777) {
778    if let Some(existing) = current
779        && *existing != next
780    {
781        *output_error = Some(format!(
782            "conflicting output formats: {source} conflicts with previous output format"
783        ));
784        return;
785    }
786    *current = Some(next);
787}
788
789fn normalize_long_flag(flag: &str) -> &str {
790    flag.trim_start_matches('-')
791}
792
793fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
794    if let Some(stripped) = arg.strip_prefix("--") {
795        if let Some((name, value)) = stripped.split_once('=') {
796            (Some(name), Some(value))
797        } else {
798            (Some(stripped), None)
799        }
800    } else if let Some(stripped) = arg.strip_prefix('-') {
801        (Some(stripped), None)
802    } else {
803        (None, None)
804    }
805}
806
807#[cfg(feature = "cli-help")]
808fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
809    let Some(flag) = raw_flag.strip_prefix('-') else {
810        return false;
811    };
812    let name = flag.trim_start_matches('-');
813    cmd.get_arguments().any(|arg| {
814        let long_matches = arg.get_long().is_some_and(|long| long == name);
815        let short_matches =
816            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
817        (long_matches || short_matches)
818            && matches!(
819                arg.get_action(),
820                clap::ArgAction::Set | clap::ArgAction::Append
821            )
822    })
823}
824
825#[cfg(feature = "cli-help")]
826fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
827    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
828    let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
829    if let Value::Object(map) = &mut schema {
830        map.insert("code".to_string(), Value::String("help".to_string()));
831        map.insert(
832            "scope".to_string(),
833            Value::String(help_scope_tag(scope).to_string()),
834        );
835        map.insert("versions".to_string(), afdata_versions_value());
836    }
837    schema
838}
839
840#[cfg(feature = "cli-help")]
841fn help_scope_tag(scope: HelpScope) -> &'static str {
842    match scope {
843        HelpScope::OneLevel => "one_level",
844        HelpScope::Recursive => "recursive",
845    }
846}
847
848#[cfg(feature = "cli-help")]
849fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
850    let subcommands: Vec<Value> = visible_subcommands(cmd)
851        .map(|sub| {
852            let mut child_names = names.to_vec();
853            child_names.push(sub.get_name().to_string());
854            if recursive {
855                // Descendants never re-advertise the help modifiers (enrich=false).
856                command_schema(sub, &child_names, true, false)
857            } else {
858                command_summary_schema(sub, &child_names)
859            }
860        })
861        .collect();
862
863    serde_json::json!({
864        "name": cmd.get_name(),
865        "command_path": names.join(" "),
866        "path": names,
867        "about": styled_to_value(cmd.get_about()),
868        "long_about": styled_to_value(cmd.get_long_about()),
869        "usage": cmd.clone().render_usage().to_string(),
870        "arguments": command_arguments_schema(cmd, enrich),
871        "subcommands": subcommands,
872    })
873}
874
875#[cfg(feature = "cli-help")]
876fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
877    serde_json::json!({
878        "name": cmd.get_name(),
879        "command_path": names.join(" "),
880        "path": names,
881        "about": styled_to_value(cmd.get_about()),
882        "long_about": styled_to_value(cmd.get_long_about()),
883        "usage": Value::Null,
884        "arguments": [],
885        "subcommands": [],
886    })
887}
888
889#[cfg(feature = "cli-help")]
890fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
891    cmd.get_subcommands()
892        .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
893}
894
895#[cfg(feature = "cli-help")]
896fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
897    // For the target command, render through the enriched clone so the schema
898    // documents the `-h, --help` modifiers (`--recursive`, `--output`) just like
899    // the plain and markdown formats do (clap adds `--help` lazily during build,
900    // so the raw command would omit it). Descendants stay un-enriched to avoid
901    // repeating the same modifier doc on every command in a recursive dump.
902    let owned = enrich.then(|| enriched_help_command(cmd));
903    let source = owned.as_ref().unwrap_or(cmd);
904    source
905        .get_arguments()
906        .filter(|arg| !arg.is_hide_set())
907        .map(argument_schema)
908        .collect()
909}
910
911#[cfg(feature = "cli-help")]
912fn argument_schema(arg: &clap::Arg) -> Value {
913    let value_names: Vec<String> = arg
914        .get_value_names()
915        .map(|names| names.iter().map(ToString::to_string).collect())
916        .unwrap_or_default();
917    let default_values: Vec<String> = arg
918        .get_default_values()
919        .iter()
920        .map(|value| {
921            if help_arg_is_secret(arg, &RedactionContext::default()) {
922                "***".to_string()
923            } else {
924                value.to_string_lossy().to_string()
925            }
926        })
927        .collect();
928    serde_json::json!({
929        "id": arg.get_id().to_string(),
930        "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
931        "long": arg.get_long(),
932        "short": arg.get_short().map(|c| c.to_string()),
933        "help": styled_to_value(arg.get_help()),
934        "long_help": styled_to_value(arg.get_long_help()),
935        "required": arg.is_required_set(),
936        "action": format!("{:?}", arg.get_action()),
937        "value_names": value_names,
938        "default_values": default_values,
939    })
940}
941
942#[cfg(feature = "cli-help")]
943fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
944    value.map_or(Value::Null, |s| Value::String(s.to_string()))
945}