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 cmd = redact_secret_help_defaults(cmd.clone()).disable_help_subcommand(true);
507 let description = if visible_subcommands(&cmd).next().is_some() {
508 HELP_FLAG_WITH_SUBCOMMANDS
509 } else {
510 HELP_FLAG_LEAF
511 };
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) {
707 let _ = writeln!(buf);
708 write_trimmed_help(buf, &long_about);
709 }
710 let _ = writeln!(buf);
711 let _ = writeln!(buf, "```text");
712 let help = markdown_help_block_command(cmd, enrich)
713 .bin_name(path.clone())
714 .render_long_help();
715 write_trimmed_help(buf, &help.to_string());
716 if !buf.ends_with('\n') {
717 let _ = writeln!(buf);
718 }
719 let _ = writeln!(buf, "```");
720}
721
722#[cfg(feature = "cli-help")]
723fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
724 let long_about = cmd.get_long_about()?.to_string();
725 let rendered = match cmd.get_about() {
726 Some(about) => {
727 let about_str = about.to_string();
728 if long_about.trim() == format!("{} - {}", cmd.get_name(), about_str) {
729 return None;
730 }
731 strip_leading_about_paragraph(&long_about, &about_str)
732 }
733 None => long_about.as_str(),
734 };
735 let rendered = rendered.trim_matches(['\r', '\n']);
736 if rendered.is_empty() {
737 None
738 } else {
739 Some(rendered.to_string())
740 }
741}
742
743#[cfg(feature = "cli-help")]
744fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
745 let long_about = long_about.trim_start_matches(['\r', '\n']);
746 let Some(rest) = long_about.strip_prefix(about) else {
747 return long_about;
748 };
749 if rest.is_empty() {
750 return "";
751 }
752 rest.strip_prefix("\r\n\r\n")
753 .or_else(|| rest.strip_prefix("\n\n"))
754 .unwrap_or(long_about)
755}
756
757#[cfg(feature = "cli-help")]
758fn markdown_help_block_command(cmd: &clap::Command, enrich: bool) -> clap::Command {
759 let cmd = if enrich {
760 enriched_help_command(cmd)
761 } else {
762 redact_secret_help_defaults(cmd.clone()).disable_help_subcommand(true)
763 };
764 cmd.about(None::<&str>).long_about(None::<&str>)
765}
766
767#[cfg(feature = "cli-help")]
768fn write_trimmed_help(buf: &mut String, help: &str) {
769 use std::fmt::Write;
770
771 for line in help.lines() {
772 let _ = writeln!(buf, "{}", line.trim_end());
773 }
774}
775
776#[cfg(feature = "cli-help")]
777struct ParsedHelpRequest {
778 help_requested: bool,
779 recursive_requested: bool,
780 output_format: Option<HelpFormat>,
781 output_error: Option<String>,
782 subcommand_path: Vec<String>,
783}
784
785#[cfg(feature = "cli-help")]
786fn parse_help_request(raw_args: &[String], cmd: &clap::Command) -> ParsedHelpRequest {
787 let args = match raw_args.first() {
788 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
789 _ => raw_args.get(1..).unwrap_or(&[]),
790 };
791 let mut help_requested = false;
792 let mut recursive_requested = false;
793 let mut output_format = None;
794 let mut output_error = None;
795 let mut subcommand_path = Vec::new();
796 let mut current = cmd;
797
798 let mut i = 0usize;
799 while i < args.len() {
800 let arg = args[i].as_str();
801 if arg == "--" {
802 break;
803 }
804
805 let (flag_name, inline_value) = split_flag(arg);
806 if arg == "--help" {
812 help_requested = true;
813 i += 1;
814 continue;
815 }
816 if arg == "--recursive" {
821 recursive_requested = true;
822 i += 1;
823 continue;
824 }
825 if arg == "--json" {
826 set_help_output_format(
827 &mut output_format,
828 HelpFormat::Json,
829 "--json",
830 &mut output_error,
831 );
832 i += 1;
833 continue;
834 }
835 if flag_name == Some("output") {
836 let value = inline_value.or_else(|| {
837 args.get(i + 1)
838 .map(String::as_str)
839 .filter(|next| !next.starts_with('-'))
840 });
841 if let Some(value) = value {
842 match HelpFormat::parse(value) {
843 Some(format) => set_help_output_format(
844 &mut output_format,
845 format,
846 &format!("--output {value}"),
847 &mut output_error,
848 ),
849 None => {
850 output_error = Some(format!(
851 "invalid --output format '{value}': expected plain, json, yaml, or markdown"
852 ));
853 }
854 }
855 } else {
856 output_error = Some(
857 "missing value for --output: expected plain, json, yaml, or markdown"
858 .to_string(),
859 );
860 }
861 i += if inline_value.is_some() || value.is_none() {
862 1
863 } else {
864 2
865 };
866 continue;
867 }
868 if arg.starts_with('-') {
869 i += if inline_value.is_none() && flag_takes_value(current, arg) {
870 2
871 } else {
872 1
873 };
874 continue;
875 }
876 if let Some(sub) = current.find_subcommand(arg)
877 && sub.get_name() != "help"
878 && !sub.is_hide_set()
879 {
880 subcommand_path.push(sub.get_name().to_string());
881 current = sub;
882 }
883 i += 1;
884 }
885
886 ParsedHelpRequest {
887 help_requested,
888 recursive_requested,
889 output_format,
890 output_error,
891 subcommand_path,
892 }
893}
894
895#[cfg(feature = "cli-help")]
896fn set_help_output_format(
897 current: &mut Option<HelpFormat>,
898 next: HelpFormat,
899 source: &str,
900 output_error: &mut Option<String>,
901) {
902 if let Some(existing) = current
903 && *existing != next
904 {
905 *output_error = Some(format!(
906 "conflicting output formats: {source} conflicts with previous output format"
907 ));
908 return;
909 }
910 *current = Some(next);
911}
912
913fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
914 if let Some(stripped) = arg.strip_prefix("--") {
915 if let Some((name, value)) = stripped.split_once('=') {
916 (Some(name), Some(value))
917 } else {
918 (Some(stripped), None)
919 }
920 } else if let Some(stripped) = arg.strip_prefix('-') {
921 (Some(stripped), None)
922 } else {
923 (None, None)
924 }
925}
926
927#[cfg(feature = "cli-help")]
928fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
929 let Some(flag) = raw_flag.strip_prefix('-') else {
930 return false;
931 };
932 let name = flag.trim_start_matches('-');
933 cmd.get_arguments().any(|arg| {
934 let long_matches = arg.get_long().is_some_and(|long| long == name);
935 let short_matches =
936 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
937 (long_matches || short_matches)
938 && matches!(
939 arg.get_action(),
940 clap::ArgAction::Set | clap::ArgAction::Append
941 )
942 })
943}
944
945#[cfg(feature = "cli-help")]
946fn build_selected_help_schema(selected: &ScopedHelpCommand, scope: HelpScope) -> Value {
947 let mut schema = command_schema(
948 &selected.local_command,
949 matches!(scope, HelpScope::Recursive),
950 true,
951 );
952 if let Value::Object(map) = &mut schema {
953 map.insert(
954 "scope".to_string(),
955 Value::String(help_scope_tag(scope).to_string()),
956 );
957 map.insert(
958 "command_path".to_string(),
959 Value::String(selected.command_path.clone()),
960 );
961 if !selected.inherited_arguments_from.is_empty() {
962 map.insert(
963 "inherited_arguments_from".to_string(),
964 Value::Array(
965 selected
966 .inherited_arguments_from
967 .iter()
968 .cloned()
969 .map(Value::String)
970 .collect(),
971 ),
972 );
973 }
974 }
975 schema
976}
977
978#[cfg(feature = "cli-help")]
979fn help_scope_tag(scope: HelpScope) -> &'static str {
980 match scope {
981 HelpScope::OneLevel => "one_level",
982 HelpScope::Recursive => "recursive",
983 }
984}
985
986#[cfg(feature = "cli-help")]
987fn command_schema(cmd: &clap::Command, recursive: bool, enrich: bool) -> Value {
988 let subcommands: Vec<Value> = visible_subcommands(cmd)
989 .map(|sub| {
990 if recursive {
991 command_schema(sub, true, false)
993 } else {
994 command_summary_schema(sub)
995 }
996 })
997 .collect();
998
999 let mut schema = serde_json::Map::new();
1000 schema.insert(
1001 "name".to_string(),
1002 Value::String(cmd.get_name().to_string()),
1003 );
1004 insert_non_empty(
1005 &mut schema,
1006 "about",
1007 cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1008 );
1009 let usage = compact_usage_suffix(cmd);
1010 if !usage.is_empty() {
1011 schema.insert("usage".to_string(), Value::String(usage));
1012 }
1013 let arguments = command_arguments_schema(cmd, enrich);
1014 if !arguments.is_empty() {
1015 schema.insert("arguments".to_string(), Value::Array(arguments));
1016 }
1017 if !subcommands.is_empty() {
1018 schema.insert("subcommands".to_string(), Value::Array(subcommands));
1019 }
1020 Value::Object(schema)
1021}
1022
1023#[cfg(feature = "cli-help")]
1024fn command_summary_schema(cmd: &clap::Command) -> Value {
1025 let mut schema = serde_json::Map::new();
1026 schema.insert(
1027 "name".to_string(),
1028 Value::String(cmd.get_name().to_string()),
1029 );
1030 insert_non_empty(
1031 &mut schema,
1032 "about",
1033 cmd.get_about().map(ToString::to_string).unwrap_or_default(),
1034 );
1035 Value::Object(schema)
1036}
1037
1038#[cfg(feature = "cli-help")]
1039fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1040 cmd.get_subcommands()
1041 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1042}
1043
1044#[cfg(feature = "cli-help")]
1045fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1046 let owned = enrich.then(|| enriched_help_command(cmd));
1052 let source = owned.as_ref().unwrap_or(cmd);
1053 source
1054 .get_arguments()
1055 .filter(|arg| !arg.is_hide_set())
1056 .map(argument_schema)
1057 .collect()
1058}
1059
1060#[cfg(feature = "cli-help")]
1067fn insert_non_empty(schema: &mut serde_json::Map<String, Value>, key: &str, value: String) {
1068 if !value.is_empty() {
1069 schema.insert(key.to_string(), Value::String(value));
1070 }
1071}
1072
1073#[cfg(feature = "cli-help")]
1074fn argument_schema(arg: &clap::Arg) -> Value {
1075 let mut schema = serde_json::Map::new();
1076 if let Some(long) = arg.get_long() {
1077 schema.insert("name".to_string(), Value::String(format!("--{long}")));
1078 } else if let Some(short) = arg.get_short() {
1079 schema.insert("name".to_string(), Value::String(format!("-{short}")));
1080 } else {
1081 schema.insert(
1082 "name".to_string(),
1083 Value::String(
1084 arg.get_value_names()
1085 .and_then(|names| names.first())
1086 .map_or_else(|| arg.get_id().to_string(), ToString::to_string),
1087 ),
1088 );
1089 }
1090 if arg.get_long().is_some()
1091 && let Some(short) = arg.get_short()
1092 {
1093 schema.insert("short".to_string(), Value::String(format!("-{short}")));
1094 }
1095 insert_non_empty(
1096 &mut schema,
1097 "help",
1098 arg.get_help().map(ToString::to_string).unwrap_or_default(),
1099 );
1100 if arg.is_required_set() {
1101 schema.insert("required".to_string(), Value::Bool(true));
1102 }
1103 if arg.is_global_set() {
1104 schema.insert("global".to_string(), Value::Bool(true));
1105 }
1106 if matches!(
1107 arg.get_action(),
1108 clap::ArgAction::Append | clap::ArgAction::Count
1109 ) {
1110 schema.insert("repeatable".to_string(), Value::Bool(true));
1111 }
1112 if (arg.get_long().is_some() || arg.get_short().is_some())
1113 && argument_takes_values(arg)
1114 && let Some(names) = arg.get_value_names()
1115 {
1116 if names.len() == 1 {
1117 insert_non_empty(&mut schema, "value", names[0].to_string());
1118 } else {
1119 let values: Vec<Value> = names
1120 .iter()
1121 .map(ToString::to_string)
1122 .filter(|name| !name.is_empty())
1123 .map(Value::String)
1124 .collect();
1125 if !values.is_empty() {
1126 schema.insert("values".to_string(), Value::Array(values));
1127 }
1128 }
1129 }
1130 let defaults = redacted_default_values(arg);
1131 if !defaults.is_empty() {
1132 if defaults.len() == 1 {
1133 schema.insert("default".to_string(), Value::String(defaults[0].clone()));
1134 } else {
1135 schema.insert(
1136 "defaults".to_string(),
1137 Value::Array(defaults.into_iter().map(Value::String).collect()),
1138 );
1139 }
1140 }
1141 Value::Object(schema)
1142}
1143
1144#[cfg(feature = "cli-help")]
1145fn argument_takes_values(arg: &clap::Arg) -> bool {
1146 matches!(
1147 arg.get_action(),
1148 clap::ArgAction::Set | clap::ArgAction::Append
1149 )
1150}
1151
1152#[cfg(feature = "cli-help")]
1153fn redacted_default_values(arg: &clap::Arg) -> Vec<String> {
1154 if arg.is_hide_default_value_set() {
1157 return Vec::new();
1158 }
1159 arg.get_default_values()
1160 .iter()
1161 .map(|value| {
1162 if help_arg_is_secret(arg, &RedactionContext::default()) {
1163 "***".to_string()
1164 } else {
1165 value.to_string_lossy().to_string()
1166 }
1167 })
1168 .collect()
1169}