Skip to main content

usage/docs/markdown/
cmd.rs

1use crate::docs::markdown::renderer::MarkdownRenderer;
2use crate::docs::models::SpecCommand;
3use crate::error::UsageErr;
4
5impl MarkdownRenderer {
6    pub fn render_cmd(&self, cmd: &crate::SpecCommand) -> Result<String, UsageErr> {
7        let mut cmd = SpecCommand::from(cmd);
8        // Anything that folds with the spec's CLI-wide declarations is taken from the
9        // renderer's own model, where the fold already happened. Converting the raw command
10        // gets only what that command declared, so a page would show a command's exit codes
11        // and silently omit the ones every command has.
12        if let Some(folded) = self.folded(&cmd.full_cmd) {
13            cmd.outputs = folded.outputs.clone();
14            cmd.exit_codes = folded.exit_codes.clone();
15        }
16        cmd.render_md(self);
17        self.render_with("cmd_template.md.tera", |ctx| ctx.insert("cmd", &cmd))
18    }
19
20    /// The command at this path in the renderer's folded model.
21    fn folded(&self, path: &[String]) -> Option<&SpecCommand> {
22        let mut cmd = &self.spec.cmd;
23        for name in path {
24            cmd = cmd.subcommands.get(name)?;
25        }
26        Some(cmd)
27    }
28}
29
30#[cfg(test)]
31mod tests {
32    use crate::docs::markdown::renderer::{MarkdownRenderer, MarkdownTheme};
33    use crate::test::SPEC_KITCHEN_SINK;
34    use crate::Spec;
35    use insta::assert_snapshot;
36
37    #[test]
38    fn test_render_markdown_cmd() {
39        let ctx = MarkdownRenderer::new(SPEC_KITCHEN_SINK.clone())
40            .with_multi(true)
41            .with_replace_pre_with_code_fences(true);
42        assert_snapshot!(ctx.render_cmd(&SPEC_KITCHEN_SINK.cmd).unwrap(), @"
43        # `mycli`
44
45        - **Usage:** `mycli [FLAGS] <ARGS>… <SUBCOMMAND>`
46
47        ## Arguments
48        - **`<arg1>`** — arg1 description
49        - **`[arg2]`** — arg2 description
50
51          **Choices:** `choice1`, `choice2`, `choice3`
52
53          **Default:** `default value`
54        - **`<arg3>`** — arg3 long description
55        - **`<argrest>…`**
56        - **`[with-default]`**
57
58          **Default:** `default value`
59
60        ## Flags
61        - **`--flag1`** — flag1 description
62        - **`--flag2`** — flag2 long description
63
64          includes a code block:
65
66          ```
67          $ echo hello world
68          hello world
69
70          more code
71          ```
72
73          Examples:
74
75          ```
76          # run with no arguments to use the interactive selector
77          $ mise use
78
79          # set the current version of node to 20.x in mise.toml of current directory
80          # will write the fuzzy version (e.g.: 20)
81          ```
82
83          some docs
84
85          ```
86          $ echo hello world
87          hello world
88          ```
89        - **`--flag3`** — flag3 description
90        - **`--with-default`**
91
92          **Default:** `default value`
93        - **`--shell <shell>`**
94
95          **Choices:** `bash`, `zsh`, `fish`
96
97        ## Subcommands
98
99        - [`mycli plugin <SUBCOMMAND>`](/plugin.md)
100        ");
101    }
102
103    #[test]
104    fn test_render_markdown_cmd_effect() {
105        let spec: Spec = r#"
106name "mise"
107bin "mise"
108cmd "ls" effect="read" help="List installed tools"
109cmd "use" effect="write" help="Install a tool"
110cmd "uninstall" effect="destructive" help="Remove a tool"
111cmd "version" help="Show the version"
112        "#
113        .parse()
114        .unwrap();
115        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
116        let rendered = spec
117            .cmd
118            .subcommands
119            .values()
120            .map(|cmd| ctx.render_cmd(cmd).unwrap())
121            .collect::<Vec<_>>()
122            .join("\n\n");
123
124        // Every effect value must render its own label, and a command without
125        // one must not render the line at all.
126        assert_snapshot!(rendered, @"
127        # `mise ls`
128
129        - **Usage:** `mise ls`
130        - **Effect:** read-only
131
132        List installed tools
133
134        # `mise use`
135
136        - **Usage:** `mise use`
137        - **Effect:** modifies state
138
139        Install a tool
140
141        # `mise uninstall`
142
143        - **Usage:** `mise uninstall`
144        - **Effect:** destructive — may delete or irreversibly overwrite
145
146        Remove a tool
147
148        # `mise version`
149
150        - **Usage:** `mise version`
151
152        Show the version
153        ");
154    }
155
156    #[test]
157    fn test_render_markdown_groups_by_heading() {
158        let spec: Spec = r#"
159bin "mycli"
160flag "--verbose" help="Verbose output"
161flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
162flag "--hidden-one" help="Not shown" help_heading="Filtering" hide=#true
163arg "<file>" help="The file"
164arg "<mode>" help="How to run" help_heading="Behaviour"
165"#
166        .parse()
167        .unwrap();
168        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
169
170        // Each heading becomes its own section, hidden entries stay out, and a
171        // heading whose every entry is hidden produces no section at all.
172        assert_snapshot!(ctx.render_cmd(&spec.cmd).unwrap(), @"
173        # `mycli`
174
175        - **Usage:** `mycli [--verbose] [--filter <pattern>] <file> <mode>`
176
177        ## Arguments
178        - **`<file>`** — The file
179
180        ## Behaviour
181        - **`<mode>`** — How to run
182
183        ## Flags
184        - **`--verbose`** — Verbose output
185
186        ## Filtering
187        - **`--filter <pattern>`** — Only matching
188        ");
189    }
190
191    #[test]
192    fn test_render_markdown_groups_global_flags_too() {
193        // Global flags are rendered in their own section, which used to come from
194        // the flat list and so ignored headings that help output honored.
195        let spec: Spec = r#"
196bin "mycli"
197flag "--verbose" help="Verbose output" global=#true
198flag "--filter <pattern>" help="Only matching" help_heading="Filtering" global=#true
199flag "--local-one" help="Not global"
200cmd "sub" help="a subcommand"
201"#
202        .parse()
203        .unwrap();
204        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
205
206        assert_snapshot!(ctx.render_cmd(&spec.cmd).unwrap(), @"
207        # `mycli`
208
209        - **Usage:** `mycli [FLAGS] <SUBCOMMAND>`
210
211        ## Global Flags
212        - **`--verbose`** — Verbose output
213
214        ## Filtering
215        - **`--filter <pattern>`** — Only matching
216
217        ## Flags
218        - **`--local-one`** — Not global
219
220        ## Subcommands
221
222        - [`mycli sub`](/sub.md)
223        ");
224    }
225
226    #[test]
227    fn generated_reference_separates_visible_flag_aliases() {
228        let spec: Spec = r#"
229bin "mycli"
230flag "-t -f --tail --follow" help="Follow output"
231"#
232        .parse()
233        .unwrap();
234        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
235        let rendered = ctx.render_cmd(&spec.cmd).unwrap();
236        assert!(rendered.contains("- **`-t --tail`**"), "{rendered}");
237        assert!(
238            rendered.contains("**Aliases:** `-f`, `--follow`"),
239            "{rendered}"
240        );
241        assert!(
242            !rendered.contains("**`-t -f --tail --follow`**"),
243            "{rendered}"
244        );
245    }
246
247    #[test]
248    fn test_render_markdown_cmd_outputs_and_exit_codes() {
249        let spec: Spec = r#"
250name "ex"
251bin "ex"
252exit_code 0 "success"
253exit_code 130 "interrupted | terminated"
254cmd "check" help="Check the project" {
255    flag "--format <FMT>" help="Output format"
256    output "human" default=#true help="A table"
257    output "jsonl" framing="jsonl" help="One event per line"
258    select "--format"
259    exit_code 1 "a check failed"
260}
261cmd "version" help="Show the version"
262        "#
263        .parse()
264        .unwrap();
265        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
266        let rendered = spec
267            .cmd
268            .subcommands
269            .values()
270            .map(|cmd| ctx.render_cmd(cmd).unwrap())
271            .collect::<Vec<_>>()
272            .join("\n\n");
273
274        // `check` shows what it writes, how to ask for it, and the CLI-wide codes folded
275        // together with its own. `version` declares no outputs, so it renders no Output
276        // Formats section — but the CLI-wide exit codes still reach it, because those are the
277        // program's, not the command's.
278        assert!(rendered.contains("## Output Formats"), "{rendered}");
279        assert!(rendered.contains("- **`human`** (default)"), "{rendered}");
280        assert!(
281            rendered.contains("**Select:** `--format jsonl`"),
282            "{rendered}"
283        );
284        assert!(
285            rendered.contains("one document per line, read as it arrives"),
286            "{rendered}"
287        );
288        assert!(rendered.contains("| `1` | a check failed |"), "{rendered}");
289        assert!(
290            rendered.contains(r"| `130` | interrupted \| terminated |"),
291            "{rendered}"
292        );
293
294        let version = ctx.render_cmd(&spec.cmd.subcommands["version"]).unwrap();
295        assert!(!version.contains("## Output Formats"), "{version}");
296        assert!(version.contains("| `0` | success |"), "{version}");
297    }
298
299    #[test]
300    fn a_command_with_no_outputs_renders_no_output_section() {
301        let spec: Spec = r#"
302name "ex"
303bin "ex"
304cmd "ls" help="List things"
305        "#
306        .parse()
307        .unwrap();
308        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
309        let rendered = ctx.render_cmd(&spec.cmd.subcommands["ls"]).unwrap();
310        assert!(!rendered.contains("## Output Formats"), "{rendered}");
311        assert!(!rendered.contains("## Exit Status"), "{rendered}");
312    }
313
314    #[test]
315    fn compact_output_formats_collapse_only_long_catalogs() {
316        let short: Spec = r#"
317name "short"
318output "text"
319output "json" framing="json"
320        "#
321        .parse()
322        .unwrap();
323        let short = MarkdownRenderer::new(short).render_spec().unwrap();
324        assert!(short.contains("## Output Formats"), "{short}");
325        assert!(!short.contains("<details>"), "{short}");
326
327        let boundary: Spec = r#"
328name "boundary"
329output "text"
330output "json" framing="json"
331output "jsonl" framing="jsonl"
332output "xml"
333output "yaml"
334        "#
335        .parse()
336        .unwrap();
337        let boundary = MarkdownRenderer::new(boundary).render_spec().unwrap();
338        assert!(!boundary.contains("<details>"), "{boundary}");
339
340        let long: Spec = r#"
341name "long"
342output "text"
343output "json" framing="json"
344output "jsonl" framing="jsonl"
345output "xml"
346output "yaml"
347output "csv"
348        "#
349        .parse()
350        .unwrap();
351        let compact = MarkdownRenderer::new(long.clone()).render_spec().unwrap();
352        assert!(
353            compact.contains("<summary>6 available formats</summary>"),
354            "{compact}"
355        );
356        assert!(compact.contains("- **`csv`**"), "{compact}");
357
358        let detailed = MarkdownRenderer::new(long)
359            .with_theme(MarkdownTheme::Detailed)
360            .render_spec()
361            .unwrap();
362        assert!(detailed.contains("## Output Formats"), "{detailed}");
363        assert!(!detailed.contains("<details>"), "{detailed}");
364    }
365}