Skip to main content

argot_cmd/render/
mod.rs

1//! Human-readable and Markdown renderers for commands.
2//!
3//! This module exposes three rendering functions and one disambiguation helper:
4//!
5//! - **[`render_help`]** — produces a multi-section plain-text help page
6//!   (NAME, SUMMARY, DESCRIPTION, USAGE, ARGUMENTS, FLAGS, SUBCOMMANDS,
7//!   EXAMPLES, BEST PRACTICES, ANTI-PATTERNS). Sections that have no content
8//!   are omitted.
9//!
10//! - **[`render_subcommand_list`]** — produces a compact two-column listing of
11//!   `canonical  summary` lines, suitable for a top-level `--help` display.
12//!
13//! - **[`render_markdown`]** — produces a GitHub-flavored Markdown page with
14//!   `##` headings, argument/flag tables, and fenced code blocks for examples.
15//!
16//! - **[`render_ambiguity`]** — formats a human-readable message when a
17//!   command token is ambiguous.
18//!
19//! - **[`render_docs`]** — produces a full Markdown reference document for all
20//!   commands in a [`crate::query::Registry`], with a table of contents and
21//!   per-command sections separated by `---`.
22//!
23//! - **[`render_skill_file`]** — produces a structured Markdown skill file for
24//!   a single command, encoding best practices, anti-patterns, and examples in
25//!   a format suitable for loading into an AI agent context (e.g.
26//!   `.claude/commands/`).
27//!
28//! - **[`render_skill_files`]** — calls [`render_skill_file`] on every command
29//!   in a [`crate::query::Registry`] (depth-first) and concatenates the results
30//!   separated by `---`.
31//!
32//! - **[`render_skill_file_with_frontmatter`]** — produces an agent-consumable
33//!   Markdown skill file with YAML frontmatter for a single command.
34//!
35//! - **[`render_skill_files_with_frontmatter`]** — renders all skill files in a
36//!   registry, each optionally prepended with YAML frontmatter.
37//!
38//! None of the functions print to stdout/stderr directly; all return a
39//! `String` that the caller can write wherever appropriate.
40
41use crate::model::Command;
42
43/// Optional YAML frontmatter to prepend to a skill file.
44///
45/// When provided to [`render_skill_file_with_frontmatter`], the frontmatter
46/// is serialized as a YAML block between `---` delimiters and prepended
47/// to the Markdown content.
48///
49/// # Example output
50///
51/// ```text
52/// ---
53/// name: deploy
54/// version: 1.0.0
55/// description: Deploy the application
56/// requires_bins:
57///   - mytool
58/// extra:
59///   min_role: "ops"
60/// ---
61///
62/// # Skill: deploy
63/// ...
64/// ```
65///
66/// # Examples
67///
68/// ```
69/// use argot_cmd::render::SkillFrontmatter;
70///
71/// let fm = SkillFrontmatter::new("mytool-deploy")
72///     .version("1.0.0")
73///     .description("Deploy the application")
74///     .requires_bin("mytool");
75///
76/// assert_eq!(fm.name, "mytool-deploy");
77/// assert_eq!(fm.version.as_deref(), Some("1.0.0"));
78/// assert_eq!(fm.requires_bins, vec!["mytool"]);
79/// ```
80#[derive(Debug, Clone)]
81pub struct SkillFrontmatter {
82    /// Skill identifier (e.g. `"mytool-deploy"`). Required.
83    pub name: String,
84    /// Semantic version string (e.g. `"1.0.0"`). Optional.
85    pub version: Option<String>,
86    /// Human-readable description. Optional. Falls back to the command's
87    /// `summary` field when `None` is passed to a render function.
88    pub description: Option<String>,
89    /// Binaries required to use this skill (e.g. `["mytool"]`). Optional.
90    pub requires_bins: Vec<String>,
91    /// Arbitrary extra key/value metadata included under an `extra:` key.
92    /// Values are [`serde_json::Value`].
93    pub extra: std::collections::HashMap<String, serde_json::Value>,
94}
95
96impl SkillFrontmatter {
97    /// Create a new `SkillFrontmatter` with only a required `name`.
98    ///
99    /// All other fields default to `None` / empty.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use argot_cmd::render::SkillFrontmatter;
105    ///
106    /// let fm = SkillFrontmatter::new("my-skill");
107    /// assert_eq!(fm.name, "my-skill");
108    /// assert!(fm.version.is_none());
109    /// ```
110    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    /// Set the semantic version string (builder style).
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use argot_cmd::render::SkillFrontmatter;
126    ///
127    /// let fm = SkillFrontmatter::new("my-skill").version("2.0.0");
128    /// assert_eq!(fm.version.as_deref(), Some("2.0.0"));
129    /// ```
130    pub fn version(mut self, v: impl Into<String>) -> Self {
131        self.version = Some(v.into());
132        self
133    }
134
135    /// Set the human-readable description (builder style).
136    ///
137    /// When not set, [`render_skill_file_with_frontmatter`] falls back to
138    /// the command's `summary` field.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use argot_cmd::render::SkillFrontmatter;
144    ///
145    /// let fm = SkillFrontmatter::new("my-skill").description("Does things");
146    /// assert_eq!(fm.description.as_deref(), Some("Does things"));
147    /// ```
148    pub fn description(mut self, d: impl Into<String>) -> Self {
149        self.description = Some(d.into());
150        self
151    }
152
153    /// Append a required binary to `requires_bins` (builder style).
154    ///
155    /// May be called multiple times to add several binaries.
156    ///
157    /// # Examples
158    ///
159    /// ```
160    /// use argot_cmd::render::SkillFrontmatter;
161    ///
162    /// let fm = SkillFrontmatter::new("my-skill")
163    ///     .requires_bin("mytool")
164    ///     .requires_bin("jq");
165    /// assert_eq!(fm.requires_bins, vec!["mytool", "jq"]);
166    /// ```
167    pub fn requires_bin(mut self, bin: impl Into<String>) -> Self {
168        self.requires_bins.push(bin.into());
169        self
170    }
171
172    /// Insert an arbitrary key/value pair into `extra` (builder style).
173    ///
174    /// Values are [`serde_json::Value`] so they can represent any JSON-compatible
175    /// type. They are serialized as compact inline JSON in the frontmatter output.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use argot_cmd::render::SkillFrontmatter;
181    ///
182    /// let fm = SkillFrontmatter::new("my-skill")
183    ///     .extra("min_role", serde_json::json!("ops"));
184    /// assert_eq!(fm.extra["min_role"], serde_json::json!("ops"));
185    /// ```
186    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
192/// Serialize a [`SkillFrontmatter`] into a YAML block delimited by `---`.
193///
194/// The serialization is hand-written (no external YAML crate). Fields that are
195/// `None` or empty are omitted.  Extra values are serialized as compact inline
196/// JSON.
197///
198/// The returned string always starts with `---\n` and ends with `---\n`.
199fn 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    // description: use explicit value, fall back to cmd.summary
209    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        // Sort keys for deterministic output.
234        let mut keys: Vec<&String> = fm.extra.keys().collect();
235        keys.sort();
236        for key in keys {
237            let value = &fm.extra[key];
238            // Serialize as compact inline JSON.
239            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
248/// Render a skill file with YAML frontmatter prepended.
249///
250/// The frontmatter is serialized as a YAML block between `---` delimiters and
251/// prepended to the Markdown content produced by [`render_skill_file`].
252///
253/// When `frontmatter.description` is `None`, the command's `summary` field is
254/// used as the `description:` value in the frontmatter.
255///
256/// # Examples
257///
258/// ```
259/// use argot_cmd::{Command, render::{render_skill_file_with_frontmatter, SkillFrontmatter}};
260///
261/// let cmd = Command::builder("deploy")
262///     .summary("Deploy the application")
263///     .build()
264///     .unwrap();
265///
266/// let fm = SkillFrontmatter::new("mytool-deploy")
267///     .version("1.0.0")
268///     .requires_bin("mytool");
269///
270/// let skill = render_skill_file_with_frontmatter(&cmd, &fm);
271/// assert!(skill.starts_with("---\n"));
272/// assert!(skill.contains("name: mytool-deploy"));
273/// assert!(skill.contains("version: 1.0.0"));
274/// assert!(skill.contains("# Skill: deploy"));
275/// ```
276pub 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
282/// Render all skill files in the registry, each optionally with its own frontmatter.
283///
284/// `frontmatter_fn` is called with each [`Command`] to produce its frontmatter.
285/// Return `None` to omit frontmatter for that command, falling back to plain
286/// [`render_skill_file`] output. Skill files are separated by `---` lines.
287///
288/// # Examples
289///
290/// ```
291/// use argot_cmd::{Command, Registry, render::{render_skill_files_with_frontmatter, SkillFrontmatter}};
292///
293/// let registry = Registry::new(vec![
294///     Command::builder("deploy").summary("Deploy").build().unwrap(),
295///     Command::builder("status").summary("Show status").build().unwrap(),
296/// ]);
297///
298/// let output = render_skill_files_with_frontmatter(&registry, |cmd| {
299///     Some(SkillFrontmatter::new(format!("mytool-{}", cmd.canonical)))
300/// });
301///
302/// assert!(output.contains("name: mytool-deploy"));
303/// assert!(output.contains("name: mytool-status"));
304/// assert!(output.contains("# Skill: deploy"));
305/// assert!(output.contains("# Skill: status"));
306/// ```
307pub 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/// A supported shell for completion script generation.
330///
331/// Used with [`render_completion`].
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
333pub enum Shell {
334    /// Bash (Bourne Again Shell)
335    Bash,
336    /// Zsh (Z Shell)
337    Zsh,
338    /// Fish shell
339    Fish,
340}
341
342/// A pluggable renderer for command help, Markdown docs, and disambiguation messages.
343///
344/// Implement this trait to fully customize how argot formats its output.
345/// Use [`crate::Cli::with_renderer`] to inject your implementation.
346///
347/// A [`DefaultRenderer`] is provided that delegates to the module-level free
348/// functions ([`render_help`], [`render_markdown`], etc.).
349///
350/// # Examples
351///
352/// ```
353/// # use argot_cmd::{Command, render::Renderer};
354/// struct UppercaseRenderer;
355///
356/// impl Renderer for UppercaseRenderer {
357///     fn render_help(&self, command: &Command) -> String {
358///         argot_cmd::render_help(command).to_uppercase()
359///     }
360///     fn render_markdown(&self, command: &Command) -> String {
361///         argot_cmd::render_markdown(command)
362///     }
363///     fn render_subcommand_list(&self, commands: &[Command]) -> String {
364///         argot_cmd::render_subcommand_list(commands)
365///     }
366///     fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String {
367///         argot_cmd::render_ambiguity(input, candidates)
368///     }
369/// }
370/// ```
371pub trait Renderer: Send + Sync {
372    /// Render a plain-text help page for a command.
373    fn render_help(&self, command: &crate::model::Command) -> String;
374    /// Render a Markdown documentation page for a command.
375    fn render_markdown(&self, command: &crate::model::Command) -> String;
376    /// Render a compact listing of multiple commands.
377    fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String;
378    /// Render a disambiguation message for an ambiguous command token.
379    fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String;
380    /// Render a full Markdown reference document for all commands in a registry.
381    ///
382    /// Produces a `# Commands` heading, a table of contents with depth-based
383    /// indentation, and per-command Markdown sections separated by `---`.
384    fn render_docs(&self, registry: &crate::query::Registry) -> String {
385        render_docs(registry)
386    }
387    /// Render a structured Markdown skill file for a single command.
388    ///
389    /// Skill files encode invariants, gotchas, best practices, anti-patterns,
390    /// and examples in a format suitable for loading into an AI agent context
391    /// (e.g. `.claude/commands/`). Sections with no content are omitted.
392    fn render_skill_file(&self, command: &crate::model::Command) -> String {
393        render_skill_file(command)
394    }
395    /// Render skill files for all commands in a registry.
396    ///
397    /// Calls [`render_skill_file`] on every command in depth-first order and
398    /// concatenates the results separated by `---\n\n`.
399    fn render_skill_files(&self, registry: &crate::query::Registry) -> String {
400        render_skill_files(registry)
401    }
402
403    /// Render a skill file with YAML frontmatter prepended.
404    ///
405    /// The default implementation delegates to
406    /// [`render_skill_file_with_frontmatter`].
407    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    /// Render all skill files in the registry, each optionally with frontmatter.
416    ///
417    /// `frontmatter_fn` is called with each command; returning `None` falls
418    /// back to plain skill file output for that command.
419    ///
420    /// The default implementation delegates to
421    /// [`render_skill_files_with_frontmatter`].
422    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/// The default renderer. Delegates to the module-level free functions.
432///
433/// This is used by [`crate::Cli`] unless overridden with [`crate::Cli::with_renderer`].
434#[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
475/// Render a human-readable help page for a command.
476///
477/// The output contains the following sections (each omitted when empty):
478/// NAME, SUMMARY, DESCRIPTION, USAGE, ARGUMENTS, FLAGS, SUBCOMMANDS,
479/// EXAMPLES, BEST PRACTICES, ANTI-PATTERNS.
480///
481/// # Arguments
482///
483/// - `command` — The command to render help for.
484///
485/// # Examples
486///
487/// ```
488/// # use argot_cmd::{Command, render_help};
489/// let cmd = Command::builder("greet")
490///     .summary("Say hello")
491///     .build()
492///     .unwrap();
493///
494/// let help = render_help(&cmd);
495/// assert!(help.contains("NAME"));
496/// assert!(help.contains("greet"));
497/// assert!(help.contains("SUMMARY"));
498/// ```
499pub fn render_help(command: &Command) -> String {
500    let mut out = String::new();
501
502    // NAME
503    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
591/// Render a compact listing of multiple commands (e.g. for a top-level help).
592///
593/// Each line has the format `  canonical  summary`. This is suitable for
594/// displaying all registered commands when no specific command is requested.
595///
596/// # Arguments
597///
598/// - `commands` — The commands to list.
599///
600/// # Examples
601///
602/// ```
603/// # use argot_cmd::{Command, render_subcommand_list};
604/// let cmds = vec![
605///     Command::builder("list").summary("List items").build().unwrap(),
606///     Command::builder("get").summary("Get an item").build().unwrap(),
607/// ];
608///
609/// let listing = render_subcommand_list(&cmds);
610/// assert!(listing.contains("list"));
611/// assert!(listing.contains("List items"));
612/// ```
613pub 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
621/// Render a Markdown documentation page for a command.
622///
623/// The output is GitHub-flavored Markdown with:
624/// - A `# canonical` top-level heading.
625/// - `##` headings for Description, Usage, Arguments, Flags, Subcommands,
626///   Examples, Best Practices, and Anti-Patterns.
627/// - Arguments and flags rendered as Markdown tables.
628/// - Usage and examples rendered as fenced code blocks.
629///
630/// Empty sections are omitted.
631///
632/// # Arguments
633///
634/// - `command` — The command to render documentation for.
635///
636/// # Examples
637///
638/// ```
639/// # use argot_cmd::{Command, render_markdown};
640/// let cmd = Command::builder("deploy")
641///     .summary("Deploy the app")
642///     .build()
643///     .unwrap();
644///
645/// let md = render_markdown(&cmd);
646/// assert!(md.starts_with("# deploy"));
647/// assert!(md.contains("Deploy the app"));
648/// ```
649pub 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
737/// Render a human-readable disambiguation message.
738///
739/// Used when a command token matches more than one candidate as a prefix.
740/// The message lists all candidate canonical names so the user or agent can
741/// choose the intended command.
742///
743/// # Arguments
744///
745/// - `input` — The ambiguous token as typed by the user.
746/// - `candidates` — Canonical names of all matching commands.
747///
748/// # Examples
749///
750/// ```
751/// # use argot_cmd::render_ambiguity;
752/// let msg = render_ambiguity("l", &["list".to_string(), "log".to_string()]);
753/// assert!(msg.contains("Ambiguous command"));
754/// assert!(msg.contains("list"));
755/// assert!(msg.contains("log"));
756/// ```
757pub 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
769/// Render any [`crate::ResolveError`] as a human-readable string.
770///
771/// - [`crate::ResolveError::Ambiguous`] — delegates to [`render_ambiguity`].
772/// - [`crate::ResolveError::Unknown`] with suggestions — formats a
773///   "Did you mean?" message.
774/// - [`crate::ResolveError::Unknown`] without suggestions — formats a
775///   plain "Unknown command" message.
776///
777/// # Examples
778///
779/// ```
780/// # use argot_cmd::{Command, Resolver};
781/// # use argot_cmd::render::render_resolve_error;
782/// let cmds = vec![
783///     Command::builder("list").build().unwrap(),
784///     Command::builder("log").build().unwrap(),
785/// ];
786/// let resolver = Resolver::new(&cmds);
787///
788/// let err = resolver.resolve("xyz").unwrap_err();
789/// let msg = render_resolve_error(&err);
790/// assert!(msg.contains("xyz"));
791///
792/// let err2 = resolver.resolve("l").unwrap_err();
793/// let msg2 = render_resolve_error(&err2);
794/// assert!(msg2.contains("list"));
795/// ```
796pub 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
809/// Generate a shell completion script for a registry of commands.
810///
811/// The generated script hooks into the shell's native completion mechanism.
812/// Source it in your shell profile to enable tab-completion for your tool.
813///
814/// # Arguments
815///
816/// - `shell` — the target shell
817/// - `program` — the program name as it appears in `PATH` (e.g. `"mytool"`)
818/// - `registry` — the [`crate::query::Registry`] containing all commands
819///
820/// # Examples
821///
822/// ```
823/// # use argot_cmd::{Command, Flag, Registry};
824/// # use argot_cmd::render::{Shell, render_completion};
825/// let registry = Registry::new(vec![
826///     Command::builder("deploy")
827///         .flag(Flag::builder("env").takes_value().choices(["prod", "staging"]).build().unwrap())
828///         .build().unwrap(),
829///     Command::builder("status").build().unwrap(),
830/// ]);
831///
832/// let script = render_completion(Shell::Bash, "mytool", &registry);
833/// assert!(script.contains("mytool"));
834/// assert!(script.contains("deploy"));
835/// assert!(script.contains("status"));
836/// ```
837pub 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    // Collect: top-level command names
849    let top_level: Vec<&str> = registry
850        .commands()
851        .iter()
852        .map(|c| c.canonical.as_str())
853        .collect();
854
855    // Build per-command flag completions
856    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    // Top-level commands
977    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    // Per-command flags
992    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
1016/// Generate a JSON Schema (draft-07) describing the inputs for a command.
1017///
1018/// The schema object is suitable for use in agent tool definitions (e.g.
1019/// OpenAI function calling, Anthropic tool use, MCP tool input schemas).
1020///
1021/// Arguments appear as required string properties (with `"required"` if marked
1022/// so). Flags with [`crate::model::Flag::takes_value`] become string properties;
1023/// boolean flags become boolean properties.
1024///
1025/// # Examples
1026///
1027/// ```
1028/// # use argot_cmd::{Argument, Command, Flag};
1029/// # use argot_cmd::render::render_json_schema;
1030/// let cmd = Command::builder("deploy")
1031///     .summary("Deploy a service")
1032///     .argument(Argument::builder("env").required().description("Target environment").build().unwrap())
1033///     .flag(Flag::builder("dry-run").description("Simulate only").build().unwrap())
1034///     .flag(Flag::builder("strategy")
1035///         .takes_value()
1036///         .choices(["rolling", "blue-green"])
1037///         .description("Rollout strategy")
1038///         .build().unwrap())
1039///     .build().unwrap();
1040///
1041/// let schema = render_json_schema(&cmd).unwrap();
1042/// let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1043/// assert_eq!(v["title"], "deploy");
1044/// assert_eq!(v["properties"]["env"]["type"], "string");
1045/// assert_eq!(v["properties"]["dry-run"]["type"], "boolean");
1046/// let strats = v["properties"]["strategy"]["enum"].as_array().unwrap();
1047/// assert_eq!(strats.len(), 2);
1048/// ```
1049pub 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    // Positional arguments → string properties
1056    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    // Flags → typed properties
1082    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            // Boolean flag
1104            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
1137/// Render a full Markdown reference document for all commands in a registry.
1138///
1139/// The output contains:
1140/// - A `# Commands` top-level heading.
1141/// - A table of contents: a bulleted list of anchor links to each command in
1142///   depth-first order. Subcommands are indented by two spaces per level beyond
1143///   the first.
1144/// - Each command rendered with [`render_markdown`], separated by `---` lines.
1145///
1146/// # Arguments
1147///
1148/// - `registry` — The registry whose commands should be documented.
1149///
1150/// # Examples
1151///
1152/// ```
1153/// # use argot_cmd::{Command, Registry, render_docs};
1154/// let registry = Registry::new(vec![
1155///     Command::builder("deploy")
1156///         .summary("Deploy the application")
1157///         .subcommand(Command::builder("rollback").summary("Roll back").build().unwrap())
1158///         .build()
1159///         .unwrap(),
1160///     Command::builder("status").summary("Show status").build().unwrap(),
1161/// ]);
1162///
1163/// let docs = render_docs(&registry);
1164/// assert!(docs.contains("# Commands"));
1165/// assert!(docs.contains("# deploy"));
1166/// assert!(docs.contains("# rollback"));
1167/// assert!(docs.contains("# status"));
1168/// assert!(docs.contains("---"));
1169/// ```
1170pub 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    // Table of contents
1176    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    // Per-command sections
1185    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; // suppress unused variable warning
1189    }
1190
1191    out
1192}
1193
1194/// Render a structured Markdown skill file for a single command.
1195///
1196/// Skill files encode best practices, anti-patterns, and examples in a format
1197/// suitable for loading into an AI agent context (e.g. `.claude/commands/`).
1198/// Only sections that have content are emitted — empty best_practices, empty
1199/// anti_patterns, no arguments, etc. are silently omitted.
1200///
1201/// # Arguments
1202///
1203/// - `command` — The command to produce a skill file for.
1204///
1205/// # Examples
1206///
1207/// ```
1208/// # use argot_cmd::{Command, render::render_skill_file};
1209/// let cmd = Command::builder("deploy")
1210///     .summary("Deploy the application")
1211///     .best_practice("always dry-run first")
1212///     .anti_pattern("deploy on Friday")
1213///     .build()
1214///     .unwrap();
1215///
1216/// let skill = render_skill_file(&cmd);
1217/// assert!(skill.contains("# Skill: deploy"));
1218/// assert!(skill.contains("## Safe Usage"));
1219/// assert!(skill.contains("## Avoid"));
1220/// ```
1221pub fn render_skill_file(command: &Command) -> String {
1222    let mut out = String::new();
1223
1224    // Heading
1225    out.push_str(&format!("# Skill: {}\n\n", command.canonical));
1226
1227    // Summary
1228    if !command.summary.is_empty() {
1229        out.push_str(&format!("{}\n\n", command.summary));
1230    }
1231
1232    // Description
1233    if !command.description.is_empty() {
1234        out.push_str(&format!("{}\n\n", command.description));
1235    }
1236
1237    // Safe Usage (best practices)
1238    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    // Avoid (anti-patterns)
1247    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    // Arguments table
1256    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    // Flags table
1271    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    // Examples
1294    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    // Subcommands
1303    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
1314/// Render skill files for every command in a registry.
1315///
1316/// Calls [`render_skill_file`] on every command in depth-first order via
1317/// [`crate::query::Registry::iter_all_recursive`] and concatenates the results
1318/// separated by `---\n\n`.
1319///
1320/// # Arguments
1321///
1322/// - `registry` — The registry whose commands should be documented as skill files.
1323///
1324/// # Examples
1325///
1326/// ```
1327/// # use argot_cmd::{Command, Registry, render::render_skill_files};
1328/// let registry = Registry::new(vec![
1329///     Command::builder("deploy")
1330///         .summary("Deploy the application")
1331///         .best_practice("always dry-run first")
1332///         .build()
1333///         .unwrap(),
1334///     Command::builder("status")
1335///         .summary("Show status")
1336///         .build()
1337///         .unwrap(),
1338/// ]);
1339///
1340/// let skills = render_skill_files(&registry);
1341/// assert!(skills.contains("# Skill: deploy"));
1342/// assert!(skills.contains("# Skill: status"));
1343/// assert!(skills.contains("---"));
1344/// ```
1345pub 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')); // alias
1445    }
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        // run with --help; output should be uppercase
1549        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", &reg);
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", &reg);
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", &reg);
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", &reg);
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        // Must return Ok, not panic.
1653        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(&reg);
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(&reg);
1728        // "deploy" at top level — no leading spaces before the bullet
1729        assert!(
1730            docs.contains("\n- [deploy](#deploy)"),
1731            "deploy should be at root indent"
1732        );
1733        // "deploy rollback" at depth 2 — two leading spaces
1734        assert!(
1735            docs.contains("\n  - [deploy rollback](#deploy-rollback)"),
1736            "deploy rollback should be indented"
1737        );
1738        // "status" at top level
1739        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(&reg);
1750        assert!(docs.starts_with("# Commands\n\n"));
1751        // Should not panic and should not contain any separator (no commands)
1752        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(&reg);
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            // A command with no flags/args (should be skipped in subcommand_cases)
1788            Command::builder("status").build().unwrap(),
1789        ]);
1790        let script = render_completion(Shell::Zsh, "mytool", &reg);
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        // A command with no summary should use the canonical name in the description
1801        let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1802        let script = render_completion(Shell::Zsh, "mytool", &reg);
1803        // canonical name used since summary is empty
1804        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", &reg);
1828        assert!(script.contains("mytool"));
1829        assert!(script.contains("deploy"));
1830        assert!(script.contains("--env") || script.contains("'env'"));
1831        // Flag with takes_value should have -r
1832        assert!(script.contains("-r"));
1833        // Summary should be in description
1834        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", &reg);
1842        // Empty summary → no -d '...' in the line for the command
1843        assert!(script.contains("run"));
1844    }
1845
1846    #[test]
1847    fn test_render_completion_bash_no_flags_cmd() {
1848        use crate::query::Registry;
1849        // Command without flags should still appear in the top-level list
1850        let reg = Registry::new(vec![Command::builder("status").build().unwrap()]);
1851        let script = render_completion(Shell::Bash, "app", &reg);
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        // Example with output should show "# Output:" line
1930        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    // -----------------------------------------------------------------------
1969    // render_skill_file tests
1970    // -----------------------------------------------------------------------
1971
1972    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        // dry-run has short -n, not required, no default
2086        assert!(
2087            skill.contains("| --dry-run | -n | no | — | Simulate without changes |"),
2088            "missing dry-run flag row"
2089        );
2090        // strategy has no short, not required, default = rolling
2091        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        // Minimal command: no best_practices, anti_patterns, args, flags, examples, subcommands
2128        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        // Should still produce a valid heading and not panic
2148        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    // -----------------------------------------------------------------------
2172    // render_skill_files tests
2173    // -----------------------------------------------------------------------
2174
2175    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(&reg);
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(&reg);
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(&reg);
2217        // Empty registry yields an empty string (no separators, no content)
2218        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(&reg);
2228        assert!(skills.contains("# Skill: ping"));
2229        // Single command: no separator expected
2230        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(&reg);
2251        assert!(skills.contains("# Skill: deploy"));
2252        assert!(skills.contains("# Skill: status"));
2253    }
2254
2255    // -----------------------------------------------------------------------
2256    // mutating annotation render tests
2257    // -----------------------------------------------------------------------
2258
2259    #[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    // ── SkillFrontmatter & skill-file render tests ────────────────────────
2364
2365    #[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        // keys are sorted, so min_role before priority
2391        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        // No description set — should fall back to cmd.summary
2429        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        // Return None for "status" → plain skill file; Some for "deploy"
2486        let output = render_skill_files_with_frontmatter(&registry, |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        // status should NOT have a frontmatter name line
2497        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(&registry, |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(&registry, &|c| {
2540            Some(super::SkillFrontmatter::new(format!("t-{}", c.canonical)))
2541        });
2542        assert!(all.contains("name: t-deploy"));
2543    }
2544}