1use crate::model::Command;
42
43#[derive(Debug, Clone)]
81pub struct SkillFrontmatter {
82 pub name: String,
84 pub version: Option<String>,
86 pub description: Option<String>,
89 pub requires_bins: Vec<String>,
91 pub extra: std::collections::HashMap<String, serde_json::Value>,
94}
95
96impl SkillFrontmatter {
97 pub fn new(name: impl Into<String>) -> Self {
111 Self {
112 name: name.into(),
113 version: None,
114 description: None,
115 requires_bins: Vec::new(),
116 extra: std::collections::HashMap::new(),
117 }
118 }
119
120 pub fn version(mut self, v: impl Into<String>) -> Self {
131 self.version = Some(v.into());
132 self
133 }
134
135 pub fn description(mut self, d: impl Into<String>) -> Self {
149 self.description = Some(d.into());
150 self
151 }
152
153 pub fn requires_bin(mut self, bin: impl Into<String>) -> Self {
168 self.requires_bins.push(bin.into());
169 self
170 }
171
172 pub fn extra(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
187 self.extra.insert(key.into(), value);
188 self
189 }
190}
191
192fn render_frontmatter(fm: &SkillFrontmatter, cmd: &Command) -> String {
200 let mut out = String::from("---\n");
201
202 out.push_str(&format!("name: {}\n", fm.name));
203
204 if let Some(ref v) = fm.version {
205 out.push_str(&format!("version: {}\n", v));
206 }
207
208 let desc = fm
210 .description
211 .as_deref()
212 .filter(|s| !s.is_empty())
213 .or_else(|| {
214 if cmd.summary.is_empty() {
215 None
216 } else {
217 Some(cmd.summary.as_str())
218 }
219 });
220 if let Some(d) = desc {
221 out.push_str(&format!("description: {}\n", d));
222 }
223
224 if !fm.requires_bins.is_empty() {
225 out.push_str("requires_bins:\n");
226 for bin in &fm.requires_bins {
227 out.push_str(&format!(" - {}\n", bin));
228 }
229 }
230
231 if !fm.extra.is_empty() {
232 out.push_str("extra:\n");
233 let mut keys: Vec<&String> = fm.extra.keys().collect();
235 keys.sort();
236 for key in keys {
237 let value = &fm.extra[key];
238 let serialized = value.to_string();
240 out.push_str(&format!(" {}: {}\n", key, serialized));
241 }
242 }
243
244 out.push_str("---\n");
245 out
246}
247
248pub fn render_skill_file_with_frontmatter(cmd: &Command, frontmatter: &SkillFrontmatter) -> String {
277 let fm_text = render_frontmatter(frontmatter, cmd);
278 let skill_text = render_skill_file(cmd);
279 format!("{}\n{}", fm_text, skill_text)
280}
281
282pub fn render_skill_files_with_frontmatter<F>(
308 registry: &crate::query::Registry,
309 frontmatter_fn: F,
310) -> String
311where
312 F: Fn(&Command) -> Option<SkillFrontmatter>,
313{
314 let entries = registry.iter_all_recursive();
315 let mut parts: Vec<String> = Vec::new();
316
317 for entry in &entries {
318 let cmd = entry.command;
319 let skill = match frontmatter_fn(cmd) {
320 Some(fm) => render_skill_file_with_frontmatter(cmd, &fm),
321 None => render_skill_file(cmd),
322 };
323 parts.push(skill);
324 }
325
326 parts.join("\n---\n\n")
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
333pub enum Shell {
334 Bash,
336 Zsh,
338 Fish,
340}
341
342pub trait Renderer: Send + Sync {
372 fn render_help(&self, command: &crate::model::Command) -> String;
374 fn render_markdown(&self, command: &crate::model::Command) -> String;
376 fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String;
378 fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String;
380 fn render_docs(&self, registry: &crate::query::Registry) -> String {
385 render_docs(registry)
386 }
387 fn render_skill_file(&self, command: &crate::model::Command) -> String {
393 render_skill_file(command)
394 }
395 fn render_skill_files(&self, registry: &crate::query::Registry) -> String {
400 render_skill_files(registry)
401 }
402
403 fn render_skill_file_with_frontmatter(
408 &self,
409 cmd: &crate::model::Command,
410 frontmatter: &SkillFrontmatter,
411 ) -> String {
412 render_skill_file_with_frontmatter(cmd, frontmatter)
413 }
414
415 fn render_skill_files_with_frontmatter_boxed(
423 &self,
424 registry: &crate::query::Registry,
425 frontmatter_fn: &dyn Fn(&crate::model::Command) -> Option<SkillFrontmatter>,
426 ) -> String {
427 render_skill_files_with_frontmatter(registry, frontmatter_fn)
428 }
429}
430
431#[derive(Debug, Default, Clone)]
435pub struct DefaultRenderer;
436
437impl Renderer for DefaultRenderer {
438 fn render_help(&self, command: &crate::model::Command) -> String {
439 render_help(command)
440 }
441 fn render_markdown(&self, command: &crate::model::Command) -> String {
442 render_markdown(command)
443 }
444 fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String {
445 render_subcommand_list(commands)
446 }
447 fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String {
448 render_ambiguity(input, candidates)
449 }
450 fn render_docs(&self, registry: &crate::query::Registry) -> String {
451 render_docs(registry)
452 }
453 fn render_skill_file(&self, command: &crate::model::Command) -> String {
454 render_skill_file(command)
455 }
456 fn render_skill_files(&self, registry: &crate::query::Registry) -> String {
457 render_skill_files(registry)
458 }
459 fn render_skill_file_with_frontmatter(
460 &self,
461 cmd: &crate::model::Command,
462 frontmatter: &SkillFrontmatter,
463 ) -> String {
464 render_skill_file_with_frontmatter(cmd, frontmatter)
465 }
466 fn render_skill_files_with_frontmatter_boxed(
467 &self,
468 registry: &crate::query::Registry,
469 frontmatter_fn: &dyn Fn(&crate::model::Command) -> Option<SkillFrontmatter>,
470 ) -> String {
471 render_skill_files_with_frontmatter(registry, frontmatter_fn)
472 }
473}
474
475pub fn render_help(command: &Command) -> String {
500 let mut out = String::new();
501
502 let name_line = if command.aliases.is_empty() {
504 command.canonical.clone()
505 } else {
506 format!("{} ({})", command.canonical, command.aliases.join(", "))
507 };
508 out.push_str(&format!("NAME\n {}\n\n", name_line));
509
510 if !command.summary.is_empty() {
511 out.push_str(&format!("SUMMARY\n {}\n\n", command.summary));
512 }
513
514 if command.mutating {
515 out.push_str("⚠ MUTATING COMMAND\n");
516 let has_dry_run = command.flags.iter().any(|f| f.name == "dry-run");
517 if !has_dry_run {
518 out.push_str(
519 " This command modifies state. Consider adding --dry-run support.\n",
520 );
521 }
522 out.push('\n');
523 }
524
525 if !command.description.is_empty() {
526 out.push_str(&format!("DESCRIPTION\n {}\n\n", command.description));
527 }
528
529 out.push_str(&format!("USAGE\n {}\n\n", build_usage(command)));
530
531 if !command.arguments.is_empty() {
532 out.push_str("ARGUMENTS\n");
533 for arg in &command.arguments {
534 let req = if arg.required { " (required)" } else { "" };
535 out.push_str(&format!(" <{}> {}{}\n", arg.name, arg.description, req));
536 }
537 out.push('\n');
538 }
539
540 if !command.flags.is_empty() {
541 out.push_str("FLAGS\n");
542 for flag in &command.flags {
543 let short_part = flag.short.map(|c| format!("-{}, ", c)).unwrap_or_default();
544 let req = if flag.required { " (required)" } else { "" };
545 out.push_str(&format!(
546 " {}--{} {}{}\n",
547 short_part, flag.name, flag.description, req
548 ));
549 }
550 out.push('\n');
551 }
552
553 if !command.subcommands.is_empty() {
554 out.push_str("SUBCOMMANDS\n");
555 for sub in &command.subcommands {
556 out.push_str(&format!(" {} {}\n", sub.canonical, sub.summary));
557 }
558 out.push('\n');
559 }
560
561 if !command.examples.is_empty() {
562 out.push_str("EXAMPLES\n");
563 for ex in &command.examples {
564 out.push_str(&format!(" # {}\n {}\n", ex.description, ex.command));
565 if let Some(output) = &ex.output {
566 out.push_str(&format!(" # Output: {}\n", output));
567 }
568 out.push('\n');
569 }
570 }
571
572 if !command.best_practices.is_empty() {
573 out.push_str("BEST PRACTICES\n");
574 for bp in &command.best_practices {
575 out.push_str(&format!(" - {}\n", bp));
576 }
577 out.push('\n');
578 }
579
580 if !command.anti_patterns.is_empty() {
581 out.push_str("ANTI-PATTERNS\n");
582 for ap in &command.anti_patterns {
583 out.push_str(&format!(" - {}\n", ap));
584 }
585 out.push('\n');
586 }
587
588 out
589}
590
591pub fn render_subcommand_list(commands: &[Command]) -> String {
614 let mut out = String::new();
615 for cmd in commands {
616 out.push_str(&format!(" {} {}\n", cmd.canonical, cmd.summary));
617 }
618 out
619}
620
621pub fn render_markdown(command: &Command) -> String {
650 let mut out = String::new();
651
652 out.push_str(&format!("# {}\n\n", command.canonical));
653
654 if !command.summary.is_empty() {
655 out.push_str(&format!("{}\n\n", command.summary));
656 }
657
658 if command.mutating {
659 out.push_str(
660 "> ⚠ **Mutating command** — this operation modifies state.\n\n",
661 );
662 }
663
664 if !command.description.is_empty() {
665 out.push_str(&format!("## Description\n\n{}\n\n", command.description));
666 }
667
668 out.push_str(&format!(
669 "## Usage\n\n```\n{}\n```\n\n",
670 build_usage(command)
671 ));
672
673 if !command.arguments.is_empty() {
674 out.push_str("## Arguments\n\n");
675 out.push_str("| Name | Description | Required |\n");
676 out.push_str("|------|-------------|----------|\n");
677 for arg in &command.arguments {
678 out.push_str(&format!(
679 "| `{}` | {} | {} |\n",
680 arg.name, arg.description, arg.required
681 ));
682 }
683 out.push('\n');
684 }
685
686 if !command.flags.is_empty() {
687 out.push_str("## Flags\n\n");
688 out.push_str("| Flag | Short | Description | Required |\n");
689 out.push_str("|------|-------|-------------|----------|\n");
690 for flag in &command.flags {
691 let short = flag.short.map(|c| format!("`-{}`", c)).unwrap_or_default();
692 out.push_str(&format!(
693 "| `--{}` | {} | {} | {} |\n",
694 flag.name, short, flag.description, flag.required
695 ));
696 }
697 out.push('\n');
698 }
699
700 if !command.subcommands.is_empty() {
701 out.push_str("## Subcommands\n\n");
702 for sub in &command.subcommands {
703 out.push_str(&format!("- **{}**: {}\n", sub.canonical, sub.summary));
704 }
705 out.push('\n');
706 }
707
708 if !command.examples.is_empty() {
709 out.push_str("## Examples\n\n");
710 for ex in &command.examples {
711 out.push_str(&format!(
712 "### {}\n\n```\n{}\n```\n\n",
713 ex.description, ex.command
714 ));
715 }
716 }
717
718 if !command.best_practices.is_empty() {
719 out.push_str("## Best Practices\n\n");
720 for bp in &command.best_practices {
721 out.push_str(&format!("- {}\n", bp));
722 }
723 out.push('\n');
724 }
725
726 if !command.anti_patterns.is_empty() {
727 out.push_str("## Anti-Patterns\n\n");
728 for ap in &command.anti_patterns {
729 out.push_str(&format!("- {}\n", ap));
730 }
731 out.push('\n');
732 }
733
734 out
735}
736
737pub fn render_ambiguity(input: &str, candidates: &[String]) -> String {
758 let list = candidates
759 .iter()
760 .map(|c| format!(" - {}", c))
761 .collect::<Vec<_>>()
762 .join("\n");
763 format!(
764 "Ambiguous command \"{}\". Did you mean one of:\n{}",
765 input, list
766 )
767}
768
769pub fn render_resolve_error(error: &crate::resolver::ResolveError) -> String {
797 use crate::resolver::ResolveError;
798 match error {
799 ResolveError::Ambiguous { input, candidates } => render_ambiguity(input, candidates),
800 ResolveError::Unknown { input, suggestions } if !suggestions.is_empty() => format!(
801 "Unknown command: `{}`. Did you mean: {}?",
802 input,
803 suggestions.join(", ")
804 ),
805 ResolveError::Unknown { input, .. } => format!("Unknown command: `{}`", input),
806 }
807}
808
809pub fn render_completion(shell: Shell, program: &str, registry: &crate::query::Registry) -> String {
838 match shell {
839 Shell::Bash => render_completion_bash(program, registry),
840 Shell::Zsh => render_completion_zsh(program, registry),
841 Shell::Fish => render_completion_fish(program, registry),
842 }
843}
844
845fn render_completion_bash(program: &str, registry: &crate::query::Registry) -> String {
846 let func_name = format!("_{}_completions", program.replace('-', "_"));
847
848 let top_level: Vec<&str> = registry
850 .commands()
851 .iter()
852 .map(|c| c.canonical.as_str())
853 .collect();
854
855 let mut cmd_cases = String::new();
857 for entry in registry.iter_all_recursive() {
858 let cmd = entry.command;
859 let flags: Vec<String> = cmd.flags.iter().map(|f| format!("--{}", f.name)).collect();
860 if !flags.is_empty() {
861 let path_str = entry.path_str();
862 cmd_cases.push_str(&format!(
863 " {})\n COMPREPLY=($(compgen -W \"{}\" -- \"$cur\"))\n return\n ;;\n",
864 path_str,
865 flags.join(" ")
866 ));
867 }
868 }
869
870 format!(
871 r#"# {program} bash completion
872# Source this file or add to ~/.bashrc:
873# source <({program} completion bash)
874
875{func_name}() {{
876 local cur prev words cword
877 _init_completion 2>/dev/null || {{
878 cur="${{COMP_WORDS[COMP_CWORD]}}"
879 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
880 }}
881
882 local cmd="${{COMP_WORDS[1]}}"
883
884 case "$cmd" in
885{cmd_cases} *)
886 COMPREPLY=($(compgen -W "{top}" -- "$cur"))
887 ;;
888 esac
889}}
890
891complete -F {func_name} {program}
892"#,
893 program = program,
894 func_name = func_name,
895 cmd_cases = cmd_cases,
896 top = top_level.join(" "),
897 )
898}
899
900fn render_completion_zsh(program: &str, registry: &crate::query::Registry) -> String {
901 let mut commands_block = String::new();
902 for cmd in registry.commands() {
903 let desc = if cmd.summary.is_empty() {
904 &cmd.canonical
905 } else {
906 &cmd.summary
907 };
908 commands_block.push_str(&format!(" '{}:{}'\n", cmd.canonical, desc));
909 }
910
911 let mut subcommand_cases = String::new();
912 for entry in registry.iter_all_recursive() {
913 let cmd = entry.command;
914 if cmd.flags.is_empty() && cmd.arguments.is_empty() {
915 continue;
916 }
917 let mut args_spec = String::new();
918 for flag in &cmd.flags {
919 let desc = if flag.description.is_empty() {
920 flag.name.as_str()
921 } else {
922 flag.description.as_str()
923 };
924 if flag.takes_value {
925 args_spec.push_str(&format!(" '--{}[{}]:value:_default'\n", flag.name, desc));
926 } else {
927 args_spec.push_str(&format!(" '--{}[{}]'\n", flag.name, desc));
928 }
929 }
930 let path_str = entry.path_str().replace('.', "-");
931 subcommand_cases.push_str(&format!(
932 " ({path})\n _arguments \\\n{args} ;;\n",
933 path = path_str,
934 args = args_spec,
935 ));
936 }
937
938 format!(
939 r#"#compdef {program}
940# {program} zsh completion
941
942_{program}() {{
943 local state
944
945 _arguments \
946 '1: :{program}_commands' \
947 '*:: :->subcommand'
948
949 case $state in
950 subcommand)
951 case $words[1] in
952{subcases} esac
953 esac
954}}
955
956_{program}_commands() {{
957 local -a commands
958 commands=(
959{cmds} )
960 _describe 'command' commands
961}}
962
963_{program}
964"#,
965 program = program,
966 subcases = subcommand_cases,
967 cmds = commands_block,
968 )
969}
970
971fn render_completion_fish(program: &str, registry: &crate::query::Registry) -> String {
972 let mut lines = format!(
973 "# {program} fish completion\n# Add to ~/.config/fish/completions/{program}.fish\n\n"
974 );
975
976 for cmd in registry.commands() {
978 let desc = if cmd.summary.is_empty() {
979 String::new()
980 } else {
981 format!(" -d '{}'", cmd.summary.replace('\'', "\\'"))
982 };
983 lines.push_str(&format!(
984 "complete -c {program} -f -n '__fish_use_subcommand' -a '{}'{}\n",
985 cmd.canonical, desc
986 ));
987 }
988
989 lines.push('\n');
990
991 for entry in registry.iter_all_recursive() {
993 let cmd = entry.command;
994 let subcmd = &cmd.canonical;
995 for flag in &cmd.flags {
996 let desc = if flag.description.is_empty() {
997 String::new()
998 } else {
999 format!(" -d '{}'", flag.description.replace('\'', "\\'"))
1000 };
1001 let req = if flag.takes_value { " -r" } else { "" };
1002 lines.push_str(&format!(
1003 "complete -c {program} -n '__fish_seen_subcommand_from {subcmd}' -l '{name}'{req}{desc}\n",
1004 program = program,
1005 subcmd = subcmd,
1006 name = flag.name,
1007 req = req,
1008 desc = desc,
1009 ));
1010 }
1011 }
1012
1013 lines
1014}
1015
1016pub fn render_json_schema(command: &Command) -> Result<String, serde_json::Error> {
1050 use serde_json::{json, Map, Value};
1051
1052 let mut properties: Map<String, Value> = Map::new();
1053 let mut required: Vec<Value> = Vec::new();
1054
1055 for arg in &command.arguments {
1057 let mut prop = json!({
1058 "type": "string",
1059 });
1060 if !arg.description.is_empty() {
1061 prop["description"] = json!(arg.description);
1062 }
1063 if arg.variadic {
1064 prop = json!({
1065 "type": "array",
1066 "items": { "type": "string" },
1067 });
1068 if !arg.description.is_empty() {
1069 prop["description"] = json!(arg.description);
1070 }
1071 }
1072 if arg.required {
1073 required.push(json!(arg.name));
1074 }
1075 if let Some(ref default) = arg.default {
1076 prop["default"] = json!(default);
1077 }
1078 properties.insert(arg.name.clone(), prop);
1079 }
1080
1081 for flag in &command.flags {
1083 let mut prop: Map<String, Value> = Map::new();
1084
1085 if !flag.description.is_empty() {
1086 prop.insert("description".into(), json!(flag.description));
1087 }
1088
1089 if flag.takes_value {
1090 if let Some(ref choices) = flag.choices {
1091 prop.insert("type".into(), json!("string"));
1092 prop.insert(
1093 "enum".into(),
1094 Value::Array(choices.iter().map(|c| json!(c)).collect()),
1095 );
1096 } else {
1097 prop.insert("type".into(), json!("string"));
1098 }
1099 if let Some(ref default) = flag.default {
1100 prop.insert("default".into(), json!(default));
1101 }
1102 } else {
1103 prop.insert("type".into(), json!("boolean"));
1105 prop.insert("default".into(), json!(false));
1106 }
1107
1108 if flag.required {
1109 required.push(json!(flag.name));
1110 }
1111
1112 properties.insert(flag.name.clone(), Value::Object(prop));
1113 }
1114
1115 let mut schema = json!({
1116 "$schema": "http://json-schema.org/draft-07/schema#",
1117 "title": command.canonical,
1118 "type": "object",
1119 "properties": properties,
1120 });
1121
1122 if !command.summary.is_empty() {
1123 schema["description"] = json!(command.summary);
1124 }
1125
1126 if !required.is_empty() {
1127 schema["required"] = Value::Array(required);
1128 }
1129
1130 if command.mutating {
1131 schema["mutating"] = json!(true);
1132 }
1133
1134 serde_json::to_string_pretty(&schema)
1135}
1136
1137pub fn render_docs(registry: &crate::query::Registry) -> String {
1171 let entries = registry.iter_all_recursive();
1172
1173 let mut out = String::from("# Commands\n\n");
1174
1175 for entry in &entries {
1177 let depth = entry.path.len();
1178 let indent = " ".repeat(depth.saturating_sub(1));
1179 let anchor = entry.path_str().replace('.', "-").to_lowercase();
1180 let label = entry.path_str().replace('.', " ");
1181 out.push_str(&format!("{}- [{}](#{})\n", indent, label, anchor));
1182 }
1183
1184 for (i, entry) in entries.iter().enumerate() {
1186 out.push_str("\n---\n\n");
1187 out.push_str(&render_markdown(entry.command));
1188 let _ = i; }
1190
1191 out
1192}
1193
1194pub fn render_skill_file(command: &Command) -> String {
1222 let mut out = String::new();
1223
1224 out.push_str(&format!("# Skill: {}\n\n", command.canonical));
1226
1227 if !command.summary.is_empty() {
1229 out.push_str(&format!("{}\n\n", command.summary));
1230 }
1231
1232 if !command.description.is_empty() {
1234 out.push_str(&format!("{}\n\n", command.description));
1235 }
1236
1237 if !command.best_practices.is_empty() {
1239 out.push_str("## Safe Usage\n\nAlways prefer:\n");
1240 for bp in &command.best_practices {
1241 out.push_str(&format!("- {}\n", bp));
1242 }
1243 out.push('\n');
1244 }
1245
1246 if !command.anti_patterns.is_empty() {
1248 out.push_str("## Avoid\n\n");
1249 for ap in &command.anti_patterns {
1250 out.push_str(&format!("- {}\n", ap));
1251 }
1252 out.push('\n');
1253 }
1254
1255 if !command.arguments.is_empty() {
1257 out.push_str("## Arguments\n\n");
1258 out.push_str("| Name | Required | Description |\n");
1259 out.push_str("|------|----------|-------------|\n");
1260 for arg in &command.arguments {
1261 let req = if arg.required { "yes" } else { "no" };
1262 out.push_str(&format!(
1263 "| {} | {} | {} |\n",
1264 arg.name, req, arg.description
1265 ));
1266 }
1267 out.push('\n');
1268 }
1269
1270 if !command.flags.is_empty() {
1272 out.push_str("## Flags\n\n");
1273 out.push_str("| Flag | Short | Required | Default | Description |\n");
1274 out.push_str("|------|-------|----------|---------|-------------|\n");
1275 for flag in &command.flags {
1276 let short = flag
1277 .short
1278 .map(|c| format!("-{}", c))
1279 .unwrap_or_else(|| "—".to_string());
1280 let req = if flag.required { "yes" } else { "no" };
1281 let default = flag
1282 .default
1283 .as_deref()
1284 .unwrap_or("—");
1285 out.push_str(&format!(
1286 "| --{} | {} | {} | {} | {} |\n",
1287 flag.name, short, req, default, flag.description
1288 ));
1289 }
1290 out.push('\n');
1291 }
1292
1293 if !command.examples.is_empty() {
1295 out.push_str("## Examples\n\n");
1296 for ex in &command.examples {
1297 out.push_str(&format!("```\n{}\n```\n", ex.command));
1298 out.push_str(&format!("> {}\n\n", ex.description));
1299 }
1300 }
1301
1302 if !command.subcommands.is_empty() {
1304 out.push_str("## Subcommands\n\n");
1305 for sub in &command.subcommands {
1306 out.push_str(&format!("- `{}` — {}\n", sub.canonical, sub.summary));
1307 }
1308 out.push('\n');
1309 }
1310
1311 out
1312}
1313
1314pub fn render_skill_files(registry: &crate::query::Registry) -> String {
1346 let entries = registry.iter_all_recursive();
1347 let parts: Vec<String> = entries
1348 .iter()
1349 .map(|entry| render_skill_file(entry.command))
1350 .collect();
1351 parts.join("---\n\n")
1352}
1353
1354fn build_usage(command: &Command) -> String {
1355 let mut parts = vec![command.canonical.clone()];
1356 if !command.subcommands.is_empty() {
1357 parts.push("<subcommand>".to_string());
1358 }
1359 for arg in &command.arguments {
1360 if arg.required {
1361 parts.push(format!("<{}>", arg.name));
1362 } else {
1363 parts.push(format!("[{}]", arg.name));
1364 }
1365 }
1366 if !command.flags.is_empty() {
1367 parts.push("[flags]".to_string());
1368 }
1369 parts.join(" ")
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375 use crate::model::{Argument, Command, Example, Flag};
1376
1377 fn full_command() -> Command {
1378 Command::builder("deploy")
1379 .alias("d")
1380 .summary("Deploy the application")
1381 .description("Deploys the app to the target environment.")
1382 .argument(
1383 Argument::builder("env")
1384 .description("target environment")
1385 .required()
1386 .build()
1387 .unwrap(),
1388 )
1389 .flag(
1390 Flag::builder("dry-run")
1391 .short('n')
1392 .description("simulate only")
1393 .build()
1394 .unwrap(),
1395 )
1396 .subcommand(
1397 Command::builder("rollback")
1398 .summary("Roll back")
1399 .build()
1400 .unwrap(),
1401 )
1402 .example(Example::new("deploy to prod", "deploy prod").with_output("deployed"))
1403 .best_practice("always dry-run first")
1404 .anti_pattern("deploy on Friday")
1405 .build()
1406 .unwrap()
1407 }
1408
1409 #[test]
1410 fn test_render_help_contains_all_sections() {
1411 let cmd = full_command();
1412 let help = render_help(&cmd);
1413 assert!(help.contains("NAME"), "missing NAME");
1414 assert!(help.contains("SUMMARY"), "missing SUMMARY");
1415 assert!(help.contains("DESCRIPTION"), "missing DESCRIPTION");
1416 assert!(help.contains("USAGE"), "missing USAGE");
1417 assert!(help.contains("ARGUMENTS"), "missing ARGUMENTS");
1418 assert!(help.contains("FLAGS"), "missing FLAGS");
1419 assert!(help.contains("SUBCOMMANDS"), "missing SUBCOMMANDS");
1420 assert!(help.contains("EXAMPLES"), "missing EXAMPLES");
1421 assert!(help.contains("BEST PRACTICES"), "missing BEST PRACTICES");
1422 assert!(help.contains("ANTI-PATTERNS"), "missing ANTI-PATTERNS");
1423 }
1424
1425 #[test]
1426 fn test_render_help_omits_empty_sections() {
1427 let cmd = Command::builder("simple")
1428 .summary("Simple")
1429 .build()
1430 .unwrap();
1431 let help = render_help(&cmd);
1432 assert!(!help.contains("ARGUMENTS"));
1433 assert!(!help.contains("FLAGS"));
1434 assert!(!help.contains("SUBCOMMANDS"));
1435 assert!(!help.contains("EXAMPLES"));
1436 assert!(!help.contains("BEST PRACTICES"));
1437 assert!(!help.contains("ANTI-PATTERNS"));
1438 }
1439
1440 #[test]
1441 fn test_render_help_shows_alias() {
1442 let cmd = full_command();
1443 let help = render_help(&cmd);
1444 assert!(help.contains('d')); }
1446
1447 #[test]
1448 fn test_render_markdown_starts_with_heading() {
1449 let cmd = full_command();
1450 let md = render_markdown(&cmd);
1451 assert!(md.starts_with("# deploy"));
1452 }
1453
1454 #[test]
1455 fn test_render_markdown_contains_table() {
1456 let cmd = full_command();
1457 let md = render_markdown(&cmd);
1458 assert!(md.contains("| `env`"));
1459 assert!(md.contains("| `--dry-run`"));
1460 }
1461
1462 #[test]
1463 fn test_render_ambiguity() {
1464 let candidates = vec!["list".to_string(), "log".to_string()];
1465 let msg = render_ambiguity("l", &candidates);
1466 assert!(msg.contains("Did you mean"));
1467 assert!(msg.contains("list"));
1468 assert!(msg.contains("log"));
1469 }
1470
1471 #[test]
1472 fn test_render_subcommand_list() {
1473 let cmds = vec![
1474 Command::builder("a").summary("alpha").build().unwrap(),
1475 Command::builder("b").summary("beta").build().unwrap(),
1476 ];
1477 let out = render_subcommand_list(&cmds);
1478 assert!(out.contains("alpha"));
1479 assert!(out.contains("beta"));
1480 }
1481
1482 #[test]
1483 fn test_render_resolve_error_unknown_no_suggestions() {
1484 use crate::resolver::ResolveError;
1485 let err = ResolveError::Unknown {
1486 input: "xyz".into(),
1487 suggestions: vec![],
1488 };
1489 let msg = render_resolve_error(&err);
1490 assert!(msg.contains("xyz"));
1491 assert!(!msg.contains("Did you mean"));
1492 }
1493
1494 #[test]
1495 fn test_render_resolve_error_unknown_with_suggestions() {
1496 use crate::resolver::ResolveError;
1497 let err = ResolveError::Unknown {
1498 input: "lst".into(),
1499 suggestions: vec!["list".into()],
1500 };
1501 let msg = render_resolve_error(&err);
1502 assert!(msg.contains("lst") && msg.contains("list") && msg.contains("Did you mean"));
1503 }
1504
1505 #[test]
1506 fn test_render_resolve_error_ambiguous() {
1507 use crate::resolver::ResolveError;
1508 let err = ResolveError::Ambiguous {
1509 input: "l".into(),
1510 candidates: vec!["list".into(), "log".into()],
1511 };
1512 let msg = render_resolve_error(&err);
1513 assert!(msg.contains("list") && msg.contains("log"));
1514 }
1515
1516 #[test]
1517 fn test_default_renderer_delegates() {
1518 let cmd = Command::builder("test")
1519 .summary("A test command")
1520 .build()
1521 .unwrap();
1522 let r = DefaultRenderer;
1523 let help = r.render_help(&cmd);
1524 assert!(help.contains("test"));
1525 let md = r.render_markdown(&cmd);
1526 assert!(md.starts_with("# test"));
1527 }
1528
1529 #[test]
1530 fn test_custom_renderer_via_cli() {
1531 struct Upper;
1532 impl Renderer for Upper {
1533 fn render_help(&self, c: &Command) -> String {
1534 render_help(c).to_uppercase()
1535 }
1536 fn render_markdown(&self, c: &Command) -> String {
1537 render_markdown(c)
1538 }
1539 fn render_subcommand_list(&self, cs: &[Command]) -> String {
1540 render_subcommand_list(cs)
1541 }
1542 fn render_ambiguity(&self, i: &str, cs: &[String]) -> String {
1543 render_ambiguity(i, cs)
1544 }
1545 }
1546 let cli = crate::cli::Cli::new(vec![Command::builder("ping").build().unwrap()])
1547 .with_renderer(Upper);
1548 let _ = cli.run(["--help"]);
1550 }
1551
1552 #[test]
1553 fn test_render_completion_bash_contains_program() {
1554 use crate::query::Registry;
1555 let reg = Registry::new(vec![
1556 Command::builder("deploy").build().unwrap(),
1557 Command::builder("status").build().unwrap(),
1558 ]);
1559 let script = render_completion(Shell::Bash, "mytool", ®);
1560 assert!(script.contains("mytool"));
1561 assert!(script.contains("deploy"));
1562 assert!(script.contains("status"));
1563 }
1564
1565 #[test]
1566 fn test_render_completion_zsh_contains_program() {
1567 use crate::query::Registry;
1568 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1569 let script = render_completion(Shell::Zsh, "mytool", ®);
1570 assert!(script.contains("mytool") && script.contains("run"));
1571 }
1572
1573 #[test]
1574 fn test_render_completion_fish_contains_program() {
1575 use crate::query::Registry;
1576 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1577 let script = render_completion(Shell::Fish, "mytool", ®);
1578 assert!(script.contains("mytool") && script.contains("run"));
1579 }
1580
1581 #[test]
1582 fn test_render_completion_bash_includes_flags() {
1583 use crate::query::Registry;
1584 let reg = Registry::new(vec![Command::builder("deploy")
1585 .flag(Flag::builder("env").takes_value().build().unwrap())
1586 .flag(Flag::builder("dry-run").build().unwrap())
1587 .build()
1588 .unwrap()]);
1589 let script = render_completion(Shell::Bash, "t", ®);
1590 assert!(script.contains("--env"));
1591 assert!(script.contains("--dry-run"));
1592 }
1593
1594 #[test]
1595 fn test_render_json_schema_properties() {
1596 let cmd = Command::builder("deploy")
1597 .summary("Deploy a service")
1598 .argument(
1599 Argument::builder("env")
1600 .required()
1601 .description("Target env")
1602 .build()
1603 .unwrap(),
1604 )
1605 .flag(
1606 Flag::builder("dry-run")
1607 .description("Simulate")
1608 .build()
1609 .unwrap(),
1610 )
1611 .flag(
1612 Flag::builder("strategy")
1613 .takes_value()
1614 .choices(["rolling", "canary"])
1615 .build()
1616 .unwrap(),
1617 )
1618 .build()
1619 .unwrap();
1620
1621 let schema = render_json_schema(&cmd).unwrap();
1622 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1623
1624 assert_eq!(v["title"], "deploy");
1625 assert_eq!(v["description"], "Deploy a service");
1626 assert_eq!(v["properties"]["env"]["type"], "string");
1627 assert_eq!(v["properties"]["dry-run"]["type"], "boolean");
1628 assert_eq!(v["properties"]["strategy"]["type"], "string");
1629 assert_eq!(v["properties"]["strategy"]["enum"][0], "rolling");
1630 let req = v["required"].as_array().unwrap();
1631 assert!(req.contains(&serde_json::json!("env")));
1632 }
1633
1634 #[test]
1635 fn test_render_json_schema_empty_command() {
1636 let cmd = Command::builder("ping").build().unwrap();
1637 let schema = render_json_schema(&cmd).unwrap();
1638 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1639 assert_eq!(v["title"], "ping");
1640 assert!(
1641 v["required"].is_null()
1642 || v["required"]
1643 .as_array()
1644 .map(|a| a.is_empty())
1645 .unwrap_or(true)
1646 );
1647 }
1648
1649 #[test]
1650 fn test_render_json_schema_returns_result() {
1651 let cmd = Command::builder("ping").build().unwrap();
1652 let result = render_json_schema(&cmd);
1654 assert!(result.is_ok());
1655 let _: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
1656 }
1657
1658 #[test]
1659 fn test_spellings_not_in_help_output() {
1660 let cmd = Command::builder("deploy")
1661 .alias("release")
1662 .spelling("deply")
1663 .build()
1664 .unwrap();
1665
1666 let help = render_help(&cmd);
1667 assert!(help.contains("release"), "alias should appear in help");
1668 assert!(!help.contains("deply"), "spelling must not appear in help");
1669 }
1670
1671 #[test]
1672 fn test_semantic_aliases_not_in_help_output() {
1673 let cmd = Command::builder("deploy")
1674 .alias("d")
1675 .semantic_alias("release to production")
1676 .semantic_alias("push to environment")
1677 .summary("Deploy a service")
1678 .build()
1679 .unwrap();
1680
1681 let help = render_help(&cmd);
1682 assert!(help.contains("d"), "alias should appear in help");
1683 assert!(
1684 !help.contains("release to production"),
1685 "semantic alias must not appear in help"
1686 );
1687 assert!(
1688 !help.contains("push to environment"),
1689 "semantic alias must not appear in help"
1690 );
1691 }
1692
1693 fn docs_registry() -> crate::query::Registry {
1694 use crate::query::Registry;
1695 Registry::new(vec![
1696 Command::builder("deploy")
1697 .summary("Deploy the application")
1698 .subcommand(
1699 Command::builder("rollback")
1700 .summary("Roll back a deployment")
1701 .build()
1702 .unwrap(),
1703 )
1704 .build()
1705 .unwrap(),
1706 Command::builder("status")
1707 .summary("Show status")
1708 .build()
1709 .unwrap(),
1710 ])
1711 }
1712
1713 #[test]
1714 fn test_render_docs_contains_all_commands() {
1715 let reg = docs_registry();
1716 let docs = render_docs(®);
1717 assert!(docs.contains("# Commands"), "missing top-level heading");
1718 assert!(docs.contains("deploy"), "missing deploy");
1719 assert!(docs.contains("rollback"), "missing rollback");
1720 assert!(docs.contains("status"), "missing status");
1721 assert!(docs.contains("---"), "missing separator");
1722 }
1723
1724 #[test]
1725 fn test_render_docs_table_of_contents_indents_subcommands() {
1726 let reg = docs_registry();
1727 let docs = render_docs(®);
1728 assert!(
1730 docs.contains("\n- [deploy](#deploy)"),
1731 "deploy should be at root indent"
1732 );
1733 assert!(
1735 docs.contains("\n - [deploy rollback](#deploy-rollback)"),
1736 "deploy rollback should be indented"
1737 );
1738 assert!(
1740 docs.contains("\n- [status](#status)"),
1741 "status should be at root indent"
1742 );
1743 }
1744
1745 #[test]
1746 fn test_render_docs_empty_registry() {
1747 use crate::query::Registry;
1748 let reg = Registry::new(vec![]);
1749 let docs = render_docs(®);
1750 assert!(docs.starts_with("# Commands\n\n"));
1751 assert!(!docs.contains("---"));
1753 }
1754
1755 #[test]
1756 fn test_default_renderer_render_docs() {
1757 let reg = docs_registry();
1758 let renderer = DefaultRenderer;
1759 let docs = renderer.render_docs(®);
1760 assert!(docs.contains("# Commands"));
1761 assert!(docs.contains("deploy"));
1762 assert!(docs.contains("status"));
1763 }
1764
1765 #[test]
1766 fn test_render_completion_zsh_with_flags_and_args() {
1767 use crate::query::Registry;
1768 let reg = Registry::new(vec![
1769 Command::builder("deploy")
1770 .summary("Deploy")
1771 .flag(
1772 Flag::builder("env")
1773 .takes_value()
1774 .description("target env")
1775 .build()
1776 .unwrap(),
1777 )
1778 .flag(
1779 Flag::builder("dry-run")
1780 .description("simulate")
1781 .build()
1782 .unwrap(),
1783 )
1784 .argument(Argument::builder("service").required().build().unwrap())
1785 .build()
1786 .unwrap(),
1787 Command::builder("status").build().unwrap(),
1789 ]);
1790 let script = render_completion(Shell::Zsh, "mytool", ®);
1791 assert!(script.contains("mytool"));
1792 assert!(script.contains("deploy"));
1793 assert!(script.contains("--env"));
1794 assert!(script.contains("--dry-run"));
1795 }
1796
1797 #[test]
1798 fn test_render_completion_zsh_empty_summary_uses_canonical() {
1799 use crate::query::Registry;
1800 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1802 let script = render_completion(Shell::Zsh, "mytool", ®);
1803 assert!(script.contains("run:run"));
1805 }
1806
1807 #[test]
1808 fn test_render_completion_fish_with_flags() {
1809 use crate::query::Registry;
1810 let reg = Registry::new(vec![Command::builder("deploy")
1811 .summary("Deploy the app")
1812 .flag(
1813 Flag::builder("env")
1814 .takes_value()
1815 .description("target environment")
1816 .build()
1817 .unwrap(),
1818 )
1819 .flag(
1820 Flag::builder("dry-run")
1821 .description("simulate")
1822 .build()
1823 .unwrap(),
1824 )
1825 .build()
1826 .unwrap()]);
1827 let script = render_completion(Shell::Fish, "mytool", ®);
1828 assert!(script.contains("mytool"));
1829 assert!(script.contains("deploy"));
1830 assert!(script.contains("--env") || script.contains("'env'"));
1831 assert!(script.contains("-r"));
1833 assert!(script.contains("Deploy the app"));
1835 }
1836
1837 #[test]
1838 fn test_render_completion_fish_empty_summary() {
1839 use crate::query::Registry;
1840 let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1841 let script = render_completion(Shell::Fish, "mytool", ®);
1842 assert!(script.contains("run"));
1844 }
1845
1846 #[test]
1847 fn test_render_completion_bash_no_flags_cmd() {
1848 use crate::query::Registry;
1849 let reg = Registry::new(vec![Command::builder("status").build().unwrap()]);
1851 let script = render_completion(Shell::Bash, "app", ®);
1852 assert!(script.contains("status"));
1853 }
1854
1855 #[test]
1856 fn test_render_json_schema_variadic_arg() {
1857 let cmd = Command::builder("run")
1858 .argument(
1859 Argument::builder("files")
1860 .variadic()
1861 .description("Files to process")
1862 .build()
1863 .unwrap(),
1864 )
1865 .build()
1866 .unwrap();
1867 let schema = render_json_schema(&cmd).unwrap();
1868 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1869 assert_eq!(v["properties"]["files"]["type"], "array");
1870 assert_eq!(v["properties"]["files"]["items"]["type"], "string");
1871 assert!(v["properties"]["files"]["description"].as_str().is_some());
1872 }
1873
1874 #[test]
1875 fn test_render_json_schema_flag_with_default() {
1876 let cmd = Command::builder("run")
1877 .flag(
1878 Flag::builder("output")
1879 .takes_value()
1880 .default_value("text")
1881 .description("Output format")
1882 .build()
1883 .unwrap(),
1884 )
1885 .build()
1886 .unwrap();
1887 let schema = render_json_schema(&cmd).unwrap();
1888 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1889 assert_eq!(v["properties"]["output"]["default"], "text");
1890 assert_eq!(v["properties"]["output"]["type"], "string");
1891 }
1892
1893 #[test]
1894 fn test_render_json_schema_required_flag() {
1895 let cmd = Command::builder("deploy")
1896 .flag(
1897 Flag::builder("env")
1898 .takes_value()
1899 .required()
1900 .build()
1901 .unwrap(),
1902 )
1903 .build()
1904 .unwrap();
1905 let schema = render_json_schema(&cmd).unwrap();
1906 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1907 let req = v["required"].as_array().unwrap();
1908 assert!(req.contains(&serde_json::json!("env")));
1909 }
1910
1911 #[test]
1912 fn test_render_json_schema_arg_with_default() {
1913 let cmd = Command::builder("run")
1914 .argument(
1915 Argument::builder("target")
1916 .default_value("prod")
1917 .build()
1918 .unwrap(),
1919 )
1920 .build()
1921 .unwrap();
1922 let schema = render_json_schema(&cmd).unwrap();
1923 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1924 assert_eq!(v["properties"]["target"]["default"], "prod");
1925 }
1926
1927 #[test]
1928 fn test_render_help_output_in_example() {
1929 let cmd = Command::builder("run")
1931 .example(Example::new("Run example", "myapp run").with_output("OK"))
1932 .build()
1933 .unwrap();
1934 let help = render_help(&cmd);
1935 assert!(help.contains("# Output: OK"));
1936 }
1937
1938 #[test]
1939 fn test_render_markdown_with_best_practices_and_anti_patterns() {
1940 let cmd = Command::builder("deploy")
1941 .best_practice("Always dry-run first")
1942 .anti_pattern("Deploy on Fridays")
1943 .build()
1944 .unwrap();
1945 let md = render_markdown(&cmd);
1946 assert!(md.contains("## Best Practices"));
1947 assert!(md.contains("Always dry-run first"));
1948 assert!(md.contains("## Anti-Patterns"));
1949 assert!(md.contains("Deploy on Fridays"));
1950 }
1951
1952 #[test]
1953 fn test_render_markdown_with_subcommands() {
1954 let cmd = Command::builder("remote")
1955 .subcommand(
1956 Command::builder("add")
1957 .summary("Add remote")
1958 .build()
1959 .unwrap(),
1960 )
1961 .build()
1962 .unwrap();
1963 let md = render_markdown(&cmd);
1964 assert!(md.contains("## Subcommands"));
1965 assert!(md.contains("**add**"));
1966 }
1967
1968 fn skill_full_command() -> Command {
1973 Command::builder("deploy")
1974 .summary("Deploy the application")
1975 .description("Deploys the app to the target environment.")
1976 .argument(
1977 Argument::builder("env")
1978 .description("Target environment")
1979 .required()
1980 .build()
1981 .unwrap(),
1982 )
1983 .flag(
1984 Flag::builder("dry-run")
1985 .short('n')
1986 .description("Simulate without changes")
1987 .build()
1988 .unwrap(),
1989 )
1990 .flag(
1991 Flag::builder("strategy")
1992 .takes_value()
1993 .default_value("rolling")
1994 .description("Rollout strategy")
1995 .build()
1996 .unwrap(),
1997 )
1998 .subcommand(
1999 Command::builder("rollback")
2000 .summary("Roll back a deployment")
2001 .build()
2002 .unwrap(),
2003 )
2004 .example(Example::new("deploy to prod", "deploy prod"))
2005 .example(
2006 Example::new("dry-run deploy", "deploy prod --dry-run")
2007 .with_output("Would deploy to prod"),
2008 )
2009 .best_practice("always dry-run first")
2010 .best_practice("pin the image tag")
2011 .anti_pattern("deploy on Friday")
2012 .anti_pattern("skip the dry-run")
2013 .build()
2014 .unwrap()
2015 }
2016
2017 #[test]
2018 fn test_render_skill_file_heading() {
2019 let cmd = skill_full_command();
2020 let skill = render_skill_file(&cmd);
2021 assert!(
2022 skill.starts_with("# Skill: deploy\n"),
2023 "skill file must start with '# Skill: deploy'"
2024 );
2025 }
2026
2027 #[test]
2028 fn test_render_skill_file_summary_and_description() {
2029 let cmd = skill_full_command();
2030 let skill = render_skill_file(&cmd);
2031 assert!(skill.contains("Deploy the application"), "missing summary");
2032 assert!(
2033 skill.contains("Deploys the app to the target environment."),
2034 "missing description"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_render_skill_file_safe_usage_section() {
2040 let cmd = skill_full_command();
2041 let skill = render_skill_file(&cmd);
2042 assert!(skill.contains("## Safe Usage"), "missing Safe Usage section");
2043 assert!(skill.contains("Always prefer:"), "missing 'Always prefer:' line");
2044 assert!(
2045 skill.contains("- always dry-run first"),
2046 "missing first best practice"
2047 );
2048 assert!(
2049 skill.contains("- pin the image tag"),
2050 "missing second best practice"
2051 );
2052 }
2053
2054 #[test]
2055 fn test_render_skill_file_avoid_section() {
2056 let cmd = skill_full_command();
2057 let skill = render_skill_file(&cmd);
2058 assert!(skill.contains("## Avoid"), "missing Avoid section");
2059 assert!(
2060 skill.contains("- deploy on Friday"),
2061 "missing first anti-pattern"
2062 );
2063 assert!(
2064 skill.contains("- skip the dry-run"),
2065 "missing second anti-pattern"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_render_skill_file_arguments_table() {
2071 let cmd = skill_full_command();
2072 let skill = render_skill_file(&cmd);
2073 assert!(skill.contains("## Arguments"), "missing Arguments section");
2074 assert!(
2075 skill.contains("| env | yes | Target environment |"),
2076 "missing env argument row"
2077 );
2078 }
2079
2080 #[test]
2081 fn test_render_skill_file_flags_table() {
2082 let cmd = skill_full_command();
2083 let skill = render_skill_file(&cmd);
2084 assert!(skill.contains("## Flags"), "missing Flags section");
2085 assert!(
2087 skill.contains("| --dry-run | -n | no | — | Simulate without changes |"),
2088 "missing dry-run flag row"
2089 );
2090 assert!(
2092 skill.contains("| --strategy | — | no | rolling | Rollout strategy |"),
2093 "missing strategy flag row"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_render_skill_file_examples_section() {
2099 let cmd = skill_full_command();
2100 let skill = render_skill_file(&cmd);
2101 assert!(skill.contains("## Examples"), "missing Examples section");
2102 assert!(skill.contains("```\ndeploy prod\n```"), "missing first example code block");
2103 assert!(skill.contains("> deploy to prod"), "missing first example description");
2104 assert!(
2105 skill.contains("```\ndeploy prod --dry-run\n```"),
2106 "missing second example code block"
2107 );
2108 assert!(
2109 skill.contains("> dry-run deploy"),
2110 "missing second example description"
2111 );
2112 }
2113
2114 #[test]
2115 fn test_render_skill_file_subcommands_section() {
2116 let cmd = skill_full_command();
2117 let skill = render_skill_file(&cmd);
2118 assert!(skill.contains("## Subcommands"), "missing Subcommands section");
2119 assert!(
2120 skill.contains("- `rollback` — Roll back a deployment"),
2121 "missing rollback subcommand entry"
2122 );
2123 }
2124
2125 #[test]
2126 fn test_render_skill_file_omits_empty_sections() {
2127 let cmd = Command::builder("ping")
2129 .summary("Check connectivity")
2130 .build()
2131 .unwrap();
2132 let skill = render_skill_file(&cmd);
2133 assert!(skill.contains("# Skill: ping"), "missing heading");
2134 assert!(skill.contains("Check connectivity"), "missing summary");
2135 assert!(!skill.contains("## Safe Usage"), "Safe Usage must be omitted");
2136 assert!(!skill.contains("## Avoid"), "Avoid must be omitted");
2137 assert!(!skill.contains("## Arguments"), "Arguments must be omitted");
2138 assert!(!skill.contains("## Flags"), "Flags must be omitted");
2139 assert!(!skill.contains("## Examples"), "Examples must be omitted");
2140 assert!(!skill.contains("## Subcommands"), "Subcommands must be omitted");
2141 }
2142
2143 #[test]
2144 fn test_render_skill_file_no_summary_or_description() {
2145 let cmd = Command::builder("ping").build().unwrap();
2146 let skill = render_skill_file(&cmd);
2147 assert!(skill.starts_with("# Skill: ping\n"));
2149 }
2150
2151 #[test]
2152 fn test_render_skill_file_flag_required_shown() {
2153 let cmd = Command::builder("deploy")
2154 .flag(
2155 Flag::builder("env")
2156 .takes_value()
2157 .required()
2158 .description("Target environment")
2159 .build()
2160 .unwrap(),
2161 )
2162 .build()
2163 .unwrap();
2164 let skill = render_skill_file(&cmd);
2165 assert!(
2166 skill.contains("| --env | — | yes | — | Target environment |"),
2167 "required flag must show 'yes' in Required column"
2168 );
2169 }
2170
2171 fn skill_registry() -> crate::query::Registry {
2176 use crate::query::Registry;
2177 Registry::new(vec![
2178 Command::builder("deploy")
2179 .summary("Deploy the application")
2180 .best_practice("always dry-run first")
2181 .subcommand(
2182 Command::builder("rollback")
2183 .summary("Roll back a deployment")
2184 .build()
2185 .unwrap(),
2186 )
2187 .build()
2188 .unwrap(),
2189 Command::builder("status")
2190 .summary("Show status")
2191 .build()
2192 .unwrap(),
2193 ])
2194 }
2195
2196 #[test]
2197 fn test_render_skill_files_contains_all_commands() {
2198 let reg = skill_registry();
2199 let skills = render_skill_files(®);
2200 assert!(skills.contains("# Skill: deploy"), "missing deploy skill");
2201 assert!(skills.contains("# Skill: rollback"), "missing rollback skill");
2202 assert!(skills.contains("# Skill: status"), "missing status skill");
2203 }
2204
2205 #[test]
2206 fn test_render_skill_files_separated_by_separator() {
2207 let reg = skill_registry();
2208 let skills = render_skill_files(®);
2209 assert!(skills.contains("---\n\n"), "skill files must be separated by '---'");
2210 }
2211
2212 #[test]
2213 fn test_render_skill_files_empty_registry() {
2214 use crate::query::Registry;
2215 let reg = Registry::new(vec![]);
2216 let skills = render_skill_files(®);
2217 assert!(skills.is_empty(), "empty registry must yield empty skill files string");
2219 }
2220
2221 #[test]
2222 fn test_render_skill_files_single_command_no_separator() {
2223 use crate::query::Registry;
2224 let reg = Registry::new(vec![
2225 Command::builder("ping").summary("Ping").build().unwrap(),
2226 ]);
2227 let skills = render_skill_files(®);
2228 assert!(skills.contains("# Skill: ping"));
2229 assert!(!skills.contains("---"), "single command must not produce separator");
2231 }
2232
2233 #[test]
2234 fn test_default_renderer_render_skill_file() {
2235 let cmd = Command::builder("deploy")
2236 .summary("Deploy")
2237 .best_practice("dry-run first")
2238 .build()
2239 .unwrap();
2240 let renderer = DefaultRenderer;
2241 let skill = renderer.render_skill_file(&cmd);
2242 assert!(skill.contains("# Skill: deploy"));
2243 assert!(skill.contains("## Safe Usage"));
2244 }
2245
2246 #[test]
2247 fn test_default_renderer_render_skill_files() {
2248 let reg = skill_registry();
2249 let renderer = DefaultRenderer;
2250 let skills = renderer.render_skill_files(®);
2251 assert!(skills.contains("# Skill: deploy"));
2252 assert!(skills.contains("# Skill: status"));
2253 }
2254
2255 #[test]
2260 fn test_render_help_mutating_shows_warning() {
2261 let cmd = Command::builder("delete")
2262 .summary("Delete a resource")
2263 .mutating()
2264 .build()
2265 .unwrap();
2266 let help = render_help(&cmd);
2267 assert!(
2268 help.contains("MUTATING COMMAND"),
2269 "help should contain MUTATING COMMAND notice"
2270 );
2271 assert!(
2272 help.contains("Consider adding --dry-run support"),
2273 "help should suggest --dry-run when flag is absent"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_render_help_mutating_with_dry_run_no_note() {
2279 let cmd = Command::builder("delete")
2280 .summary("Delete a resource")
2281 .flag(Flag::builder("dry-run").description("Simulate only").build().unwrap())
2282 .mutating()
2283 .build()
2284 .unwrap();
2285 let help = render_help(&cmd);
2286 assert!(
2287 help.contains("MUTATING COMMAND"),
2288 "help should still show MUTATING COMMAND"
2289 );
2290 assert!(
2291 !help.contains("Consider adding --dry-run support"),
2292 "help should not suggest --dry-run when flag is already present"
2293 );
2294 }
2295
2296 #[test]
2297 fn test_render_help_non_mutating_no_warning() {
2298 let cmd = Command::builder("list")
2299 .summary("List resources")
2300 .build()
2301 .unwrap();
2302 let help = render_help(&cmd);
2303 assert!(
2304 !help.contains("MUTATING COMMAND"),
2305 "non-mutating command should not show warning"
2306 );
2307 }
2308
2309 #[test]
2310 fn test_render_markdown_mutating_blockquote() {
2311 let cmd = Command::builder("delete")
2312 .summary("Delete a resource")
2313 .mutating()
2314 .build()
2315 .unwrap();
2316 let md = render_markdown(&cmd);
2317 assert!(
2318 md.contains("> ⚠ **Mutating command**"),
2319 "markdown should contain mutating blockquote"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_render_markdown_non_mutating_no_blockquote() {
2325 let cmd = Command::builder("list")
2326 .summary("List resources")
2327 .build()
2328 .unwrap();
2329 let md = render_markdown(&cmd);
2330 assert!(
2331 !md.contains("> ⚠ **Mutating command**"),
2332 "non-mutating command should not have mutating blockquote"
2333 );
2334 }
2335
2336 #[test]
2337 fn test_render_json_schema_mutating_flag_in_schema() {
2338 let cmd = Command::builder("delete")
2339 .summary("Delete a resource")
2340 .mutating()
2341 .build()
2342 .unwrap();
2343 let schema = render_json_schema(&cmd).unwrap();
2344 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
2345 assert_eq!(
2346 v["mutating"],
2347 serde_json::json!(true),
2348 "JSON schema should include mutating:true"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_render_json_schema_non_mutating_no_flag() {
2354 let cmd = Command::builder("list").build().unwrap();
2355 let schema = render_json_schema(&cmd).unwrap();
2356 let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
2357 assert!(
2358 v["mutating"].is_null(),
2359 "non-mutating command should not have mutating key in schema"
2360 );
2361 }
2362
2363 #[test]
2366 fn test_skill_frontmatter_all_fields() {
2367 let cmd = Command::builder("deploy")
2368 .summary("Deploy the app")
2369 .build()
2370 .unwrap();
2371 let fm = super::SkillFrontmatter::new("mytool-deploy")
2372 .version("1.2.3")
2373 .description("Custom description")
2374 .requires_bin("mytool")
2375 .requires_bin("jq")
2376 .extra("min_role", serde_json::json!("ops"))
2377 .extra("priority", serde_json::json!(42));
2378
2379 let text = super::render_frontmatter(&fm, &cmd);
2380
2381 assert!(text.starts_with("---\n"), "must start with ---");
2382 assert!(text.ends_with("---\n"), "must end with ---");
2383 assert!(text.contains("name: mytool-deploy\n"));
2384 assert!(text.contains("version: 1.2.3\n"));
2385 assert!(text.contains("description: Custom description\n"));
2386 assert!(text.contains("requires_bins:\n"));
2387 assert!(text.contains(" - mytool\n"));
2388 assert!(text.contains(" - jq\n"));
2389 assert!(text.contains("extra:\n"));
2390 assert!(text.contains(" min_role:"));
2392 assert!(text.contains(" priority:"));
2393 }
2394
2395 #[test]
2396 fn test_skill_frontmatter_version_none_omits_line() {
2397 let cmd = Command::builder("ping").build().unwrap();
2398 let fm = super::SkillFrontmatter::new("ping");
2399 let text = super::render_frontmatter(&fm, &cmd);
2400 assert!(!text.contains("version:"), "version line must be omitted");
2401 }
2402
2403 #[test]
2404 fn test_skill_frontmatter_requires_bins_empty_omits_block() {
2405 let cmd = Command::builder("ping").build().unwrap();
2406 let fm = super::SkillFrontmatter::new("ping");
2407 let text = super::render_frontmatter(&fm, &cmd);
2408 assert!(
2409 !text.contains("requires_bins:"),
2410 "requires_bins block must be omitted"
2411 );
2412 }
2413
2414 #[test]
2415 fn test_skill_frontmatter_extra_empty_omits_block() {
2416 let cmd = Command::builder("ping").build().unwrap();
2417 let fm = super::SkillFrontmatter::new("ping");
2418 let text = super::render_frontmatter(&fm, &cmd);
2419 assert!(!text.contains("extra:"), "extra block must be omitted");
2420 }
2421
2422 #[test]
2423 fn test_skill_frontmatter_description_falls_back_to_cmd_summary() {
2424 let cmd = Command::builder("deploy")
2425 .summary("Deploy the application")
2426 .build()
2427 .unwrap();
2428 let fm = super::SkillFrontmatter::new("mytool-deploy");
2430 let text = super::render_frontmatter(&fm, &cmd);
2431 assert!(
2432 text.contains("description: Deploy the application\n"),
2433 "should fall back to cmd summary"
2434 );
2435 }
2436
2437 #[test]
2438 fn test_skill_frontmatter_description_explicit_overrides_summary() {
2439 let cmd = Command::builder("deploy")
2440 .summary("Deploy the application")
2441 .build()
2442 .unwrap();
2443 let fm = super::SkillFrontmatter::new("mytool-deploy")
2444 .description("My custom description");
2445 let text = super::render_frontmatter(&fm, &cmd);
2446 assert!(text.contains("description: My custom description\n"));
2447 assert!(!text.contains("Deploy the application"));
2448 }
2449
2450 #[test]
2451 fn test_render_skill_file_with_frontmatter_starts_with_dashes() {
2452 let cmd = Command::builder("deploy")
2453 .summary("Deploy the app")
2454 .build()
2455 .unwrap();
2456 let fm = super::SkillFrontmatter::new("mytool-deploy").version("1.0.0");
2457 let skill = render_skill_file_with_frontmatter(&cmd, &fm);
2458 assert!(
2459 skill.starts_with("---\n"),
2460 "skill file with frontmatter must start with ---\\n"
2461 );
2462 assert!(skill.contains("name: mytool-deploy\n"));
2463 assert!(skill.contains("version: 1.0.0\n"));
2464 assert!(skill.contains("# Skill: deploy"));
2465 }
2466
2467 #[test]
2468 fn test_render_skill_file_basic() {
2469 let cmd = Command::builder("deploy")
2470 .summary("Deploy the app")
2471 .build()
2472 .unwrap();
2473 let skill = render_skill_file(&cmd);
2474 assert!(skill.starts_with("# Skill: deploy"));
2475 assert!(skill.contains("Deploy the app"));
2476 }
2477
2478 #[test]
2479 fn test_render_skill_files_with_frontmatter_none_falls_back_to_plain() {
2480 use crate::query::Registry;
2481 let registry = Registry::new(vec![
2482 Command::builder("deploy").summary("Deploy").build().unwrap(),
2483 Command::builder("status").summary("Status").build().unwrap(),
2484 ]);
2485 let output = render_skill_files_with_frontmatter(®istry, |cmd| {
2487 if cmd.canonical == "deploy" {
2488 Some(super::SkillFrontmatter::new("mytool-deploy"))
2489 } else {
2490 None
2491 }
2492 });
2493 assert!(output.contains("name: mytool-deploy"), "deploy has frontmatter");
2494 assert!(output.contains("# Skill: deploy"));
2495 assert!(output.contains("# Skill: status"));
2496 let status_part = output
2498 .split("# Skill: status")
2499 .next()
2500 .unwrap_or("")
2501 .rsplit("---")
2502 .next()
2503 .unwrap_or("");
2504 assert!(
2505 !status_part.contains("name: mytool-status"),
2506 "status must not have frontmatter"
2507 );
2508 }
2509
2510 #[test]
2511 fn test_render_skill_files_with_frontmatter_all_with_fm() {
2512 use crate::query::Registry;
2513 let registry = Registry::new(vec![
2514 Command::builder("deploy").summary("Deploy").build().unwrap(),
2515 Command::builder("status").summary("Status").build().unwrap(),
2516 ]);
2517 let output = render_skill_files_with_frontmatter(®istry, |cmd| {
2518 Some(super::SkillFrontmatter::new(format!("tool-{}", cmd.canonical)))
2519 });
2520 assert!(output.contains("name: tool-deploy"));
2521 assert!(output.contains("name: tool-status"));
2522 }
2523
2524 #[test]
2525 fn test_default_renderer_skill_frontmatter_delegation() {
2526 use crate::query::Registry;
2527 let cmd = Command::builder("deploy")
2528 .summary("Deploy the app")
2529 .build()
2530 .unwrap();
2531 let registry = Registry::new(vec![cmd.clone()]);
2532 let renderer = DefaultRenderer;
2533
2534 let fm = super::SkillFrontmatter::new("mytool-deploy").version("1.0.0");
2535 let single = renderer.render_skill_file_with_frontmatter(&cmd, &fm);
2536 assert!(single.starts_with("---\n"));
2537 assert!(single.contains("name: mytool-deploy"));
2538
2539 let all = renderer.render_skill_files_with_frontmatter_boxed(®istry, &|c| {
2540 Some(super::SkillFrontmatter::new(format!("t-{}", c.canonical)))
2541 });
2542 assert!(all.contains("name: t-deploy"));
2543 }
2544}