Skip to main content

agent_first_data/
help.rs

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