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#[cfg(feature = "cli-help")]
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum HelpScope {
18 OneLevel,
23 Recursive,
25}
26
27#[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#[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 pub const fn one_level_plain() -> Self {
66 Self {
67 scope: HelpScope::OneLevel,
68 format: HelpFormat::Plain,
69 }
70 }
71
72 pub const fn recursive_plain() -> Self {
74 Self {
75 scope: HelpScope::Recursive,
76 format: HelpFormat::Plain,
77 }
78 }
79}
80
81#[cfg(feature = "cli-help")]
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct HelpConfig {
91 pub default_scope: HelpScope,
93 pub default_format: HelpFormat,
99}
100
101#[cfg(feature = "cli-help")]
102impl HelpConfig {
103 pub const fn output_aware() -> Self {
111 Self::output_aware_with_fallback(HelpFormat::Plain)
112 }
113
114 pub const fn output_aware_with_fallback(default_format: HelpFormat) -> Self {
121 Self {
122 default_scope: HelpScope::OneLevel,
123 default_format,
124 }
125 }
126
127 pub const fn human_cli_default() -> Self {
132 Self::output_aware()
133 }
134}
135
136#[cfg(feature = "cli-help")]
149pub fn cli_render_help_with_options(
150 cmd: &clap::Command,
151 subcommand_path: &[&str],
152 options: &HelpOptions,
153) -> String {
154 let selected = scoped_help_command(cmd, subcommand_path);
155 let target = &selected.conventional_command;
156 let mut rendered = match options.format {
157 HelpFormat::Plain => match options.scope {
158 HelpScope::OneLevel => render_help_one_level_plain(target),
159 HelpScope::Recursive => {
160 let mut buf = String::new();
161 render_help_recursive_plain(target, &[], &mut buf);
162 buf
163 }
164 },
165 HelpFormat::Markdown => render_help_markdown(
166 target,
167 std::slice::from_ref(&selected.command_path),
168 options.scope,
169 ),
170 HelpFormat::Json => {
171 serialize_json_output(help_result_event(&selected, options.scope).as_value())
172 }
173 HelpFormat::Yaml => render_yaml(
174 help_result_event(&selected, options.scope).as_value(),
175 &OutputOptions {
176 redaction: Redactor::new().policy(RedactionPolicy::Off),
177 style: PlainStyle::Raw,
178 },
179 ),
180 };
181 while rendered.ends_with('\n') {
185 rendered.pop();
186 }
187 rendered.push('\n');
188 rendered
189}
190
191#[cfg(feature = "cli-help")]
198pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
199 cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
200}
201
202#[cfg(feature = "cli-help-markdown")]
209pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
210 cli_render_help_with_options(
211 cmd,
212 subcommand_path,
213 &HelpOptions {
214 scope: HelpScope::Recursive,
215 format: HelpFormat::Markdown,
216 },
217 )
218}
219
220#[cfg(feature = "cli-help")]
236pub fn cli_handle_help_or_continue(
237 raw_args: &[String],
238 cmd: &clap::Command,
239 config: &HelpConfig,
240) -> Result<Option<String>, Value> {
241 cli_handle_help_or_continue_inner(raw_args, cmd, config)
242}
243
244#[cfg(feature = "cli-help")]
254#[allow(clippy::too_many_arguments)]
255pub fn cli_handle_version_or_help_or_continue(
256 raw_args: &[String],
257 cmd: &clap::Command,
258 config: &HelpConfig,
259 name: &str,
260 display_name: Option<&str>,
261 version: &str,
262 build: Option<&str>,
263) -> Result<Option<String>, Value> {
264 match crate::cli::cli_handle_version_or_continue(
265 raw_args,
266 cmd,
267 name,
268 display_name,
269 version,
270 build,
271 ) {
272 Ok(Some(rendered)) => return Ok(Some(rendered)),
273 Ok(None) => {}
274 Err(event) => return Err(event.into()),
275 }
276 let cmd = version_aware_help_command(cmd);
283 cli_handle_help_or_continue(raw_args, &cmd, config)
284}
285
286#[cfg(feature = "cli-help")]
292fn version_aware_help_command(cmd: &clap::Command) -> clap::Command {
293 let already_exposed = cmd.get_arguments().any(|arg| {
294 arg.get_long() == Some("version") || arg.get_short().is_some_and(|short| short == 'V')
295 }) || (!cmd.is_disable_version_flag_set()
296 && (cmd.get_version().is_some() || cmd.get_long_version().is_some()));
297 if already_exposed {
298 return cmd.clone();
299 }
300 let version_flag = clap::Arg::new("version")
301 .long("version")
302 .help("Print version")
303 .action(clap::ArgAction::SetTrue);
304 cmd.clone().disable_version_flag(true).arg(version_flag)
305}
306
307#[cfg(feature = "cli-help")]
308fn cli_handle_help_or_continue_inner(
309 raw_args: &[String],
310 cmd: &clap::Command,
311 config: &HelpConfig,
312) -> Result<Option<String>, Value> {
313 let parsed = parse_help_request(raw_args, cmd);
314 if !parsed.help_requested {
315 return Ok(None);
316 }
317 if let Some(error) = parsed.output_error {
318 let event = build_cli_error(
319 &error,
320 Some("valid help output formats: plain, markdown, json, yaml"),
321 );
322 return Err(event.into());
323 }
324
325 let (scope, format) = resolve_help_options(&parsed, cmd, config);
326 let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
327 if matches!(format, HelpFormat::Json | HelpFormat::Yaml) {
331 let event = help_result_event(&scoped_help_command(cmd, &path), scope);
332 let rendered = match format {
333 HelpFormat::Json => serialize_json_output(event.as_value()),
334 HelpFormat::Yaml => render_yaml(
335 event.as_value(),
336 &OutputOptions {
337 redaction: Redactor::new().policy(RedactionPolicy::Off),
338 style: PlainStyle::Raw,
339 },
340 ),
341 HelpFormat::Plain | HelpFormat::Markdown => unreachable!(),
342 };
343 return Ok(Some(format!("{rendered}\n")));
344 }
345 let options = HelpOptions { scope, format };
346 Ok(Some(cli_render_help_with_options(cmd, &path, &options)))
347}
348
349#[cfg(feature = "cli-help")]
352fn help_result_event(selected: &ScopedHelpCommand, scope: HelpScope) -> crate::protocol::Event {
353 crate::protocol::json_result(serde_json::json!({
354 "code": "help",
355 "help": build_selected_help_schema(selected, scope),
356 }))
357 .trace(serde_json::json!({}))
358 .build()
359}
360
361#[cfg(feature = "cli-help")]
362fn resolve_help_options(
363 parsed: &ParsedHelpRequest,
364 cmd: &clap::Command,
365 config: &HelpConfig,
366) -> (HelpScope, HelpFormat) {
367 let scope = if parsed.recursive_requested {
370 HelpScope::Recursive
371 } else {
372 config.default_scope
373 };
374 let format = parsed
375 .output_format
376 .or_else(|| command_output_default(cmd, &parsed.subcommand_path))
377 .unwrap_or(config.default_format);
378 (scope, format)
379}
380
381#[cfg(feature = "cli-help")]
382fn command_output_default(cmd: &clap::Command, path: &[String]) -> Option<HelpFormat> {
383 let mut current = cmd;
384 let mut format = command_output_default_here(current);
385 for name in path {
386 let Some(next) = current.find_subcommand(name) else {
387 break;
388 };
389 current = next;
390 if let Some(selected) = command_output_default_here(current) {
391 format = Some(selected);
392 }
393 }
394 format
395}
396
397#[cfg(feature = "cli-help")]
398fn command_output_default_here(cmd: &clap::Command) -> Option<HelpFormat> {
399 cmd.get_arguments()
400 .find(|arg| arg.get_long() == Some("output"))
401 .and_then(|arg| arg.get_default_values().first())
402 .and_then(|value| value.to_str())
403 .and_then(HelpFormat::parse)
404}
405
406#[cfg(feature = "cli-help")]
407struct ScopedHelpCommand {
408 local_command: clap::Command,
409 conventional_command: clap::Command,
410 command_path: String,
411 inherited_arguments_from: Vec<String>,
412}
413
414#[cfg(feature = "cli-help")]
415fn scoped_help_command(cmd: &clap::Command, path: &[&str]) -> ScopedHelpCommand {
416 let mut current = cmd;
417 let mut command_names = vec![cmd.get_bin_name().unwrap_or(cmd.get_name()).to_string()];
418 let mut inherited_groups = Vec::new();
419
420 for name in path {
421 let Some(next) = current.find_subcommand(name) else {
422 break;
423 };
424 let globals: Vec<clap::Arg> = current
425 .get_arguments()
426 .filter(|arg| arg.is_global_set() && !arg.is_hide_set())
427 .cloned()
428 .collect();
429 if !globals.is_empty() {
430 inherited_groups.push((command_names.join(" "), globals));
431 }
432 current = next;
433 command_names.push(next.get_name().to_string());
434 }
435
436 let command_path = command_names.join(" ");
437 let local_command = current.clone().bin_name(command_path.clone());
438 let mut conventional_command = local_command.clone();
439 let mut inherited_arguments_from = Vec::new();
440 for (source, arguments) in inherited_groups {
441 let mut inherited_from_source = false;
442 for argument in arguments {
443 let already_declared = conventional_command
444 .get_arguments()
445 .any(|candidate| candidate.get_id() == argument.get_id());
446 if !already_declared {
447 conventional_command = conventional_command.arg(argument);
448 inherited_from_source = true;
449 }
450 }
451 if inherited_from_source {
452 inherited_arguments_from.push(source);
453 }
454 }
455
456 ScopedHelpCommand {
457 local_command,
458 conventional_command,
459 command_path,
460 inherited_arguments_from,
461 }
462}
463
464#[cfg(feature = "cli-help")]
465fn render_help_one_level_plain(cmd: &clap::Command) -> String {
466 enriched_help_command(cmd).render_long_help().to_string()
467}
468
469#[cfg(feature = "cli-help")]
470fn redact_secret_help_defaults(mut cmd: clap::Command) -> clap::Command {
471 let context = RedactionContext::default();
472 let ids: Vec<String> = cmd
473 .get_arguments()
474 .filter(|arg| !arg.get_default_values().is_empty())
475 .filter(|arg| help_arg_is_secret(arg, &context))
476 .map(|arg| arg.get_id().to_string())
477 .collect();
478 for id in ids {
479 cmd = cmd.mut_arg(id, |arg| arg.default_value("***"));
480 }
481 cmd
482}
483
484#[cfg(feature = "cli-help")]
485fn help_arg_is_secret(arg: &clap::Arg, context: &RedactionContext) -> bool {
486 is_secret_flag_name(arg.get_id().as_ref(), context)
487 || arg
488 .get_long()
489 .is_some_and(|long| is_secret_flag_name(long, context))
490}
491
492#[cfg(feature = "cli-help")]
503fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
504 let description = if visible_subcommands(cmd).next().is_some() {
505 HELP_FLAG_WITH_SUBCOMMANDS
506 } else {
507 HELP_FLAG_LEAF
508 };
509 let cmd = redact_secret_help_defaults(cmd.clone()).disable_help_subcommand(true);
512 let has_explicit_version = cmd.get_arguments().any(|arg| {
513 arg.get_long() == Some("version") || arg.get_short().is_some_and(|short| short == 'V')
514 });
515 let exposes_auto_version = !cmd.is_disable_version_flag_set()
516 && (cmd.get_version().is_some() || cmd.get_long_version().is_some());
517 let help_flag = clap::Arg::new("help")
523 .long("help")
524 .help(description)
525 .long_help(description)
526 .action(clap::ArgAction::Help);
527 let mut enriched = cmd.disable_help_flag(true).arg(help_flag);
531 if exposes_auto_version && !has_explicit_version {
532 enriched = enriched.disable_version_flag(true).arg(
533 clap::Arg::new("version")
534 .long("version")
535 .help("Print version")
536 .action(clap::ArgAction::Version),
537 );
538 }
539 enriched
540}
541
542#[cfg(feature = "cli-help")]
544const HELP_FLAG_WITH_SUBCOMMANDS: &str = "Print help. Add --recursive to expand every nested subcommand; \
545 add --output plain|json|yaml|markdown to choose the format.";
546
547#[cfg(feature = "cli-help")]
549const HELP_FLAG_LEAF: &str =
550 "Print help. Add --output plain|json|yaml|markdown to choose the format.";
551
552#[cfg(feature = "cli-help")]
553fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
554 use std::fmt::Write;
555
556 let mut cmd_path = parent_path.to_vec();
557 cmd_path.push(if parent_path.is_empty() {
558 cmd.get_bin_name().unwrap_or(cmd.get_name())
559 } else {
560 cmd.get_name()
561 });
562 let path = cmd_path.join(" ");
563 let usage = compact_usage(cmd, &path);
564 if let Some(about) = cmd.get_about() {
565 let _ = writeln!(buf, "{usage} — {about}");
566 } else {
567 let _ = writeln!(buf, "{usage}");
568 }
569
570 let is_target = parent_path.is_empty();
571 let owned = if is_target {
572 Some(enriched_help_command(cmd))
573 } else {
574 None
575 };
576 let source = owned.as_ref().unwrap_or(cmd);
577 for arg in source.get_arguments().filter(|arg| !arg.is_hide_set()) {
578 let _ = writeln!(buf, " {}", compact_plain_argument(arg));
579 }
580
581 for sub in visible_subcommands(cmd) {
582 if !buf.ends_with('\n') {
583 let _ = writeln!(buf);
584 }
585 render_help_recursive_plain(sub, &cmd_path, buf);
586 }
587}
588
589#[cfg(feature = "cli-help")]
590fn compact_usage(cmd: &clap::Command, path: &str) -> String {
591 let rendered = cmd.clone().render_usage().to_string();
592 let usage = rendered.strip_prefix("Usage: ").unwrap_or(&rendered);
593 let invocation = cmd.get_bin_name().unwrap_or(cmd.get_name());
594 usage.strip_prefix(invocation).map_or_else(
595 || format!("{path} {usage}"),
596 |suffix| format!("{path}{suffix}"),
597 )
598}
599
600#[cfg(feature = "cli-help")]
601fn compact_usage_suffix(cmd: &clap::Command) -> String {
602 let rendered = cmd.clone().render_usage().to_string();
603 let usage = rendered.strip_prefix("Usage: ").unwrap_or(&rendered);
604 usage
605 .strip_prefix(cmd.get_bin_name().unwrap_or(cmd.get_name()))
606 .unwrap_or(usage)
607 .trim()
608 .to_string()
609}
610
611#[cfg(feature = "cli-help")]
612fn compact_plain_argument(arg: &clap::Arg) -> String {
613 use std::fmt::Write;
614
615 let mut rendered = String::new();
616 if let Some(short) = arg.get_short() {
617 let _ = write!(rendered, "-{short}");
618 if arg.get_long().is_some() {
619 rendered.push_str(", ");
620 }
621 }
622 if let Some(long) = arg.get_long() {
623 let _ = write!(rendered, "--{long}");
624 } else if arg.get_short().is_none() {
625 rendered.push_str(
626 &arg.get_value_names()
627 .and_then(|names| names.first())
628 .map_or_else(|| arg.get_id().to_string(), ToString::to_string),
629 );
630 }
631 if argument_takes_values(arg)
632 && let Some(names) = arg.get_value_names()
633 && (arg.get_long().is_some() || arg.get_short().is_some())
634 {
635 for name in names {
636 let _ = write!(rendered, " <{name}>");
637 }
638 }
639 if matches!(
640 arg.get_action(),
641 clap::ArgAction::Append | clap::ArgAction::Count
642 ) {
643 rendered.push_str("...");
644 }
645 let defaults = redacted_default_values(arg);
646 if !defaults.is_empty() {
647 let _ = write!(rendered, " [default={}]", defaults.join(","));
648 }
649 if let Some(help) = arg.get_help() {
650 let _ = write!(rendered, ": {help}");
651 }
652 rendered
653}
654
655#[cfg(feature = "cli-help")]
656fn render_help_markdown(cmd: &clap::Command, names: &[String], scope: HelpScope) -> String {
657 let mut buf = String::new();
658 render_markdown_command(cmd, names, &mut buf, 1, true);
659 if matches!(scope, HelpScope::Recursive) {
660 render_markdown_descendants(cmd, names, &mut buf, 2);
661 }
662 buf
663}
664
665#[cfg(feature = "cli-help")]
666fn render_markdown_descendants(
667 cmd: &clap::Command,
668 parent_names: &[String],
669 buf: &mut String,
670 level: usize,
671) {
672 for sub in cmd.get_subcommands() {
673 if sub.get_name() == "help" || sub.is_hide_set() {
674 continue;
675 }
676 let mut names = parent_names.to_vec();
677 names.push(sub.get_name().to_string());
678 render_markdown_command(sub, &names, buf, level, false);
679 render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
680 }
681}
682
683#[cfg(feature = "cli-help")]
684fn render_markdown_command(
685 cmd: &clap::Command,
686 names: &[String],
687 buf: &mut String,
688 level: usize,
689 enrich: bool,
690) {
691 use std::fmt::Write;
692
693 if !buf.is_empty() {
694 let _ = writeln!(buf);
695 }
696 let heading_level = "#".repeat(level.max(1));
697 let path = names.join(" ");
701 if let Some(about) = cmd.get_about() {
702 let _ = writeln!(buf, "{heading_level} {path} - {about}");
703 } else {
704 let _ = writeln!(buf, "{heading_level} {path}");
705 }
706 if let Some(long_about) = markdown_long_about(cmd) {
710 let _ = writeln!(buf);
711 write_trimmed_help(buf, &long_about);
712 }
713
714 let usage_suffix = compact_usage_suffix(cmd);
715 let _ = writeln!(buf);
716 if usage_suffix.is_empty() {
717 let _ = writeln!(buf, "```text\n{path}\n```");
718 } else {
719 let _ = writeln!(buf, "```text\n{path} {usage_suffix}\n```");
720 }
721
722 let arguments = command_arguments_schema(cmd, enrich);
727 if !arguments.is_empty() {
728 let _ = writeln!(buf);
729 let _ = writeln!(buf, "| Argument | Description |");
730 let _ = writeln!(buf, "| --- | --- |");
731 for argument in &arguments {
732 let _ = writeln!(
733 buf,
734 "| {} | {} |",
735 markdown_argument_signature(argument),
736 markdown_argument_description(argument)
737 );
738 }
739 }
740
741 let subcommands: Vec<&clap::Command> = visible_subcommands(cmd).collect();
742 if !subcommands.is_empty() {
743 let _ = writeln!(buf);
744 let _ = writeln!(buf, "| Command | Summary |");
745 let _ = writeln!(buf, "| --- | --- |");
746 for sub in subcommands {
747 let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
748 let _ = writeln!(
749 buf,
750 "| `{} {}` | {} |",
751 path,
752 sub.get_name(),
753 markdown_cell(&about)
754 );
755 }
756 }
757}
758
759#[cfg(feature = "cli-help")]
761fn markdown_argument_signature(argument: &Value) -> String {
762 let name = argument["name"].as_str().unwrap_or_default();
763 let mut rendered = String::from(name);
764 if let Some(short) = argument.get("short").and_then(Value::as_str) {
765 rendered.push_str(", ");
766 rendered.push_str(short);
767 }
768 if let Some(value) = argument.get("value").and_then(Value::as_str) {
769 rendered.push_str(&format!(" <{value}>"));
770 } else if let Some(values) = argument.get("values").and_then(Value::as_array) {
771 let placeholders: Vec<String> = values
774 .iter()
775 .filter_map(Value::as_str)
776 .filter(|value| *value != name)
777 .map(|value| format!(" <{value}>"))
778 .collect();
779 rendered.push_str(&placeholders.join(""));
780 }
781 if argument.get("repeatable").and_then(Value::as_bool) == Some(true) {
782 rendered.push_str("...");
783 }
784 format!("`{}`", markdown_cell(&rendered))
785}
786
787#[cfg(feature = "cli-help")]
789fn markdown_argument_description(argument: &Value) -> String {
790 let mut parts = Vec::new();
791 if let Some(help) = argument.get("help").and_then(Value::as_str) {
792 parts.push(markdown_cell(help));
793 }
794 let mut notes = Vec::new();
795 if argument.get("required").and_then(Value::as_bool) == Some(true) {
796 notes.push("required".to_string());
797 }
798 if argument.get("global").and_then(Value::as_bool) == Some(true) {
799 notes.push("global".to_string());
800 }
801 if let Some(default) = argument.get("default").and_then(Value::as_str) {
802 notes.push(format!("default: `{}`", markdown_cell(default)));
803 } else if let Some(defaults) = argument.get("defaults").and_then(Value::as_array) {
804 let joined: Vec<String> = defaults
805 .iter()
806 .filter_map(Value::as_str)
807 .map(|value| format!("`{}`", markdown_cell(value)))
808 .collect();
809 if !joined.is_empty() {
810 notes.push(format!("defaults: {}", joined.join(", ")));
811 }
812 }
813 if !notes.is_empty() {
814 parts.push(format!("*({})*", notes.join(", ")));
815 }
816 if parts.is_empty() {
817 String::new()
818 } else {
819 parts.join(" ")
820 }
821}
822
823#[cfg(feature = "cli-help")]
825fn markdown_cell(text: &str) -> String {
826 text.replace('|', "\\|").replace(['\n', '\r'], " ")
827}
828
829#[cfg(feature = "cli-help")]
830fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
831 let long_about = cmd.get_long_about()?.to_string();
832 let rendered = match cmd.get_about() {
833 Some(about) => {
834 let about_str = about.to_string();
835 if long_about.trim() == format!("{} - {}", cmd.get_name(), about_str) {
836 return None;
837 }
838 strip_leading_about_paragraph(&long_about, &about_str)
839 }
840 None => long_about.as_str(),
841 };
842 let rendered = rendered.trim_matches(['\r', '\n']);
843 if rendered.is_empty() {
844 None
845 } else {
846 Some(rendered.to_string())
847 }
848}
849
850#[cfg(feature = "cli-help")]
851fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
852 let long_about = long_about.trim_start_matches(['\r', '\n']);
853 let Some(rest) = long_about.strip_prefix(about) else {
854 return long_about;
855 };
856 if rest.is_empty() {
857 return "";
858 }
859 rest.strip_prefix("\r\n\r\n")
860 .or_else(|| rest.strip_prefix("\n\n"))
861 .unwrap_or(long_about)
862}
863
864#[cfg(feature = "cli-help")]
865fn write_trimmed_help(buf: &mut String, help: &str) {
866 use std::fmt::Write;
867
868 for line in help.lines() {
869 let _ = writeln!(buf, "{}", line.trim_end());
870 }
871}
872
873#[cfg(feature = "cli-help")]
874struct ParsedHelpRequest {
875 help_requested: bool,
876 recursive_requested: bool,
877 output_format: Option<HelpFormat>,
878 output_error: Option<String>,
879 subcommand_path: Vec<String>,
880}
881
882#[cfg(feature = "cli-help")]
883fn parse_help_request(raw_args: &[String], cmd: &clap::Command) -> ParsedHelpRequest {
884 let args = crate::cli::strip_argv0(raw_args);
885 let mut help_requested = false;
886 let mut recursive_requested = false;
887 let mut output_format = None;
888 let mut output_error = None;
889 let mut subcommand_path = Vec::new();
890 let mut current = cmd;
891
892 let mut i = 0usize;
893 while i < args.len() {
894 let arg = args[i].as_str();
895 if arg == "--" {
896 break;
897 }
898
899 let (flag_name, inline_value) = split_flag(arg);
900 if arg == "--help" {
906 help_requested = true;
907 i += 1;
908 continue;
909 }
910 if arg == "--recursive" {
915 recursive_requested = true;
916 i += 1;
917 continue;
918 }
919 if flag_name == Some("output") {
920 let value = inline_value.or_else(|| {
921 args.get(i + 1)
922 .map(String::as_str)
923 .filter(|next| !next.starts_with('-'))
924 });
925 if let Some(value) = value {
926 match HelpFormat::parse(value) {
927 Some(format) => set_help_output_format(
928 &mut output_format,
929 format,
930 &format!("--output {value}"),
931 &mut output_error,
932 ),
933 None => {
934 output_error = Some(format!(
935 "invalid --output format '{value}': expected plain, json, yaml, or markdown"
936 ));
937 }
938 }
939 } else {
940 output_error = Some(
941 "missing value for --output: expected plain, json, yaml, or markdown"
942 .to_string(),
943 );
944 }
945 i += if inline_value.is_some() || value.is_none() {
946 1
947 } else {
948 2
949 };
950 continue;
951 }
952 if arg.starts_with('-') {
953 i += if inline_value.is_none() && flag_takes_value(current, arg) {
954 2
955 } else {
956 1
957 };
958 continue;
959 }
960 if let Some(sub) = current.find_subcommand(arg)
961 && sub.get_name() != "help"
962 && !sub.is_hide_set()
963 {
964 subcommand_path.push(sub.get_name().to_string());
965 current = sub;
966 }
967 i += 1;
968 }
969
970 ParsedHelpRequest {
971 help_requested,
972 recursive_requested,
973 output_format,
974 output_error,
975 subcommand_path,
976 }
977}
978
979#[cfg(feature = "cli-help")]
980fn set_help_output_format(
981 current: &mut Option<HelpFormat>,
982 next: HelpFormat,
983 source: &str,
984 output_error: &mut Option<String>,
985) {
986 if let Some(existing) = current
987 && *existing != next
988 {
989 *output_error = Some(format!(
990 "conflicting output formats: {source} conflicts with previous output format"
991 ));
992 return;
993 }
994 *current = Some(next);
995}
996
997fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
998 if let Some(stripped) = arg.strip_prefix("--") {
999 if let Some((name, value)) = stripped.split_once('=') {
1000 (Some(name), Some(value))
1001 } else {
1002 (Some(stripped), None)
1003 }
1004 } else if let Some(stripped) = arg.strip_prefix('-') {
1005 (Some(stripped), None)
1006 } else {
1007 (None, None)
1008 }
1009}
1010
1011#[cfg(feature = "cli-help")]
1012fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1013 let Some(flag) = raw_flag.strip_prefix('-') else {
1014 return false;
1015 };
1016 let name = flag.trim_start_matches('-');
1017 cmd.get_arguments().any(|arg| {
1018 let long_matches = arg.get_long().is_some_and(|long| long == name);
1019 let short_matches =
1020 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1021 (long_matches || short_matches)
1022 && matches!(
1023 arg.get_action(),
1024 clap::ArgAction::Set | clap::ArgAction::Append
1025 )
1026 })
1027}
1028
1029#[cfg(feature = "cli-help")]
1030fn build_selected_help_schema(selected: &ScopedHelpCommand, scope: HelpScope) -> Value {
1031 let mut schema = command_schema(
1032 &selected.local_command,
1033 matches!(scope, HelpScope::Recursive),
1034 true,
1035 );
1036 if let Value::Object(map) = &mut schema {
1037 map.insert(
1038 "scope".to_string(),
1039 Value::String(help_scope_tag(scope).to_string()),
1040 );
1041 map.insert(
1042 "command_path".to_string(),
1043 Value::String(selected.command_path.clone()),
1044 );
1045 if !selected.inherited_arguments_from.is_empty() {
1046 map.insert(
1047 "inherited_arguments_from".to_string(),
1048 Value::Array(
1049 selected
1050 .inherited_arguments_from
1051 .iter()
1052 .cloned()
1053 .map(Value::String)
1054 .collect(),
1055 ),
1056 );
1057 }
1058 }
1059 schema
1060}
1061
1062#[cfg(feature = "cli-help")]
1063fn help_scope_tag(scope: HelpScope) -> &'static str {
1064 match scope {
1065 HelpScope::OneLevel => "one_level",
1066 HelpScope::Recursive => "recursive",
1067 }
1068}
1069
1070#[cfg(feature = "cli-help")]
1071fn command_schema(cmd: &clap::Command, recursive: bool, enrich: bool) -> Value {
1072 let subcommands: Vec<Value> = visible_subcommands(cmd)
1073 .map(|sub| {
1074 if recursive {
1075 command_schema(sub, true, false)
1077 } else {
1078 command_summary_schema(sub)
1079 }
1080 })
1081 .collect();
1082
1083 let mut schema = serde_json::Map::new();
1084 schema.insert(
1085 "name".to_string(),
1086 Value::String(cmd.get_name().to_string()),
1087 );
1088 insert_non_empty(
1089 &mut schema,
1090 "about",
1091 cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1092 );
1093 let usage = compact_usage_suffix(cmd);
1094 if !usage.is_empty() {
1095 schema.insert("usage".to_string(), Value::String(usage));
1096 }
1097 let arguments = command_arguments_schema(cmd, enrich);
1098 if !arguments.is_empty() {
1099 schema.insert("arguments".to_string(), Value::Array(arguments));
1100 }
1101 if !subcommands.is_empty() {
1102 schema.insert("subcommands".to_string(), Value::Array(subcommands));
1103 }
1104 Value::Object(schema)
1105}
1106
1107#[cfg(feature = "cli-help")]
1108fn command_summary_schema(cmd: &clap::Command) -> Value {
1109 let mut schema = serde_json::Map::new();
1110 schema.insert(
1111 "name".to_string(),
1112 Value::String(cmd.get_name().to_string()),
1113 );
1114 insert_non_empty(
1115 &mut schema,
1116 "about",
1117 cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1118 );
1119 Value::Object(schema)
1120}
1121
1122#[cfg(feature = "cli-help")]
1123fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1124 cmd.get_subcommands()
1125 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1126}
1127
1128#[cfg(feature = "cli-help")]
1129fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1130 let owned = enrich.then(|| enriched_help_command(cmd));
1136 let source = owned.as_ref().unwrap_or(cmd);
1137 source
1138 .get_arguments()
1139 .filter(|arg| !arg.is_hide_set())
1140 .map(argument_schema)
1141 .collect()
1142}
1143
1144#[cfg(feature = "cli-help")]
1151fn insert_non_empty(schema: &mut serde_json::Map<String, Value>, key: &str, value: String) {
1152 if !value.is_empty() {
1153 schema.insert(key.to_string(), Value::String(value));
1154 }
1155}
1156
1157#[cfg(feature = "cli-help")]
1158fn argument_schema(arg: &clap::Arg) -> Value {
1159 let mut schema = serde_json::Map::new();
1160 if let Some(long) = arg.get_long() {
1161 schema.insert("name".to_string(), Value::String(format!("--{long}")));
1162 } else if let Some(short) = arg.get_short() {
1163 schema.insert("name".to_string(), Value::String(format!("-{short}")));
1164 } else {
1165 schema.insert(
1166 "name".to_string(),
1167 Value::String(
1168 arg.get_value_names()
1169 .and_then(|names| names.first())
1170 .map_or_else(|| arg.get_id().to_string(), ToString::to_string),
1171 ),
1172 );
1173 }
1174 if arg.get_long().is_some()
1175 && let Some(short) = arg.get_short()
1176 {
1177 schema.insert("short".to_string(), Value::String(format!("-{short}")));
1178 }
1179 insert_non_empty(
1180 &mut schema,
1181 "help",
1182 arg.get_help().map(ToString::to_string).unwrap_or_default(),
1183 );
1184 if arg.is_required_set() {
1185 schema.insert("required".to_string(), Value::Bool(true));
1186 }
1187 if arg.is_global_set() {
1188 schema.insert("global".to_string(), Value::Bool(true));
1189 }
1190 if matches!(
1191 arg.get_action(),
1192 clap::ArgAction::Append | clap::ArgAction::Count
1193 ) {
1194 schema.insert("repeatable".to_string(), Value::Bool(true));
1195 }
1196 if arg.get_long().is_none()
1201 && arg.get_short().is_none()
1202 && let Some(names) = arg.get_value_names()
1203 && names.len() > 1
1204 {
1205 schema.insert(
1206 "values".to_string(),
1207 Value::Array(
1208 names
1209 .iter()
1210 .map(ToString::to_string)
1211 .filter(|name| !name.is_empty())
1212 .map(Value::String)
1213 .collect(),
1214 ),
1215 );
1216 }
1217 if (arg.get_long().is_some() || arg.get_short().is_some())
1218 && argument_takes_values(arg)
1219 && let Some(names) = arg.get_value_names()
1220 {
1221 if names.len() == 1 {
1222 insert_non_empty(&mut schema, "value", names[0].to_string());
1223 } else {
1224 let values: Vec<Value> = names
1225 .iter()
1226 .map(ToString::to_string)
1227 .filter(|name| !name.is_empty())
1228 .map(Value::String)
1229 .collect();
1230 if !values.is_empty() {
1231 schema.insert("values".to_string(), Value::Array(values));
1232 }
1233 }
1234 }
1235 let defaults = redacted_default_values(arg);
1236 if !defaults.is_empty() {
1237 if defaults.len() == 1 {
1238 schema.insert("default".to_string(), Value::String(defaults[0].clone()));
1239 } else {
1240 schema.insert(
1241 "defaults".to_string(),
1242 Value::Array(defaults.into_iter().map(Value::String).collect()),
1243 );
1244 }
1245 }
1246 Value::Object(schema)
1247}
1248
1249#[cfg(feature = "cli-help")]
1250fn argument_takes_values(arg: &clap::Arg) -> bool {
1251 matches!(
1252 arg.get_action(),
1253 clap::ArgAction::Set | clap::ArgAction::Append
1254 )
1255}
1256
1257#[cfg(feature = "cli-help")]
1258fn redacted_default_values(arg: &clap::Arg) -> Vec<String> {
1259 if arg.is_hide_default_value_set() {
1262 return Vec::new();
1263 }
1264 arg.get_default_values()
1265 .iter()
1266 .map(|value| {
1267 if help_arg_is_secret(arg, &RedactionContext::default()) {
1268 "***".to_string()
1269 } else {
1270 value.to_string_lossy().to_string()
1271 }
1272 })
1273 .collect()
1274}