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,
95}
96
97#[cfg(feature = "cli-help")]
98impl HelpConfig {
99 pub const fn human_cli_default() -> Self {
107 Self {
108 default_scope: HelpScope::OneLevel,
109 default_format: HelpFormat::Plain,
110 }
111 }
112}
113
114#[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 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#[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#[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#[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 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 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#[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 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#[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#[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 let mut cmd_path = parent_path.to_vec();
395 cmd_path.push(cmd.get_name());
396 let path_str = cmd_path.join(" ");
397
398 if !buf.is_empty() {
400 let _ = writeln!(buf);
401 let _ = writeln!(buf, "{}", "═".repeat(60));
402 }
403
404 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 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 for sub in cmd.get_subcommands() {
426 if sub.get_name() == "help" || sub.is_hide_set() {
427 continue; }
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 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 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 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}