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//! None of the functions print to stdout/stderr directly; all return a
24//! `String` that the caller can write wherever appropriate.
25
26use crate::model::Command;
27
28/// A supported shell for completion script generation.
29///
30/// Used with [`render_completion`].
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32pub enum Shell {
33    /// Bash (Bourne Again Shell)
34    Bash,
35    /// Zsh (Z Shell)
36    Zsh,
37    /// Fish shell
38    Fish,
39}
40
41/// A pluggable renderer for command help, Markdown docs, and disambiguation messages.
42///
43/// Implement this trait to fully customize how argot formats its output.
44/// Use [`crate::Cli::with_renderer`] to inject your implementation.
45///
46/// A [`DefaultRenderer`] is provided that delegates to the module-level free
47/// functions ([`render_help`], [`render_markdown`], etc.).
48///
49/// # Examples
50///
51/// ```
52/// # use argot_cmd::{Command, render::Renderer};
53/// struct UppercaseRenderer;
54///
55/// impl Renderer for UppercaseRenderer {
56///     fn render_help(&self, command: &Command) -> String {
57///         argot_cmd::render_help(command).to_uppercase()
58///     }
59///     fn render_markdown(&self, command: &Command) -> String {
60///         argot_cmd::render_markdown(command)
61///     }
62///     fn render_subcommand_list(&self, commands: &[Command]) -> String {
63///         argot_cmd::render_subcommand_list(commands)
64///     }
65///     fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String {
66///         argot_cmd::render_ambiguity(input, candidates)
67///     }
68/// }
69/// ```
70pub trait Renderer: Send + Sync {
71    /// Render a plain-text help page for a command.
72    fn render_help(&self, command: &crate::model::Command) -> String;
73    /// Render a Markdown documentation page for a command.
74    fn render_markdown(&self, command: &crate::model::Command) -> String;
75    /// Render a compact listing of multiple commands.
76    fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String;
77    /// Render a disambiguation message for an ambiguous command token.
78    fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String;
79    /// Render a full Markdown reference document for all commands in a registry.
80    ///
81    /// Produces a `# Commands` heading, a table of contents with depth-based
82    /// indentation, and per-command Markdown sections separated by `---`.
83    fn render_docs(&self, registry: &crate::query::Registry) -> String {
84        render_docs(registry)
85    }
86}
87
88/// The default renderer. Delegates to the module-level free functions.
89///
90/// This is used by [`crate::Cli`] unless overridden with [`crate::Cli::with_renderer`].
91#[derive(Debug, Default, Clone)]
92pub struct DefaultRenderer;
93
94impl Renderer for DefaultRenderer {
95    fn render_help(&self, command: &crate::model::Command) -> String {
96        render_help(command)
97    }
98    fn render_markdown(&self, command: &crate::model::Command) -> String {
99        render_markdown(command)
100    }
101    fn render_subcommand_list(&self, commands: &[crate::model::Command]) -> String {
102        render_subcommand_list(commands)
103    }
104    fn render_ambiguity(&self, input: &str, candidates: &[String]) -> String {
105        render_ambiguity(input, candidates)
106    }
107    fn render_docs(&self, registry: &crate::query::Registry) -> String {
108        render_docs(registry)
109    }
110}
111
112/// Render a human-readable help page for a command.
113///
114/// The output contains the following sections (each omitted when empty):
115/// NAME, SUMMARY, DESCRIPTION, USAGE, ARGUMENTS, FLAGS, SUBCOMMANDS,
116/// EXAMPLES, BEST PRACTICES, ANTI-PATTERNS.
117///
118/// # Arguments
119///
120/// - `command` — The command to render help for.
121///
122/// # Examples
123///
124/// ```
125/// # use argot_cmd::{Command, render_help};
126/// let cmd = Command::builder("greet")
127///     .summary("Say hello")
128///     .build()
129///     .unwrap();
130///
131/// let help = render_help(&cmd);
132/// assert!(help.contains("NAME"));
133/// assert!(help.contains("greet"));
134/// assert!(help.contains("SUMMARY"));
135/// ```
136pub fn render_help(command: &Command) -> String {
137    let mut out = String::new();
138
139    // NAME
140    let name_line = if command.aliases.is_empty() {
141        command.canonical.clone()
142    } else {
143        format!("{} ({})", command.canonical, command.aliases.join(", "))
144    };
145    out.push_str(&format!("NAME\n    {}\n\n", name_line));
146
147    if !command.summary.is_empty() {
148        out.push_str(&format!("SUMMARY\n    {}\n\n", command.summary));
149    }
150
151    if !command.description.is_empty() {
152        out.push_str(&format!("DESCRIPTION\n    {}\n\n", command.description));
153    }
154
155    out.push_str(&format!("USAGE\n    {}\n\n", build_usage(command)));
156
157    if !command.arguments.is_empty() {
158        out.push_str("ARGUMENTS\n");
159        for arg in &command.arguments {
160            let req = if arg.required { " (required)" } else { "" };
161            out.push_str(&format!("    <{}>  {}{}\n", arg.name, arg.description, req));
162        }
163        out.push('\n');
164    }
165
166    if !command.flags.is_empty() {
167        out.push_str("FLAGS\n");
168        for flag in &command.flags {
169            let short_part = flag.short.map(|c| format!("-{}, ", c)).unwrap_or_default();
170            let req = if flag.required { " (required)" } else { "" };
171            out.push_str(&format!(
172                "    {}--{}  {}{}\n",
173                short_part, flag.name, flag.description, req
174            ));
175        }
176        out.push('\n');
177    }
178
179    if !command.subcommands.is_empty() {
180        out.push_str("SUBCOMMANDS\n");
181        for sub in &command.subcommands {
182            out.push_str(&format!("    {}  {}\n", sub.canonical, sub.summary));
183        }
184        out.push('\n');
185    }
186
187    if !command.examples.is_empty() {
188        out.push_str("EXAMPLES\n");
189        for ex in &command.examples {
190            out.push_str(&format!("    # {}\n    {}\n", ex.description, ex.command));
191            if let Some(output) = &ex.output {
192                out.push_str(&format!("    # Output: {}\n", output));
193            }
194            out.push('\n');
195        }
196    }
197
198    if !command.best_practices.is_empty() {
199        out.push_str("BEST PRACTICES\n");
200        for bp in &command.best_practices {
201            out.push_str(&format!("    - {}\n", bp));
202        }
203        out.push('\n');
204    }
205
206    if !command.anti_patterns.is_empty() {
207        out.push_str("ANTI-PATTERNS\n");
208        for ap in &command.anti_patterns {
209            out.push_str(&format!("    - {}\n", ap));
210        }
211        out.push('\n');
212    }
213
214    out
215}
216
217/// Render a compact listing of multiple commands (e.g. for a top-level help).
218///
219/// Each line has the format `  canonical  summary`. This is suitable for
220/// displaying all registered commands when no specific command is requested.
221///
222/// # Arguments
223///
224/// - `commands` — The commands to list.
225///
226/// # Examples
227///
228/// ```
229/// # use argot_cmd::{Command, render_subcommand_list};
230/// let cmds = vec![
231///     Command::builder("list").summary("List items").build().unwrap(),
232///     Command::builder("get").summary("Get an item").build().unwrap(),
233/// ];
234///
235/// let listing = render_subcommand_list(&cmds);
236/// assert!(listing.contains("list"));
237/// assert!(listing.contains("List items"));
238/// ```
239pub fn render_subcommand_list(commands: &[Command]) -> String {
240    let mut out = String::new();
241    for cmd in commands {
242        out.push_str(&format!("  {}  {}\n", cmd.canonical, cmd.summary));
243    }
244    out
245}
246
247/// Render a Markdown documentation page for a command.
248///
249/// The output is GitHub-flavored Markdown with:
250/// - A `# canonical` top-level heading.
251/// - `##` headings for Description, Usage, Arguments, Flags, Subcommands,
252///   Examples, Best Practices, and Anti-Patterns.
253/// - Arguments and flags rendered as Markdown tables.
254/// - Usage and examples rendered as fenced code blocks.
255///
256/// Empty sections are omitted.
257///
258/// # Arguments
259///
260/// - `command` — The command to render documentation for.
261///
262/// # Examples
263///
264/// ```
265/// # use argot_cmd::{Command, render_markdown};
266/// let cmd = Command::builder("deploy")
267///     .summary("Deploy the app")
268///     .build()
269///     .unwrap();
270///
271/// let md = render_markdown(&cmd);
272/// assert!(md.starts_with("# deploy"));
273/// assert!(md.contains("Deploy the app"));
274/// ```
275pub fn render_markdown(command: &Command) -> String {
276    let mut out = String::new();
277
278    out.push_str(&format!("# {}\n\n", command.canonical));
279
280    if !command.summary.is_empty() {
281        out.push_str(&format!("{}\n\n", command.summary));
282    }
283
284    if !command.description.is_empty() {
285        out.push_str(&format!("## Description\n\n{}\n\n", command.description));
286    }
287
288    out.push_str(&format!(
289        "## Usage\n\n```\n{}\n```\n\n",
290        build_usage(command)
291    ));
292
293    if !command.arguments.is_empty() {
294        out.push_str("## Arguments\n\n");
295        out.push_str("| Name | Description | Required |\n");
296        out.push_str("|------|-------------|----------|\n");
297        for arg in &command.arguments {
298            out.push_str(&format!(
299                "| `{}` | {} | {} |\n",
300                arg.name, arg.description, arg.required
301            ));
302        }
303        out.push('\n');
304    }
305
306    if !command.flags.is_empty() {
307        out.push_str("## Flags\n\n");
308        out.push_str("| Flag | Short | Description | Required |\n");
309        out.push_str("|------|-------|-------------|----------|\n");
310        for flag in &command.flags {
311            let short = flag.short.map(|c| format!("`-{}`", c)).unwrap_or_default();
312            out.push_str(&format!(
313                "| `--{}` | {} | {} | {} |\n",
314                flag.name, short, flag.description, flag.required
315            ));
316        }
317        out.push('\n');
318    }
319
320    if !command.subcommands.is_empty() {
321        out.push_str("## Subcommands\n\n");
322        for sub in &command.subcommands {
323            out.push_str(&format!("- **{}**: {}\n", sub.canonical, sub.summary));
324        }
325        out.push('\n');
326    }
327
328    if !command.examples.is_empty() {
329        out.push_str("## Examples\n\n");
330        for ex in &command.examples {
331            out.push_str(&format!(
332                "### {}\n\n```\n{}\n```\n\n",
333                ex.description, ex.command
334            ));
335        }
336    }
337
338    if !command.best_practices.is_empty() {
339        out.push_str("## Best Practices\n\n");
340        for bp in &command.best_practices {
341            out.push_str(&format!("- {}\n", bp));
342        }
343        out.push('\n');
344    }
345
346    if !command.anti_patterns.is_empty() {
347        out.push_str("## Anti-Patterns\n\n");
348        for ap in &command.anti_patterns {
349            out.push_str(&format!("- {}\n", ap));
350        }
351        out.push('\n');
352    }
353
354    out
355}
356
357/// Render a human-readable disambiguation message.
358///
359/// Used when a command token matches more than one candidate as a prefix.
360/// The message lists all candidate canonical names so the user or agent can
361/// choose the intended command.
362///
363/// # Arguments
364///
365/// - `input` — The ambiguous token as typed by the user.
366/// - `candidates` — Canonical names of all matching commands.
367///
368/// # Examples
369///
370/// ```
371/// # use argot_cmd::render_ambiguity;
372/// let msg = render_ambiguity("l", &["list".to_string(), "log".to_string()]);
373/// assert!(msg.contains("Ambiguous command"));
374/// assert!(msg.contains("list"));
375/// assert!(msg.contains("log"));
376/// ```
377pub fn render_ambiguity(input: &str, candidates: &[String]) -> String {
378    let list = candidates
379        .iter()
380        .map(|c| format!("  - {}", c))
381        .collect::<Vec<_>>()
382        .join("\n");
383    format!(
384        "Ambiguous command \"{}\". Did you mean one of:\n{}",
385        input, list
386    )
387}
388
389/// Render any [`crate::ResolveError`] as a human-readable string.
390///
391/// - [`crate::ResolveError::Ambiguous`] — delegates to [`render_ambiguity`].
392/// - [`crate::ResolveError::Unknown`] with suggestions — formats a
393///   "Did you mean?" message.
394/// - [`crate::ResolveError::Unknown`] without suggestions — formats a
395///   plain "Unknown command" message.
396///
397/// # Examples
398///
399/// ```
400/// # use argot_cmd::{Command, Resolver};
401/// # use argot_cmd::render::render_resolve_error;
402/// let cmds = vec![
403///     Command::builder("list").build().unwrap(),
404///     Command::builder("log").build().unwrap(),
405/// ];
406/// let resolver = Resolver::new(&cmds);
407///
408/// let err = resolver.resolve("xyz").unwrap_err();
409/// let msg = render_resolve_error(&err);
410/// assert!(msg.contains("xyz"));
411///
412/// let err2 = resolver.resolve("l").unwrap_err();
413/// let msg2 = render_resolve_error(&err2);
414/// assert!(msg2.contains("list"));
415/// ```
416pub fn render_resolve_error(error: &crate::resolver::ResolveError) -> String {
417    use crate::resolver::ResolveError;
418    match error {
419        ResolveError::Ambiguous { input, candidates } => render_ambiguity(input, candidates),
420        ResolveError::Unknown { input, suggestions } if !suggestions.is_empty() => format!(
421            "Unknown command: `{}`. Did you mean: {}?",
422            input,
423            suggestions.join(", ")
424        ),
425        ResolveError::Unknown { input, .. } => format!("Unknown command: `{}`", input),
426    }
427}
428
429/// Generate a shell completion script for a registry of commands.
430///
431/// The generated script hooks into the shell's native completion mechanism.
432/// Source it in your shell profile to enable tab-completion for your tool.
433///
434/// # Arguments
435///
436/// - `shell` — the target shell
437/// - `program` — the program name as it appears in `PATH` (e.g. `"mytool"`)
438/// - `registry` — the [`crate::query::Registry`] containing all commands
439///
440/// # Examples
441///
442/// ```
443/// # use argot_cmd::{Command, Flag, Registry};
444/// # use argot_cmd::render::{Shell, render_completion};
445/// let registry = Registry::new(vec![
446///     Command::builder("deploy")
447///         .flag(Flag::builder("env").takes_value().choices(["prod", "staging"]).build().unwrap())
448///         .build().unwrap(),
449///     Command::builder("status").build().unwrap(),
450/// ]);
451///
452/// let script = render_completion(Shell::Bash, "mytool", &registry);
453/// assert!(script.contains("mytool"));
454/// assert!(script.contains("deploy"));
455/// assert!(script.contains("status"));
456/// ```
457pub fn render_completion(shell: Shell, program: &str, registry: &crate::query::Registry) -> String {
458    match shell {
459        Shell::Bash => render_completion_bash(program, registry),
460        Shell::Zsh => render_completion_zsh(program, registry),
461        Shell::Fish => render_completion_fish(program, registry),
462    }
463}
464
465fn render_completion_bash(program: &str, registry: &crate::query::Registry) -> String {
466    let func_name = format!("_{}_completions", program.replace('-', "_"));
467
468    // Collect: top-level command names
469    let top_level: Vec<&str> = registry
470        .commands()
471        .iter()
472        .map(|c| c.canonical.as_str())
473        .collect();
474
475    // Build per-command flag completions
476    let mut cmd_cases = String::new();
477    for entry in registry.iter_all_recursive() {
478        let cmd = entry.command;
479        let flags: Vec<String> = cmd.flags.iter().map(|f| format!("--{}", f.name)).collect();
480        if !flags.is_empty() {
481            let path_str = entry.path_str();
482            cmd_cases.push_str(&format!(
483                "        {})\n            COMPREPLY=($(compgen -W \"{}\" -- \"$cur\"))\n            return\n            ;;\n",
484                path_str,
485                flags.join(" ")
486            ));
487        }
488    }
489
490    format!(
491        r#"# {program} bash completion
492# Source this file or add to ~/.bashrc:
493#   source <({program} completion bash)
494
495{func_name}() {{
496    local cur prev words cword
497    _init_completion 2>/dev/null || {{
498        cur="${{COMP_WORDS[COMP_CWORD]}}"
499        prev="${{COMP_WORDS[COMP_CWORD-1]}}"
500    }}
501
502    local cmd="${{COMP_WORDS[1]}}"
503
504    case "$cmd" in
505{cmd_cases}        *)
506            COMPREPLY=($(compgen -W "{top}" -- "$cur"))
507            ;;
508    esac
509}}
510
511complete -F {func_name} {program}
512"#,
513        program = program,
514        func_name = func_name,
515        cmd_cases = cmd_cases,
516        top = top_level.join(" "),
517    )
518}
519
520fn render_completion_zsh(program: &str, registry: &crate::query::Registry) -> String {
521    let mut commands_block = String::new();
522    for cmd in registry.commands() {
523        let desc = if cmd.summary.is_empty() {
524            &cmd.canonical
525        } else {
526            &cmd.summary
527        };
528        commands_block.push_str(&format!("    '{}:{}'\n", cmd.canonical, desc));
529    }
530
531    let mut subcommand_cases = String::new();
532    for entry in registry.iter_all_recursive() {
533        let cmd = entry.command;
534        if cmd.flags.is_empty() && cmd.arguments.is_empty() {
535            continue;
536        }
537        let mut args_spec = String::new();
538        for flag in &cmd.flags {
539            let desc = if flag.description.is_empty() {
540                flag.name.as_str()
541            } else {
542                flag.description.as_str()
543            };
544            if flag.takes_value {
545                args_spec.push_str(&format!("    '--{}[{}]:value:_default'\n", flag.name, desc));
546            } else {
547                args_spec.push_str(&format!("    '--{}[{}]'\n", flag.name, desc));
548            }
549        }
550        let path_str = entry.path_str().replace('.', "-");
551        subcommand_cases.push_str(&format!(
552            "  ({path})\n    _arguments \\\n{args}  ;;\n",
553            path = path_str,
554            args = args_spec,
555        ));
556    }
557
558    format!(
559        r#"#compdef {program}
560# {program} zsh completion
561
562_{program}() {{
563  local state
564
565  _arguments \
566    '1: :{program}_commands' \
567    '*:: :->subcommand'
568
569  case $state in
570    subcommand)
571      case $words[1] in
572{subcases}      esac
573  esac
574}}
575
576_{program}_commands() {{
577  local -a commands
578  commands=(
579{cmds}  )
580  _describe 'command' commands
581}}
582
583_{program}
584"#,
585        program = program,
586        subcases = subcommand_cases,
587        cmds = commands_block,
588    )
589}
590
591fn render_completion_fish(program: &str, registry: &crate::query::Registry) -> String {
592    let mut lines = format!(
593        "# {program} fish completion\n# Add to ~/.config/fish/completions/{program}.fish\n\n"
594    );
595
596    // Top-level commands
597    for cmd in registry.commands() {
598        let desc = if cmd.summary.is_empty() {
599            String::new()
600        } else {
601            format!(" -d '{}'", cmd.summary.replace('\'', "\\'"))
602        };
603        lines.push_str(&format!(
604            "complete -c {program} -f -n '__fish_use_subcommand' -a '{}'{}\n",
605            cmd.canonical, desc
606        ));
607    }
608
609    lines.push('\n');
610
611    // Per-command flags
612    for entry in registry.iter_all_recursive() {
613        let cmd = entry.command;
614        let subcmd = &cmd.canonical;
615        for flag in &cmd.flags {
616            let desc = if flag.description.is_empty() {
617                String::new()
618            } else {
619                format!(" -d '{}'", flag.description.replace('\'', "\\'"))
620            };
621            let req = if flag.takes_value { " -r" } else { "" };
622            lines.push_str(&format!(
623                "complete -c {program} -n '__fish_seen_subcommand_from {subcmd}' -l '{name}'{req}{desc}\n",
624                program = program,
625                subcmd = subcmd,
626                name = flag.name,
627                req = req,
628                desc = desc,
629            ));
630        }
631    }
632
633    lines
634}
635
636/// Generate a JSON Schema (draft-07) describing the inputs for a command.
637///
638/// The schema object is suitable for use in agent tool definitions (e.g.
639/// OpenAI function calling, Anthropic tool use, MCP tool input schemas).
640///
641/// Arguments appear as required string properties (with `"required"` if marked
642/// so). Flags with [`crate::model::Flag::takes_value`] become string properties;
643/// boolean flags become boolean properties.
644///
645/// # Examples
646///
647/// ```
648/// # use argot_cmd::{Argument, Command, Flag};
649/// # use argot_cmd::render::render_json_schema;
650/// let cmd = Command::builder("deploy")
651///     .summary("Deploy a service")
652///     .argument(Argument::builder("env").required().description("Target environment").build().unwrap())
653///     .flag(Flag::builder("dry-run").description("Simulate only").build().unwrap())
654///     .flag(Flag::builder("strategy")
655///         .takes_value()
656///         .choices(["rolling", "blue-green"])
657///         .description("Rollout strategy")
658///         .build().unwrap())
659///     .build().unwrap();
660///
661/// let schema = render_json_schema(&cmd).unwrap();
662/// let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
663/// assert_eq!(v["title"], "deploy");
664/// assert_eq!(v["properties"]["env"]["type"], "string");
665/// assert_eq!(v["properties"]["dry-run"]["type"], "boolean");
666/// let strats = v["properties"]["strategy"]["enum"].as_array().unwrap();
667/// assert_eq!(strats.len(), 2);
668/// ```
669pub fn render_json_schema(command: &Command) -> Result<String, serde_json::Error> {
670    use serde_json::{json, Map, Value};
671
672    let mut properties: Map<String, Value> = Map::new();
673    let mut required: Vec<Value> = Vec::new();
674
675    // Positional arguments → string properties
676    for arg in &command.arguments {
677        let mut prop = json!({
678            "type": "string",
679        });
680        if !arg.description.is_empty() {
681            prop["description"] = json!(arg.description);
682        }
683        if arg.variadic {
684            prop = json!({
685                "type": "array",
686                "items": { "type": "string" },
687            });
688            if !arg.description.is_empty() {
689                prop["description"] = json!(arg.description);
690            }
691        }
692        if arg.required {
693            required.push(json!(arg.name));
694        }
695        if let Some(ref default) = arg.default {
696            prop["default"] = json!(default);
697        }
698        properties.insert(arg.name.clone(), prop);
699    }
700
701    // Flags → typed properties
702    for flag in &command.flags {
703        let mut prop: Map<String, Value> = Map::new();
704
705        if !flag.description.is_empty() {
706            prop.insert("description".into(), json!(flag.description));
707        }
708
709        if flag.takes_value {
710            if let Some(ref choices) = flag.choices {
711                prop.insert("type".into(), json!("string"));
712                prop.insert(
713                    "enum".into(),
714                    Value::Array(choices.iter().map(|c| json!(c)).collect()),
715                );
716            } else {
717                prop.insert("type".into(), json!("string"));
718            }
719            if let Some(ref default) = flag.default {
720                prop.insert("default".into(), json!(default));
721            }
722        } else {
723            // Boolean flag
724            prop.insert("type".into(), json!("boolean"));
725            prop.insert("default".into(), json!(false));
726        }
727
728        if flag.required {
729            required.push(json!(flag.name));
730        }
731
732        properties.insert(flag.name.clone(), Value::Object(prop));
733    }
734
735    let mut schema = json!({
736        "$schema": "http://json-schema.org/draft-07/schema#",
737        "title": command.canonical,
738        "type": "object",
739        "properties": properties,
740    });
741
742    if !command.summary.is_empty() {
743        schema["description"] = json!(command.summary);
744    }
745
746    if !required.is_empty() {
747        schema["required"] = Value::Array(required);
748    }
749
750    serde_json::to_string_pretty(&schema)
751}
752
753/// Render a full Markdown reference document for all commands in a registry.
754///
755/// The output contains:
756/// - A `# Commands` top-level heading.
757/// - A table of contents: a bulleted list of anchor links to each command in
758///   depth-first order. Subcommands are indented by two spaces per level beyond
759///   the first.
760/// - Each command rendered with [`render_markdown`], separated by `---` lines.
761///
762/// # Arguments
763///
764/// - `registry` — The registry whose commands should be documented.
765///
766/// # Examples
767///
768/// ```
769/// # use argot_cmd::{Command, Registry, render_docs};
770/// let registry = Registry::new(vec![
771///     Command::builder("deploy")
772///         .summary("Deploy the application")
773///         .subcommand(Command::builder("rollback").summary("Roll back").build().unwrap())
774///         .build()
775///         .unwrap(),
776///     Command::builder("status").summary("Show status").build().unwrap(),
777/// ]);
778///
779/// let docs = render_docs(&registry);
780/// assert!(docs.contains("# Commands"));
781/// assert!(docs.contains("# deploy"));
782/// assert!(docs.contains("# rollback"));
783/// assert!(docs.contains("# status"));
784/// assert!(docs.contains("---"));
785/// ```
786pub fn render_docs(registry: &crate::query::Registry) -> String {
787    let entries = registry.iter_all_recursive();
788
789    let mut out = String::from("# Commands\n\n");
790
791    // Table of contents
792    for entry in &entries {
793        let depth = entry.path.len();
794        let indent = "  ".repeat(depth.saturating_sub(1));
795        let anchor = entry.path_str().replace('.', "-").to_lowercase();
796        let label = entry.path_str().replace('.', " ");
797        out.push_str(&format!("{}- [{}](#{})\n", indent, label, anchor));
798    }
799
800    // Per-command sections
801    for (i, entry) in entries.iter().enumerate() {
802        out.push_str("\n---\n\n");
803        out.push_str(&render_markdown(entry.command));
804        let _ = i; // suppress unused variable warning
805    }
806
807    out
808}
809
810fn build_usage(command: &Command) -> String {
811    let mut parts = vec![command.canonical.clone()];
812    if !command.subcommands.is_empty() {
813        parts.push("<subcommand>".to_string());
814    }
815    for arg in &command.arguments {
816        if arg.required {
817            parts.push(format!("<{}>", arg.name));
818        } else {
819            parts.push(format!("[{}]", arg.name));
820        }
821    }
822    if !command.flags.is_empty() {
823        parts.push("[flags]".to_string());
824    }
825    parts.join(" ")
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use crate::model::{Argument, Command, Example, Flag};
832
833    fn full_command() -> Command {
834        Command::builder("deploy")
835            .alias("d")
836            .summary("Deploy the application")
837            .description("Deploys the app to the target environment.")
838            .argument(
839                Argument::builder("env")
840                    .description("target environment")
841                    .required()
842                    .build()
843                    .unwrap(),
844            )
845            .flag(
846                Flag::builder("dry-run")
847                    .short('n')
848                    .description("simulate only")
849                    .build()
850                    .unwrap(),
851            )
852            .subcommand(
853                Command::builder("rollback")
854                    .summary("Roll back")
855                    .build()
856                    .unwrap(),
857            )
858            .example(Example::new("deploy to prod", "deploy prod").with_output("deployed"))
859            .best_practice("always dry-run first")
860            .anti_pattern("deploy on Friday")
861            .build()
862            .unwrap()
863    }
864
865    #[test]
866    fn test_render_help_contains_all_sections() {
867        let cmd = full_command();
868        let help = render_help(&cmd);
869        assert!(help.contains("NAME"), "missing NAME");
870        assert!(help.contains("SUMMARY"), "missing SUMMARY");
871        assert!(help.contains("DESCRIPTION"), "missing DESCRIPTION");
872        assert!(help.contains("USAGE"), "missing USAGE");
873        assert!(help.contains("ARGUMENTS"), "missing ARGUMENTS");
874        assert!(help.contains("FLAGS"), "missing FLAGS");
875        assert!(help.contains("SUBCOMMANDS"), "missing SUBCOMMANDS");
876        assert!(help.contains("EXAMPLES"), "missing EXAMPLES");
877        assert!(help.contains("BEST PRACTICES"), "missing BEST PRACTICES");
878        assert!(help.contains("ANTI-PATTERNS"), "missing ANTI-PATTERNS");
879    }
880
881    #[test]
882    fn test_render_help_omits_empty_sections() {
883        let cmd = Command::builder("simple")
884            .summary("Simple")
885            .build()
886            .unwrap();
887        let help = render_help(&cmd);
888        assert!(!help.contains("ARGUMENTS"));
889        assert!(!help.contains("FLAGS"));
890        assert!(!help.contains("SUBCOMMANDS"));
891        assert!(!help.contains("EXAMPLES"));
892        assert!(!help.contains("BEST PRACTICES"));
893        assert!(!help.contains("ANTI-PATTERNS"));
894    }
895
896    #[test]
897    fn test_render_help_shows_alias() {
898        let cmd = full_command();
899        let help = render_help(&cmd);
900        assert!(help.contains('d')); // alias
901    }
902
903    #[test]
904    fn test_render_markdown_starts_with_heading() {
905        let cmd = full_command();
906        let md = render_markdown(&cmd);
907        assert!(md.starts_with("# deploy"));
908    }
909
910    #[test]
911    fn test_render_markdown_contains_table() {
912        let cmd = full_command();
913        let md = render_markdown(&cmd);
914        assert!(md.contains("| `env`"));
915        assert!(md.contains("| `--dry-run`"));
916    }
917
918    #[test]
919    fn test_render_ambiguity() {
920        let candidates = vec!["list".to_string(), "log".to_string()];
921        let msg = render_ambiguity("l", &candidates);
922        assert!(msg.contains("Did you mean"));
923        assert!(msg.contains("list"));
924        assert!(msg.contains("log"));
925    }
926
927    #[test]
928    fn test_render_subcommand_list() {
929        let cmds = vec![
930            Command::builder("a").summary("alpha").build().unwrap(),
931            Command::builder("b").summary("beta").build().unwrap(),
932        ];
933        let out = render_subcommand_list(&cmds);
934        assert!(out.contains("alpha"));
935        assert!(out.contains("beta"));
936    }
937
938    #[test]
939    fn test_render_resolve_error_unknown_no_suggestions() {
940        use crate::resolver::ResolveError;
941        let err = ResolveError::Unknown {
942            input: "xyz".into(),
943            suggestions: vec![],
944        };
945        let msg = render_resolve_error(&err);
946        assert!(msg.contains("xyz"));
947        assert!(!msg.contains("Did you mean"));
948    }
949
950    #[test]
951    fn test_render_resolve_error_unknown_with_suggestions() {
952        use crate::resolver::ResolveError;
953        let err = ResolveError::Unknown {
954            input: "lst".into(),
955            suggestions: vec!["list".into()],
956        };
957        let msg = render_resolve_error(&err);
958        assert!(msg.contains("lst") && msg.contains("list") && msg.contains("Did you mean"));
959    }
960
961    #[test]
962    fn test_render_resolve_error_ambiguous() {
963        use crate::resolver::ResolveError;
964        let err = ResolveError::Ambiguous {
965            input: "l".into(),
966            candidates: vec!["list".into(), "log".into()],
967        };
968        let msg = render_resolve_error(&err);
969        assert!(msg.contains("list") && msg.contains("log"));
970    }
971
972    #[test]
973    fn test_default_renderer_delegates() {
974        let cmd = Command::builder("test")
975            .summary("A test command")
976            .build()
977            .unwrap();
978        let r = DefaultRenderer;
979        let help = r.render_help(&cmd);
980        assert!(help.contains("test"));
981        let md = r.render_markdown(&cmd);
982        assert!(md.starts_with("# test"));
983    }
984
985    #[test]
986    fn test_custom_renderer_via_cli() {
987        struct Upper;
988        impl Renderer for Upper {
989            fn render_help(&self, c: &Command) -> String {
990                render_help(c).to_uppercase()
991            }
992            fn render_markdown(&self, c: &Command) -> String {
993                render_markdown(c)
994            }
995            fn render_subcommand_list(&self, cs: &[Command]) -> String {
996                render_subcommand_list(cs)
997            }
998            fn render_ambiguity(&self, i: &str, cs: &[String]) -> String {
999                render_ambiguity(i, cs)
1000            }
1001        }
1002        let cli = crate::cli::Cli::new(vec![Command::builder("ping").build().unwrap()])
1003            .with_renderer(Upper);
1004        // run with --help; output should be uppercase
1005        let _ = cli.run(["--help"]);
1006    }
1007
1008    #[test]
1009    fn test_render_completion_bash_contains_program() {
1010        use crate::query::Registry;
1011        let reg = Registry::new(vec![
1012            Command::builder("deploy").build().unwrap(),
1013            Command::builder("status").build().unwrap(),
1014        ]);
1015        let script = render_completion(Shell::Bash, "mytool", &reg);
1016        assert!(script.contains("mytool"));
1017        assert!(script.contains("deploy"));
1018        assert!(script.contains("status"));
1019    }
1020
1021    #[test]
1022    fn test_render_completion_zsh_contains_program() {
1023        use crate::query::Registry;
1024        let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1025        let script = render_completion(Shell::Zsh, "mytool", &reg);
1026        assert!(script.contains("mytool") && script.contains("run"));
1027    }
1028
1029    #[test]
1030    fn test_render_completion_fish_contains_program() {
1031        use crate::query::Registry;
1032        let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1033        let script = render_completion(Shell::Fish, "mytool", &reg);
1034        assert!(script.contains("mytool") && script.contains("run"));
1035    }
1036
1037    #[test]
1038    fn test_render_completion_bash_includes_flags() {
1039        use crate::query::Registry;
1040        let reg = Registry::new(vec![Command::builder("deploy")
1041            .flag(Flag::builder("env").takes_value().build().unwrap())
1042            .flag(Flag::builder("dry-run").build().unwrap())
1043            .build()
1044            .unwrap()]);
1045        let script = render_completion(Shell::Bash, "t", &reg);
1046        assert!(script.contains("--env"));
1047        assert!(script.contains("--dry-run"));
1048    }
1049
1050    #[test]
1051    fn test_render_json_schema_properties() {
1052        let cmd = Command::builder("deploy")
1053            .summary("Deploy a service")
1054            .argument(
1055                Argument::builder("env")
1056                    .required()
1057                    .description("Target env")
1058                    .build()
1059                    .unwrap(),
1060            )
1061            .flag(
1062                Flag::builder("dry-run")
1063                    .description("Simulate")
1064                    .build()
1065                    .unwrap(),
1066            )
1067            .flag(
1068                Flag::builder("strategy")
1069                    .takes_value()
1070                    .choices(["rolling", "canary"])
1071                    .build()
1072                    .unwrap(),
1073            )
1074            .build()
1075            .unwrap();
1076
1077        let schema = render_json_schema(&cmd).unwrap();
1078        let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1079
1080        assert_eq!(v["title"], "deploy");
1081        assert_eq!(v["description"], "Deploy a service");
1082        assert_eq!(v["properties"]["env"]["type"], "string");
1083        assert_eq!(v["properties"]["dry-run"]["type"], "boolean");
1084        assert_eq!(v["properties"]["strategy"]["type"], "string");
1085        assert_eq!(v["properties"]["strategy"]["enum"][0], "rolling");
1086        let req = v["required"].as_array().unwrap();
1087        assert!(req.contains(&serde_json::json!("env")));
1088    }
1089
1090    #[test]
1091    fn test_render_json_schema_empty_command() {
1092        let cmd = Command::builder("ping").build().unwrap();
1093        let schema = render_json_schema(&cmd).unwrap();
1094        let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1095        assert_eq!(v["title"], "ping");
1096        assert!(
1097            v["required"].is_null()
1098                || v["required"]
1099                    .as_array()
1100                    .map(|a| a.is_empty())
1101                    .unwrap_or(true)
1102        );
1103    }
1104
1105    #[test]
1106    fn test_render_json_schema_returns_result() {
1107        let cmd = Command::builder("ping").build().unwrap();
1108        // Must return Ok, not panic.
1109        let result = render_json_schema(&cmd);
1110        assert!(result.is_ok());
1111        let _: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
1112    }
1113
1114    #[test]
1115    fn test_spellings_not_in_help_output() {
1116        let cmd = Command::builder("deploy")
1117            .alias("release")
1118            .spelling("deply")
1119            .build()
1120            .unwrap();
1121
1122        let help = render_help(&cmd);
1123        assert!(help.contains("release"), "alias should appear in help");
1124        assert!(!help.contains("deply"), "spelling must not appear in help");
1125    }
1126
1127    #[test]
1128    fn test_semantic_aliases_not_in_help_output() {
1129        let cmd = Command::builder("deploy")
1130            .alias("d")
1131            .semantic_alias("release to production")
1132            .semantic_alias("push to environment")
1133            .summary("Deploy a service")
1134            .build()
1135            .unwrap();
1136
1137        let help = render_help(&cmd);
1138        assert!(help.contains("d"), "alias should appear in help");
1139        assert!(
1140            !help.contains("release to production"),
1141            "semantic alias must not appear in help"
1142        );
1143        assert!(
1144            !help.contains("push to environment"),
1145            "semantic alias must not appear in help"
1146        );
1147    }
1148
1149    fn docs_registry() -> crate::query::Registry {
1150        use crate::query::Registry;
1151        Registry::new(vec![
1152            Command::builder("deploy")
1153                .summary("Deploy the application")
1154                .subcommand(
1155                    Command::builder("rollback")
1156                        .summary("Roll back a deployment")
1157                        .build()
1158                        .unwrap(),
1159                )
1160                .build()
1161                .unwrap(),
1162            Command::builder("status")
1163                .summary("Show status")
1164                .build()
1165                .unwrap(),
1166        ])
1167    }
1168
1169    #[test]
1170    fn test_render_docs_contains_all_commands() {
1171        let reg = docs_registry();
1172        let docs = render_docs(&reg);
1173        assert!(docs.contains("# Commands"), "missing top-level heading");
1174        assert!(docs.contains("deploy"), "missing deploy");
1175        assert!(docs.contains("rollback"), "missing rollback");
1176        assert!(docs.contains("status"), "missing status");
1177        assert!(docs.contains("---"), "missing separator");
1178    }
1179
1180    #[test]
1181    fn test_render_docs_table_of_contents_indents_subcommands() {
1182        let reg = docs_registry();
1183        let docs = render_docs(&reg);
1184        // "deploy" at top level — no leading spaces before the bullet
1185        assert!(
1186            docs.contains("\n- [deploy](#deploy)"),
1187            "deploy should be at root indent"
1188        );
1189        // "deploy rollback" at depth 2 — two leading spaces
1190        assert!(
1191            docs.contains("\n  - [deploy rollback](#deploy-rollback)"),
1192            "deploy rollback should be indented"
1193        );
1194        // "status" at top level
1195        assert!(
1196            docs.contains("\n- [status](#status)"),
1197            "status should be at root indent"
1198        );
1199    }
1200
1201    #[test]
1202    fn test_render_docs_empty_registry() {
1203        use crate::query::Registry;
1204        let reg = Registry::new(vec![]);
1205        let docs = render_docs(&reg);
1206        assert!(docs.starts_with("# Commands\n\n"));
1207        // Should not panic and should not contain any separator (no commands)
1208        assert!(!docs.contains("---"));
1209    }
1210
1211    #[test]
1212    fn test_default_renderer_render_docs() {
1213        let reg = docs_registry();
1214        let renderer = DefaultRenderer;
1215        let docs = renderer.render_docs(&reg);
1216        assert!(docs.contains("# Commands"));
1217        assert!(docs.contains("deploy"));
1218        assert!(docs.contains("status"));
1219    }
1220
1221    #[test]
1222    fn test_render_completion_zsh_with_flags_and_args() {
1223        use crate::query::Registry;
1224        let reg = Registry::new(vec![
1225            Command::builder("deploy")
1226                .summary("Deploy")
1227                .flag(
1228                    Flag::builder("env")
1229                        .takes_value()
1230                        .description("target env")
1231                        .build()
1232                        .unwrap(),
1233                )
1234                .flag(
1235                    Flag::builder("dry-run")
1236                        .description("simulate")
1237                        .build()
1238                        .unwrap(),
1239                )
1240                .argument(Argument::builder("service").required().build().unwrap())
1241                .build()
1242                .unwrap(),
1243            // A command with no flags/args (should be skipped in subcommand_cases)
1244            Command::builder("status").build().unwrap(),
1245        ]);
1246        let script = render_completion(Shell::Zsh, "mytool", &reg);
1247        assert!(script.contains("mytool"));
1248        assert!(script.contains("deploy"));
1249        assert!(script.contains("--env"));
1250        assert!(script.contains("--dry-run"));
1251    }
1252
1253    #[test]
1254    fn test_render_completion_zsh_empty_summary_uses_canonical() {
1255        use crate::query::Registry;
1256        // A command with no summary should use the canonical name in the description
1257        let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1258        let script = render_completion(Shell::Zsh, "mytool", &reg);
1259        // canonical name used since summary is empty
1260        assert!(script.contains("run:run"));
1261    }
1262
1263    #[test]
1264    fn test_render_completion_fish_with_flags() {
1265        use crate::query::Registry;
1266        let reg = Registry::new(vec![Command::builder("deploy")
1267            .summary("Deploy the app")
1268            .flag(
1269                Flag::builder("env")
1270                    .takes_value()
1271                    .description("target environment")
1272                    .build()
1273                    .unwrap(),
1274            )
1275            .flag(
1276                Flag::builder("dry-run")
1277                    .description("simulate")
1278                    .build()
1279                    .unwrap(),
1280            )
1281            .build()
1282            .unwrap()]);
1283        let script = render_completion(Shell::Fish, "mytool", &reg);
1284        assert!(script.contains("mytool"));
1285        assert!(script.contains("deploy"));
1286        assert!(script.contains("--env") || script.contains("'env'"));
1287        // Flag with takes_value should have -r
1288        assert!(script.contains("-r"));
1289        // Summary should be in description
1290        assert!(script.contains("Deploy the app"));
1291    }
1292
1293    #[test]
1294    fn test_render_completion_fish_empty_summary() {
1295        use crate::query::Registry;
1296        let reg = Registry::new(vec![Command::builder("run").build().unwrap()]);
1297        let script = render_completion(Shell::Fish, "mytool", &reg);
1298        // Empty summary → no -d '...' in the line for the command
1299        assert!(script.contains("run"));
1300    }
1301
1302    #[test]
1303    fn test_render_completion_bash_no_flags_cmd() {
1304        use crate::query::Registry;
1305        // Command without flags should still appear in the top-level list
1306        let reg = Registry::new(vec![Command::builder("status").build().unwrap()]);
1307        let script = render_completion(Shell::Bash, "app", &reg);
1308        assert!(script.contains("status"));
1309    }
1310
1311    #[test]
1312    fn test_render_json_schema_variadic_arg() {
1313        let cmd = Command::builder("run")
1314            .argument(
1315                Argument::builder("files")
1316                    .variadic()
1317                    .description("Files to process")
1318                    .build()
1319                    .unwrap(),
1320            )
1321            .build()
1322            .unwrap();
1323        let schema = render_json_schema(&cmd).unwrap();
1324        let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1325        assert_eq!(v["properties"]["files"]["type"], "array");
1326        assert_eq!(v["properties"]["files"]["items"]["type"], "string");
1327        assert!(v["properties"]["files"]["description"].as_str().is_some());
1328    }
1329
1330    #[test]
1331    fn test_render_json_schema_flag_with_default() {
1332        let cmd = Command::builder("run")
1333            .flag(
1334                Flag::builder("output")
1335                    .takes_value()
1336                    .default_value("text")
1337                    .description("Output format")
1338                    .build()
1339                    .unwrap(),
1340            )
1341            .build()
1342            .unwrap();
1343        let schema = render_json_schema(&cmd).unwrap();
1344        let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1345        assert_eq!(v["properties"]["output"]["default"], "text");
1346        assert_eq!(v["properties"]["output"]["type"], "string");
1347    }
1348
1349    #[test]
1350    fn test_render_json_schema_required_flag() {
1351        let cmd = Command::builder("deploy")
1352            .flag(
1353                Flag::builder("env")
1354                    .takes_value()
1355                    .required()
1356                    .build()
1357                    .unwrap(),
1358            )
1359            .build()
1360            .unwrap();
1361        let schema = render_json_schema(&cmd).unwrap();
1362        let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1363        let req = v["required"].as_array().unwrap();
1364        assert!(req.contains(&serde_json::json!("env")));
1365    }
1366
1367    #[test]
1368    fn test_render_json_schema_arg_with_default() {
1369        let cmd = Command::builder("run")
1370            .argument(
1371                Argument::builder("target")
1372                    .default_value("prod")
1373                    .build()
1374                    .unwrap(),
1375            )
1376            .build()
1377            .unwrap();
1378        let schema = render_json_schema(&cmd).unwrap();
1379        let v: serde_json::Value = serde_json::from_str(&schema).unwrap();
1380        assert_eq!(v["properties"]["target"]["default"], "prod");
1381    }
1382
1383    #[test]
1384    fn test_render_help_output_in_example() {
1385        // Example with output should show "# Output:" line
1386        let cmd = Command::builder("run")
1387            .example(Example::new("Run example", "myapp run").with_output("OK"))
1388            .build()
1389            .unwrap();
1390        let help = render_help(&cmd);
1391        assert!(help.contains("# Output: OK"));
1392    }
1393
1394    #[test]
1395    fn test_render_markdown_with_best_practices_and_anti_patterns() {
1396        let cmd = Command::builder("deploy")
1397            .best_practice("Always dry-run first")
1398            .anti_pattern("Deploy on Fridays")
1399            .build()
1400            .unwrap();
1401        let md = render_markdown(&cmd);
1402        assert!(md.contains("## Best Practices"));
1403        assert!(md.contains("Always dry-run first"));
1404        assert!(md.contains("## Anti-Patterns"));
1405        assert!(md.contains("Deploy on Fridays"));
1406    }
1407
1408    #[test]
1409    fn test_render_markdown_with_subcommands() {
1410        let cmd = Command::builder("remote")
1411            .subcommand(
1412                Command::builder("add")
1413                    .summary("Add remote")
1414                    .build()
1415                    .unwrap(),
1416            )
1417            .build()
1418            .unwrap();
1419        let md = render_markdown(&cmd);
1420        assert!(md.contains("## Subcommands"));
1421        assert!(md.contains("**add**"));
1422    }
1423}