1use crate::model::Command;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Shell {
33 Bash,
35 Zsh,
37 Fish,
39}
40
41pub trait Renderer: Send + Sync {
71 fn render_help(&self, command: &crate::model::Command) -> String;
73 fn render_markdown(&self, command: &crate::model::Command) -> String;
75 fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String;
77 fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String;
79 fn render_docs(&self, registry: &crate::query::Registry) -> String {
84 render_docs(registry)
85 }
86}
87
88#[derive(Debug, Default, Clone)]
92pub struct DefaultRenderer;
93
94impl Renderer for DefaultRenderer {
95 fn render_help(&self, command: &crate::model::Command) -> String {
96 render_help(command)
97 }
98 fn render_markdown(&self, command: &crate::model::Command) -> String {
99 render_markdown(command)
100 }
101 fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String {
102 render_subcommand_list(commands)
103 }
104 fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String {
105 render_ambiguity(input, candidates)
106 }
107 fn render_docs(&self, registry: &crate::query::Registry) -> String {
108 render_docs(registry)
109 }
110}
111
112pub fn render_help(command: &Command) -> String {
137 let mut out = String::new();
138
139 let name_line = if command.aliases.is_empty() {
141 command.canonical.clone()
142 } else {
143 format!("{} ({})", command.canonical, command.aliases.join(", "))
144 };
145 out.push_str(&format!("NAME\n {}\n\n", name_line));
146
147 if !command.summary.is_empty() {
148 out.push_str(&format!("SUMMARY\n {}\n\n", command.summary));
149 }
150
151 if !command.description.is_empty() {
152 out.push_str(&format!("DESCRIPTION\n {}\n\n", command.description));
153 }
154
155 out.push_str(&format!("USAGE\n {}\n\n", build_usage(command)));
156
157 if !command.arguments.is_empty() {
158 out.push_str("ARGUMENTS\n");
159 for arg in &command.arguments {
160 let req = if arg.required { " (required)" } else { "" };
161 out.push_str(&format!(" <{}> {}{}\n", arg.name, arg.description, req));
162 }
163 out.push('\n');
164 }
165
166 if !command.flags.is_empty() {
167 out.push_str("FLAGS\n");
168 for flag in &command.flags {
169 let short_part = flag.short.map(|c| format!("-{}, ", c)).unwrap_or_default();
170 let req = if flag.required { " (required)" } else { "" };
171 out.push_str(&format!(
172 " {}--{} {}{}\n",
173 short_part, flag.name, flag.description, req
174 ));
175 }
176 out.push('\n');
177 }
178
179 if !command.subcommands.is_empty() {
180 out.push_str("SUBCOMMANDS\n");
181 for sub in &command.subcommands {
182 out.push_str(&format!(" {} {}\n", sub.canonical, sub.summary));
183 }
184 out.push('\n');
185 }
186
187 if !command.examples.is_empty() {
188 out.push_str("EXAMPLES\n");
189 for ex in &command.examples {
190 out.push_str(&format!(" # {}\n {}\n", ex.description, ex.command));
191 if let Some(output) = &ex.output {
192 out.push_str(&format!(" # Output: {}\n", output));
193 }
194 out.push('\n');
195 }
196 }
197
198 if !command.best_practices.is_empty() {
199 out.push_str("BEST PRACTICES\n");
200 for bp in &command.best_practices {
201 out.push_str(&format!(" - {}\n", bp));
202 }
203 out.push('\n');
204 }
205
206 if !command.anti_patterns.is_empty() {
207 out.push_str("ANTI-PATTERNS\n");
208 for ap in &command.anti_patterns {
209 out.push_str(&format!(" - {}\n", ap));
210 }
211 out.push('\n');
212 }
213
214 out
215}
216
217pub fn render_subcommand_list(commands: &[Command]) -> String {
240 let mut out = String::new();
241 for cmd in commands {
242 out.push_str(&format!(" {} {}\n", cmd.canonical, cmd.summary));
243 }
244 out
245}
246
247pub fn render_markdown(command: &Command) -> String {
276 let mut out = String::new();
277
278 out.push_str(&format!("# {}\n\n", command.canonical));
279
280 if !command.summary.is_empty() {
281 out.push_str(&format!("{}\n\n", command.summary));
282 }
283
284 if !command.description.is_empty() {
285 out.push_str(&format!("## Description\n\n{}\n\n", command.description));
286 }
287
288 out.push_str(&format!(
289 "## Usage\n\n```\n{}\n```\n\n",
290 build_usage(command)
291 ));
292
293 if !command.arguments.is_empty() {
294 out.push_str("## Arguments\n\n");
295 out.push_str("| Name | Description | Required |\n");
296 out.push_str("|------|-------------|----------|\n");
297 for arg in &command.arguments {
298 out.push_str(&format!(
299 "| `{}` | {} | {} |\n",
300 arg.name, arg.description, arg.required
301 ));
302 }
303 out.push('\n');
304 }
305
306 if !command.flags.is_empty() {
307 out.push_str("## Flags\n\n");
308 out.push_str("| Flag | Short | Description | Required |\n");
309 out.push_str("|------|-------|-------------|----------|\n");
310 for flag in &command.flags {
311 let short = flag.short.map(|c| format!("`-{}`", c)).unwrap_or_default();
312 out.push_str(&format!(
313 "| `--{}` | {} | {} | {} |\n",
314 flag.name, short, flag.description, flag.required
315 ));
316 }
317 out.push('\n');
318 }
319
320 if !command.subcommands.is_empty() {
321 out.push_str("## Subcommands\n\n");
322 for sub in &command.subcommands {
323 out.push_str(&format!("- **{}**: {}\n", sub.canonical, sub.summary));
324 }
325 out.push('\n');
326 }
327
328 if !command.examples.is_empty() {
329 out.push_str("## Examples\n\n");
330 for ex in &command.examples {
331 out.push_str(&format!(
332 "### {}\n\n```\n{}\n```\n\n",
333 ex.description, ex.command
334 ));
335 }
336 }
337
338 if !command.best_practices.is_empty() {
339 out.push_str("## Best Practices\n\n");
340 for bp in &command.best_practices {
341 out.push_str(&format!("- {}\n", bp));
342 }
343 out.push('\n');
344 }
345
346 if !command.anti_patterns.is_empty() {
347 out.push_str("## Anti-Patterns\n\n");
348 for ap in &command.anti_patterns {
349 out.push_str(&format!("- {}\n", ap));
350 }
351 out.push('\n');
352 }
353
354 out
355}
356
357pub fn render_ambiguity(input: &str, candidates: &[String]) -> String {
378 let list = candidates
379 .iter()
380 .map(|c| format!(" - {}", c))
381 .collect::<Vec<_>>()
382 .join("\n");
383 format!(
384 "Ambiguous command \"{}\". Did you mean one of:\n{}",
385 input, list
386 )
387}
388
389pub fn render_resolve_error(error: &crate::resolver::ResolveError) -> String {
417 use crate::resolver::ResolveError;
418 match error {
419 ResolveError::Ambiguous { input, candidates } => render_ambiguity(input, candidates),
420 ResolveError::Unknown { input, suggestions } if !suggestions.is_empty() => format!(
421 "Unknown command: `{}`. Did you mean: {}?",
422 input,
423 suggestions.join(", ")
424 ),
425 ResolveError::Unknown { input, .. } => format!("Unknown command: `{}`", input),
426 }
427}
428
429pub fn render_completion(shell: Shell, program: &str, registry: &crate::query::Registry) -> String {
458 match shell {
459 Shell::Bash => render_completion_bash(program, registry),
460 Shell::Zsh => render_completion_zsh(program, registry),
461 Shell::Fish => render_completion_fish(program, registry),
462 }
463}
464
465fn render_completion_bash(program: &str, registry: &crate::query::Registry) -> String {
466 let func_name = format!("_{}_completions", program.replace('-', "_"));
467
468 let top_level: Vec<&str> = registry
470 .commands()
471 .iter()
472 .map(|c| c.canonical.as_str())
473 .collect();
474
475 let mut cmd_cases = String::new();
477 for entry in registry.iter_all_recursive() {
478 let cmd = entry.command;
479 let flags: Vec<String> = cmd.flags.iter().map(|f| format!("--{}", f.name)).collect();
480 if !flags.is_empty() {
481 let path_str = entry.path_str();
482 cmd_cases.push_str(&format!(
483 " {})\n COMPREPLY=($(compgen -W \"{}\" -- \"$cur\"))\n return\n ;;\n",
484 path_str,
485 flags.join(" ")
486 ));
487 }
488 }
489
490 format!(
491 r#"# {program} bash completion
492# Source this file or add to ~/.bashrc:
493# source <({program} completion bash)
494
495{func_name}() {{
496 local cur prev words cword
497 _init_completion 2>/dev/null || {{
498 cur="${{COMP_WORDS[COMP_CWORD]}}"
499 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
500 }}
501
502 local cmd="${{COMP_WORDS[1]}}"
503
504 case "$cmd" in
505{cmd_cases} *)
506 COMPREPLY=($(compgen -W "{top}" -- "$cur"))
507 ;;
508 esac
509}}
510
511complete -F {func_name} {program}
512"#,
513 program = program,
514 func_name = func_name,
515 cmd_cases = cmd_cases,
516 top = top_level.join(" "),
517 )
518}
519
520fn render_completion_zsh(program: &str, registry: &crate::query::Registry) -> String {
521 let mut commands_block = String::new();
522 for cmd in registry.commands() {
523 let desc = if cmd.summary.is_empty() {
524 &cmd.canonical
525 } else {
526 &cmd.summary
527 };
528 commands_block.push_str(&format!(" '{}:{}'\n", cmd.canonical, desc));
529 }
530
531 let mut subcommand_cases = String::new();
532 for entry in registry.iter_all_recursive() {
533 let cmd = entry.command;
534 if cmd.flags.is_empty() && cmd.arguments.is_empty() {
535 continue;
536 }
537 let mut args_spec = String::new();
538 for flag in &cmd.flags {
539 let desc = if flag.description.is_empty() {
540 flag.name.as_str()
541 } else {
542 flag.description.as_str()
543 };
544 if flag.takes_value {
545 args_spec.push_str(&format!(" '--{}[{}]:value:_default'\n", flag.name, desc));
546 } else {
547 args_spec.push_str(&format!(" '--{}[{}]'\n", flag.name, desc));
548 }
549 }
550 let path_str = entry.path_str().replace('.', "-");
551 subcommand_cases.push_str(&format!(
552 " ({path})\n _arguments \\\n{args} ;;\n",
553 path = path_str,
554 args = args_spec,
555 ));
556 }
557
558 format!(
559 r#"#compdef {program}
560# {program} zsh completion
561
562_{program}() {{
563 local state
564
565 _arguments \
566 '1: :{program}_commands' \
567 '*:: :->subcommand'
568
569 case $state in
570 subcommand)
571 case $words[1] in
572{subcases} esac
573 esac
574}}
575
576_{program}_commands() {{
577 local -a commands
578 commands=(
579{cmds} )
580 _describe 'command' commands
581}}
582
583_{program}
584"#,
585 program = program,
586 subcases = subcommand_cases,
587 cmds = commands_block,
588 )
589}
590
591fn render_completion_fish(program: &str, registry: &crate::query::Registry) -> String {
592 let mut lines = format!(
593 "# {program} fish completion\n# Add to ~/.config/fish/completions/{program}.fish\n\n"
594 );
595
596 for cmd in registry.commands() {
598 let desc = if cmd.summary.is_empty() {
599 String::new()
600 } else {
601 format!(" -d '{}'", cmd.summary.replace('\'', "\\'"))
602 };
603 lines.push_str(&format!(
604 "complete -c {program} -f -n '__fish_use_subcommand' -a '{}'{}\n",
605 cmd.canonical, desc
606 ));
607 }
608
609 lines.push('\n');
610
611 for entry in registry.iter_all_recursive() {
613 let cmd = entry.command;
614 let subcmd = &cmd.canonical;
615 for flag in &cmd.flags {
616 let desc = if flag.description.is_empty() {
617 String::new()
618 } else {
619 format!(" -d '{}'", flag.description.replace('\'', "\\'"))
620 };
621 let req = if flag.takes_value { " -r" } else { "" };
622 lines.push_str(&format!(
623 "complete -c {program} -n '__fish_seen_subcommand_from {subcmd}' -l '{name}'{req}{desc}\n",
624 program = program,
625 subcmd = subcmd,
626 name = flag.name,
627 req = req,
628 desc = desc,
629 ));
630 }
631 }
632
633 lines
634}
635
636pub fn render_json_schema(command: &Command) -> Result<String, serde_json::Error> {
670 use serde_json::{json, Map, Value};
671
672 let mut properties: Map<String, Value> = Map::new();
673 let mut required: Vec<Value> = Vec::new();
674
675 for arg in &command.arguments {
677 let mut prop = json!({
678 "type": "string",
679 });
680 if !arg.description.is_empty() {
681 prop["description"] = json!(arg.description);
682 }
683 if arg.variadic {
684 prop = json!({
685 "type": "array",
686 "items": { "type": "string" },
687 });
688 if !arg.description.is_empty() {
689 prop["description"] = json!(arg.description);
690 }
691 }
692 if arg.required {
693 required.push(json!(arg.name));
694 }
695 if let Some(ref default) = arg.default {
696 prop["default"] = json!(default);
697 }
698 properties.insert(arg.name.clone(), prop);
699 }
700
701 for flag in &command.flags {
703 let mut prop: Map<String, Value> = Map::new();
704
705 if !flag.description.is_empty() {
706 prop.insert("description".into(), json!(flag.description));
707 }
708
709 if flag.takes_value {
710 if let Some(ref choices) = flag.choices {
711 prop.insert("type".into(), json!("string"));
712 prop.insert(
713 "enum".into(),
714 Value::Array(choices.iter().map(|c| json!(c)).collect()),
715 );
716 } else {
717 prop.insert("type".into(), json!("string"));
718 }
719 if let Some(ref default) = flag.default {
720 prop.insert("default".into(), json!(default));
721 }
722 } else {
723 prop.insert("type".into(), json!("boolean"));
725 prop.insert("default".into(), json!(false));
726 }
727
728 if flag.required {
729 required.push(json!(flag.name));
730 }
731
732 properties.insert(flag.name.clone(), Value::Object(prop));
733 }
734
735 let mut schema = json!({
736 "$schema": "http://json-schema.org/draft-07/schema#",
737 "title": command.canonical,
738 "type": "object",
739 "properties": properties,
740 });
741
742 if !command.summary.is_empty() {
743 schema["description"] = json!(command.summary);
744 }
745
746 if !required.is_empty() {
747 schema["required"] = Value::Array(required);
748 }
749
750 serde_json::to_string_pretty(&schema)
751}
752
753pub fn render_docs(registry: &crate::query::Registry) -> String {
787 let entries = registry.iter_all_recursive();
788
789 let mut out = String::from("# Commands\n\n");
790
791 for entry in &entries {
793 let depth = entry.path.len();
794 let indent = " ".repeat(depth.saturating_sub(1));
795 let anchor = entry.path_str().replace('.', "-").to_lowercase();
796 let label = entry.path_str().replace('.', " ");
797 out.push_str(&format!("{}- [{}](#{})\n", indent, label, anchor));
798 }
799
800 for (i, entry) in entries.iter().enumerate() {
802 out.push_str("\n---\n\n");
803 out.push_str(&render_markdown(entry.command));
804 let _ = i; }
806
807 out
808}
809
810fn build_usage(command: &Command) -> String {
811 let mut parts = vec![command.canonical.clone()];
812 if !command.subcommands.is_empty() {
813 parts.push("<subcommand>".to_string());
814 }
815 for arg in &command.arguments {
816 if arg.required {
817 parts.push(format!("<{}>", arg.name));
818 } else {
819 parts.push(format!("[{}]", arg.name));
820 }
821 }
822 if !command.flags.is_empty() {
823 parts.push("[flags]".to_string());
824 }
825 parts.join(" ")
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831 use crate::model::{Argument, Command, Example, Flag};
832
833 fn full_command() -> Command {
834 Command::builder("deploy")
835 .alias("d")
836 .summary("Deploy the application")
837 .description("Deploys the app to the target environment.")
838 .argument(
839 Argument::builder("env")
840 .description("target environment")
841 .required()
842 .build()
843 .unwrap(),
844 )
845 .flag(
846 Flag::builder("dry-run")
847 .short('n')
848 .description("simulate only")
849 .build()
850 .unwrap(),
851 )
852 .subcommand(
853 Command::builder("rollback")
854 .summary("Roll back")
855 .build()
856 .unwrap(),
857 )
858 .example(Example::new("deploy to prod", "deploy prod").with_output("deployed"))
859 .best_practice("always dry-run first")
860 .anti_pattern("deploy on Friday")
861 .build()
862 .unwrap()
863 }
864
865 #[test]
866 fn test_render_help_contains_all_sections() {
867 let cmd = full_command();
868 let help = render_help(&cmd);
869 assert!(help.contains("NAME"), "missing NAME");
870 assert!(help.contains("SUMMARY"), "missing SUMMARY");
871 assert!(help.contains("DESCRIPTION"), "missing DESCRIPTION");
872 assert!(help.contains("USAGE"), "missing USAGE");
873 assert!(help.contains("ARGUMENTS"), "missing ARGUMENTS");
874 assert!(help.contains("FLAGS"), "missing FLAGS");
875 assert!(help.contains("SUBCOMMANDS"), "missing SUBCOMMANDS");
876 assert!(help.contains("EXAMPLES"), "missing EXAMPLES");
877 assert!(help.contains("BEST PRACTICES"), "missing BEST PRACTICES");
878 assert!(help.contains("ANTI-PATTERNS"), "missing ANTI-PATTERNS");
879 }
880
881 #[test]
882 fn test_render_help_omits_empty_sections() {
883 let cmd = Command::builder("simple")
884 .summary("Simple")
885 .build()
886 .unwrap();
887 let help = render_help(&cmd);
888 assert!(!help.contains("ARGUMENTS"));
889 assert!(!help.contains("FLAGS"));
890 assert!(!help.contains("SUBCOMMANDS"));
891 assert!(!help.contains("EXAMPLES"));
892 assert!(!help.contains("BEST PRACTICES"));
893 assert!(!help.contains("ANTI-PATTERNS"));
894 }
895
896 #[test]
897 fn test_render_help_shows_alias() {
898 let cmd = full_command();
899 let help = render_help(&cmd);
900 assert!(help.contains('d')); }
902
903 #[test]
904 fn test_render_markdown_starts_with_heading() {
905 let cmd = full_command();
906 let md = render_markdown(&cmd);
907 assert!(md.starts_with("# deploy"));
908 }
909
910 #[test]
911 fn test_render_markdown_contains_table() {
912 let cmd = full_command();
913 let md = render_markdown(&cmd);
914 assert!(md.contains("| `env`"));
915 assert!(md.contains("| `--dry-run`"));
916 }
917
918 #[test]
919 fn test_render_ambiguity() {
920 let candidates = vec!["list".to_string(), "log".to_string()];
921 let msg = render_ambiguity("l", &candidates);
922 assert!(msg.contains("Did you mean"));
923 assert!(msg.contains("list"));
924 assert!(msg.contains("log"));
925 }
926
927 #[test]
928 fn test_render_subcommand_list() {
929 let cmds = vec![
930 Command::builder("a").summary("alpha").build().unwrap(),
931 Command::builder("b").summary("beta").build().unwrap(),
932 ];
933 let out = render_subcommand_list(&cmds);
934 assert!(out.contains("alpha"));
935 assert!(out.contains("beta"));
936 }
937
938 #[test]
939 fn test_render_resolve_error_unknown_no_suggestions() {
940 use crate::resolver::ResolveError;
941 let err = ResolveError::Unknown {
942 input: "xyz".into(),
943 suggestions: vec![],
944 };
945 let msg = render_resolve_error(&err);
946 assert!(msg.contains("xyz"));
947 assert!(!msg.contains("Did you mean"));
948 }
949
950 #[test]
951 fn test_render_resolve_error_unknown_with_suggestions() {
952 use crate::resolver::ResolveError;
953 let err = ResolveError::Unknown {
954 input: "lst".into(),
955 suggestions: vec!["list".into()],
956 };
957 let msg = render_resolve_error(&err);
958 assert!(msg.contains("lst") && msg.contains("list") && msg.contains("Did you mean"));
959 }
960
961 #[test]
962 fn test_render_resolve_error_ambiguous() {
963 use crate::resolver::ResolveError;
964 let err = ResolveError::Ambiguous {
965 input: "l".into(),
966 candidates: vec!["list".into(), "log".into()],
967 };
968 let msg = render_resolve_error(&err);
969 assert!(msg.contains("list") && msg.contains("log"));
970 }
971
972 #[test]
973 fn test_default_renderer_delegates() {
974 let cmd = Command::builder("test")
975 .summary("A test command")
976 .build()
977 .unwrap();
978 let r = DefaultRenderer;
979 let help = r.render_help(&cmd);
980 assert!(help.contains("test"));
981 let md = r.render_markdown(&cmd);
982 assert!(md.starts_with("# test"));
983 }
984
985 #[test]
986 fn test_custom_renderer_via_cli() {
987 struct Upper;
988 impl Renderer for Upper {
989 fn render_help(&self, c: &Command) -> String {
990 render_help(c).to_uppercase()
991 }
992 fn render_markdown(&self, c: &Command) -> String {
993 render_markdown(c)
994 }
995 fn render_subcommand_list(&self, cs: &[Command]) -> String {
996 render_subcommand_list(cs)
997 }
998 fn render_ambiguity(&self, i: &str, cs: &[String]) -> String {
999 render_ambiguity(i, cs)
1000 }
1001 }
1002 let cli = crate::cli::Cli::new(vec![Command::builder("ping").build().unwrap()])
1003 .with_renderer(Upper);
1004 let _ = cli.run(["--help"]);
1006 }
1007
1008 #[test]
1009 fn test_render_completion_bash_contains_program() {
1010 use crate::query::Registry;
1011 let reg = Registry::new(vec![
1012 Command::builder("deploy").build().unwrap(),
1013 Command::builder("status").build().unwrap(),
1014 ]);
1015 let script = render_completion(Shell::Bash, "mytool", ®);
1016 assert!(script.contains("mytool"));
1017 assert!(script.contains("deploy"));
1018 assert!(script.contains("status"));
1019 }
1020
1021 #[test]
1022 fn test_render_completion_zsh_contains_program() {
1023 use crate::query::Registry;
1024 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1025 let script = render_completion(Shell::Zsh, "mytool", ®);
1026 assert!(script.contains("mytool") && script.contains("run"));
1027 }
1028
1029 #[test]
1030 fn test_render_completion_fish_contains_program() {
1031 use crate::query::Registry;
1032 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1033 let script = render_completion(Shell::Fish, "mytool", ®);
1034 assert!(script.contains("mytool") && script.contains("run"));
1035 }
1036
1037 #[test]
1038 fn test_render_completion_bash_includes_flags() {
1039 use crate::query::Registry;
1040 let reg = Registry::new(vec![Command::builder("deploy")
1041 .flag(Flag::builder("env").takes_value().build().unwrap())
1042 .flag(Flag::builder("dry-run").build().unwrap())
1043 .build()
1044 .unwrap()]);
1045 let script = render_completion(Shell::Bash, "t", ®);
1046 assert!(script.contains("--env"));
1047 assert!(script.contains("--dry-run"));
1048 }
1049
1050 #[test]
1051 fn test_render_json_schema_properties() {
1052 let cmd = Command::builder("deploy")
1053 .summary("Deploy a service")
1054 .argument(
1055 Argument::builder("env")
1056 .required()
1057 .description("Target env")
1058 .build()
1059 .unwrap(),
1060 )
1061 .flag(
1062 Flag::builder("dry-run")
1063 .description("Simulate")
1064 .build()
1065 .unwrap(),
1066 )
1067 .flag(
1068 Flag::builder("strategy")
1069 .takes_value()
1070 .choices(["rolling", "canary"])
1071 .build()
1072 .unwrap(),
1073 )
1074 .build()
1075 .unwrap();
1076
1077 let schema = render_json_schema(&cmd).unwrap();
1078 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1079
1080 assert_eq!(v["title"], "deploy");
1081 assert_eq!(v["description"], "Deploy a service");
1082 assert_eq!(v["properties"]["env"]["type"], "string");
1083 assert_eq!(v["properties"]["dry-run"]["type"], "boolean");
1084 assert_eq!(v["properties"]["strategy"]["type"], "string");
1085 assert_eq!(v["properties"]["strategy"]["enum"][0], "rolling");
1086 let req = v["required"].as_array().unwrap();
1087 assert!(req.contains(&serde_json::json!("env")));
1088 }
1089
1090 #[test]
1091 fn test_render_json_schema_empty_command() {
1092 let cmd = Command::builder("ping").build().unwrap();
1093 let schema = render_json_schema(&cmd).unwrap();
1094 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1095 assert_eq!(v["title"], "ping");
1096 assert!(
1097 v["required"].is_null()
1098 || v["required"]
1099 .as_array()
1100 .map(|a| a.is_empty())
1101 .unwrap_or(true)
1102 );
1103 }
1104
1105 #[test]
1106 fn test_render_json_schema_returns_result() {
1107 let cmd = Command::builder("ping").build().unwrap();
1108 let result = render_json_schema(&cmd);
1110 assert!(result.is_ok());
1111 let _: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
1112 }
1113
1114 #[test]
1115 fn test_spellings_not_in_help_output() {
1116 let cmd = Command::builder("deploy")
1117 .alias("release")
1118 .spelling("deply")
1119 .build()
1120 .unwrap();
1121
1122 let help = render_help(&cmd);
1123 assert!(help.contains("release"), "alias should appear in help");
1124 assert!(!help.contains("deply"), "spelling must not appear in help");
1125 }
1126
1127 #[test]
1128 fn test_semantic_aliases_not_in_help_output() {
1129 let cmd = Command::builder("deploy")
1130 .alias("d")
1131 .semantic_alias("release to production")
1132 .semantic_alias("push to environment")
1133 .summary("Deploy a service")
1134 .build()
1135 .unwrap();
1136
1137 let help = render_help(&cmd);
1138 assert!(help.contains("d"), "alias should appear in help");
1139 assert!(
1140 !help.contains("release to production"),
1141 "semantic alias must not appear in help"
1142 );
1143 assert!(
1144 !help.contains("push to environment"),
1145 "semantic alias must not appear in help"
1146 );
1147 }
1148
1149 fn docs_registry() -> crate::query::Registry {
1150 use crate::query::Registry;
1151 Registry::new(vec![
1152 Command::builder("deploy")
1153 .summary("Deploy the application")
1154 .subcommand(
1155 Command::builder("rollback")
1156 .summary("Roll back a deployment")
1157 .build()
1158 .unwrap(),
1159 )
1160 .build()
1161 .unwrap(),
1162 Command::builder("status")
1163 .summary("Show status")
1164 .build()
1165 .unwrap(),
1166 ])
1167 }
1168
1169 #[test]
1170 fn test_render_docs_contains_all_commands() {
1171 let reg = docs_registry();
1172 let docs = render_docs(®);
1173 assert!(docs.contains("# Commands"), "missing top-level heading");
1174 assert!(docs.contains("deploy"), "missing deploy");
1175 assert!(docs.contains("rollback"), "missing rollback");
1176 assert!(docs.contains("status"), "missing status");
1177 assert!(docs.contains("---"), "missing separator");
1178 }
1179
1180 #[test]
1181 fn test_render_docs_table_of_contents_indents_subcommands() {
1182 let reg = docs_registry();
1183 let docs = render_docs(®);
1184 assert!(
1186 docs.contains("\n- [deploy](#deploy)"),
1187 "deploy should be at root indent"
1188 );
1189 assert!(
1191 docs.contains("\n - [deploy rollback](#deploy-rollback)"),
1192 "deploy rollback should be indented"
1193 );
1194 assert!(
1196 docs.contains("\n- [status](#status)"),
1197 "status should be at root indent"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_render_docs_empty_registry() {
1203 use crate::query::Registry;
1204 let reg = Registry::new(vec![]);
1205 let docs = render_docs(®);
1206 assert!(docs.starts_with("# Commands\n\n"));
1207 assert!(!docs.contains("---"));
1209 }
1210
1211 #[test]
1212 fn test_default_renderer_render_docs() {
1213 let reg = docs_registry();
1214 let renderer = DefaultRenderer;
1215 let docs = renderer.render_docs(®);
1216 assert!(docs.contains("# Commands"));
1217 assert!(docs.contains("deploy"));
1218 assert!(docs.contains("status"));
1219 }
1220
1221 #[test]
1222 fn test_render_completion_zsh_with_flags_and_args() {
1223 use crate::query::Registry;
1224 let reg = Registry::new(vec![
1225 Command::builder("deploy")
1226 .summary("Deploy")
1227 .flag(
1228 Flag::builder("env")
1229 .takes_value()
1230 .description("target env")
1231 .build()
1232 .unwrap(),
1233 )
1234 .flag(
1235 Flag::builder("dry-run")
1236 .description("simulate")
1237 .build()
1238 .unwrap(),
1239 )
1240 .argument(Argument::builder("service").required().build().unwrap())
1241 .build()
1242 .unwrap(),
1243 Command::builder("status").build().unwrap(),
1245 ]);
1246 let script = render_completion(Shell::Zsh, "mytool", ®);
1247 assert!(script.contains("mytool"));
1248 assert!(script.contains("deploy"));
1249 assert!(script.contains("--env"));
1250 assert!(script.contains("--dry-run"));
1251 }
1252
1253 #[test]
1254 fn test_render_completion_zsh_empty_summary_uses_canonical() {
1255 use crate::query::Registry;
1256 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1258 let script = render_completion(Shell::Zsh, "mytool", ®);
1259 assert!(script.contains("run:run"));
1261 }
1262
1263 #[test]
1264 fn test_render_completion_fish_with_flags() {
1265 use crate::query::Registry;
1266 let reg = Registry::new(vec![Command::builder("deploy")
1267 .summary("Deploy the app")
1268 .flag(
1269 Flag::builder("env")
1270 .takes_value()
1271 .description("target environment")
1272 .build()
1273 .unwrap(),
1274 )
1275 .flag(
1276 Flag::builder("dry-run")
1277 .description("simulate")
1278 .build()
1279 .unwrap(),
1280 )
1281 .build()
1282 .unwrap()]);
1283 let script = render_completion(Shell::Fish, "mytool", ®);
1284 assert!(script.contains("mytool"));
1285 assert!(script.contains("deploy"));
1286 assert!(script.contains("--env") || script.contains("'env'"));
1287 assert!(script.contains("-r"));
1289 assert!(script.contains("Deploy the app"));
1291 }
1292
1293 #[test]
1294 fn test_render_completion_fish_empty_summary() {
1295 use crate::query::Registry;
1296 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1297 let script = render_completion(Shell::Fish, "mytool", ®);
1298 assert!(script.contains("run"));
1300 }
1301
1302 #[test]
1303 fn test_render_completion_bash_no_flags_cmd() {
1304 use crate::query::Registry;
1305 let reg = Registry::new(vec![Command::builder("status").build().unwrap()]);
1307 let script = render_completion(Shell::Bash, "app", ®);
1308 assert!(script.contains("status"));
1309 }
1310
1311 #[test]
1312 fn test_render_json_schema_variadic_arg() {
1313 let cmd = Command::builder("run")
1314 .argument(
1315 Argument::builder("files")
1316 .variadic()
1317 .description("Files to process")
1318 .build()
1319 .unwrap(),
1320 )
1321 .build()
1322 .unwrap();
1323 let schema = render_json_schema(&cmd).unwrap();
1324 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1325 assert_eq!(v["properties"]["files"]["type"], "array");
1326 assert_eq!(v["properties"]["files"]["items"]["type"], "string");
1327 assert!(v["properties"]["files"]["description"].as_str().is_some());
1328 }
1329
1330 #[test]
1331 fn test_render_json_schema_flag_with_default() {
1332 let cmd = Command::builder("run")
1333 .flag(
1334 Flag::builder("output")
1335 .takes_value()
1336 .default_value("text")
1337 .description("Output format")
1338 .build()
1339 .unwrap(),
1340 )
1341 .build()
1342 .unwrap();
1343 let schema = render_json_schema(&cmd).unwrap();
1344 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1345 assert_eq!(v["properties"]["output"]["default"], "text");
1346 assert_eq!(v["properties"]["output"]["type"], "string");
1347 }
1348
1349 #[test]
1350 fn test_render_json_schema_required_flag() {
1351 let cmd = Command::builder("deploy")
1352 .flag(
1353 Flag::builder("env")
1354 .takes_value()
1355 .required()
1356 .build()
1357 .unwrap(),
1358 )
1359 .build()
1360 .unwrap();
1361 let schema = render_json_schema(&cmd).unwrap();
1362 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1363 let req = v["required"].as_array().unwrap();
1364 assert!(req.contains(&serde_json::json!("env")));
1365 }
1366
1367 #[test]
1368 fn test_render_json_schema_arg_with_default() {
1369 let cmd = Command::builder("run")
1370 .argument(
1371 Argument::builder("target")
1372 .default_value("prod")
1373 .build()
1374 .unwrap(),
1375 )
1376 .build()
1377 .unwrap();
1378 let schema = render_json_schema(&cmd).unwrap();
1379 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1380 assert_eq!(v["properties"]["target"]["default"], "prod");
1381 }
1382
1383 #[test]
1384 fn test_render_help_output_in_example() {
1385 let cmd = Command::builder("run")
1387 .example(Example::new("Run example", "myapp run").with_output("OK"))
1388 .build()
1389 .unwrap();
1390 let help = render_help(&cmd);
1391 assert!(help.contains("# Output: OK"));
1392 }
1393
1394 #[test]
1395 fn test_render_markdown_with_best_practices_and_anti_patterns() {
1396 let cmd = Command::builder("deploy")
1397 .best_practice("Always dry-run first")
1398 .anti_pattern("Deploy on Fridays")
1399 .build()
1400 .unwrap();
1401 let md = render_markdown(&cmd);
1402 assert!(md.contains("## Best Practices"));
1403 assert!(md.contains("Always dry-run first"));
1404 assert!(md.contains("## Anti-Patterns"));
1405 assert!(md.contains("Deploy on Fridays"));
1406 }
1407
1408 #[test]
1409 fn test_render_markdown_with_subcommands() {
1410 let cmd = Command::builder("remote")
1411 .subcommand(
1412 Command::builder("add")
1413 .summary("Add remote")
1414 .build()
1415 .unwrap(),
1416 )
1417 .build()
1418 .unwrap();
1419 let md = render_markdown(&cmd);
1420 assert!(md.contains("## Subcommands"));
1421 assert!(md.contains("**add**"));
1422 }
1423}