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_indented_blocks_to_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 clause_fields_are_documented_like_the_arguments_users_type() {
158        let spec: Spec = r#"
159bin "mycli"
160cmd "use" {
161    clause tools {
162        flag "--postinstall <command>" help="Run after installation"
163        arg <tool> help="Tool to install"
164    }
165}
166        "#
167        .parse()
168        .unwrap();
169        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
170        let rendered = ctx
171            .render_cmd(spec.cmd.subcommands.get("use").unwrap())
172            .unwrap();
173
174        assert!(rendered.contains("## Arguments"), "{rendered}");
175        assert!(
176            rendered.contains("**`<tool>`** — Tool to install"),
177            "{rendered}"
178        );
179        assert!(rendered.contains("## Flags"), "{rendered}");
180        assert!(
181            rendered.contains("**`--postinstall <command>`** — Run after installation"),
182            "{rendered}"
183        );
184    }
185
186    #[test]
187    fn test_render_markdown_groups_by_heading() {
188        let spec: Spec = r#"
189bin "mycli"
190flag "--verbose" help="Verbose output"
191flag "--filter <pattern>" help="Only matching" help_heading="Filtering"
192flag "--hidden-one" help="Not shown" help_heading="Filtering" hide=#true
193arg "<file>" help="The file"
194arg "<mode>" help="How to run" help_heading="Behaviour"
195"#
196        .parse()
197        .unwrap();
198        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
199
200        // Each heading becomes its own section, hidden entries stay out, and a
201        // heading whose every entry is hidden produces no section at all.
202        assert_snapshot!(ctx.render_cmd(&spec.cmd).unwrap(), @"
203        # `mycli`
204
205        - **Usage:** `mycli [--verbose] [--filter <pattern>] <file> <mode>`
206
207        ## Arguments
208        - **`<file>`** — The file
209
210        ## Behaviour
211        - **`<mode>`** — How to run
212
213        ## Flags
214        - **`--verbose`** — Verbose output
215
216        ## Filtering
217        - **`--filter <pattern>`** — Only matching
218        ");
219    }
220
221    #[test]
222    fn test_render_markdown_groups_global_flags_too() {
223        // Global flags are rendered in their own section, which used to come from
224        // the flat list and so ignored headings that help output honored.
225        let spec: Spec = r#"
226bin "mycli"
227flag "--verbose" help="Verbose output" global=#true
228flag "--filter <pattern>" help="Only matching" help_heading="Filtering" global=#true
229flag "--local-one" help="Not global"
230cmd "sub" help="a subcommand"
231"#
232        .parse()
233        .unwrap();
234        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
235
236        assert_snapshot!(ctx.render_cmd(&spec.cmd).unwrap(), @"
237        # `mycli`
238
239        - **Usage:** `mycli [FLAGS] [SUBCOMMAND]`
240
241        ## Global Flags
242        - **`--verbose`** — Verbose output
243
244        ## Filtering
245        - **`--filter <pattern>`** — Only matching
246
247        ## Flags
248        - **`--local-one`** — Not global
249
250        ## Subcommands
251
252        - [`mycli sub`](/sub.md)
253        ");
254    }
255
256    #[test]
257    fn generated_reference_separates_visible_flag_aliases() {
258        let spec: Spec = r#"
259bin "mycli"
260flag "-t -f --tail --follow" help="Follow output"
261"#
262        .parse()
263        .unwrap();
264        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
265        let rendered = ctx.render_cmd(&spec.cmd).unwrap();
266        assert!(rendered.contains("- **`-t --tail`**"), "{rendered}");
267        assert!(
268            rendered.contains("**Aliases:** `-f`, `--follow`"),
269            "{rendered}"
270        );
271        assert!(
272            !rendered.contains("**`-t -f --tail --follow`**"),
273            "{rendered}"
274        );
275    }
276
277    #[test]
278    fn test_render_markdown_cmd_outputs_and_exit_codes() {
279        let spec: Spec = r#"
280name "ex"
281bin "ex"
282exit_code 0 "success"
283exit_code 130 "interrupted | terminated"
284cmd "check" help="Check the project" {
285    flag "--format <FMT>" help="Output format"
286    output "human" default=#true help="A table"
287    output "jsonl" framing="jsonl" help="One event per line"
288    select "--format"
289    exit_code 1 "a check failed"
290}
291cmd "version" help="Show the version"
292        "#
293        .parse()
294        .unwrap();
295        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
296        let rendered = spec
297            .cmd
298            .subcommands
299            .values()
300            .map(|cmd| ctx.render_cmd(cmd).unwrap())
301            .collect::<Vec<_>>()
302            .join("\n\n");
303
304        // `check` shows what it writes, how to ask for it, and the CLI-wide codes folded
305        // together with its own. `version` declares no outputs, so it renders no Output
306        // Formats section — but the CLI-wide exit codes still reach it, because those are the
307        // program's, not the command's.
308        assert!(rendered.contains("## Output Formats"), "{rendered}");
309        assert!(rendered.contains("- **`human`** (default)"), "{rendered}");
310        assert!(
311            rendered.contains("**Select:** `--format jsonl`"),
312            "{rendered}"
313        );
314        assert!(
315            rendered.contains("one document per line, read as it arrives"),
316            "{rendered}"
317        );
318        assert!(rendered.contains("| `1` | a check failed |"), "{rendered}");
319        assert!(
320            rendered.contains(r"| `130` | interrupted \| terminated |"),
321            "{rendered}"
322        );
323
324        let version = ctx.render_cmd(&spec.cmd.subcommands["version"]).unwrap();
325        assert!(!version.contains("## Output Formats"), "{version}");
326        assert!(version.contains("| `0` | success |"), "{version}");
327    }
328
329    #[test]
330    fn a_command_with_no_outputs_renders_no_output_section() {
331        let spec: Spec = r#"
332name "ex"
333bin "ex"
334cmd "ls" help="List things"
335        "#
336        .parse()
337        .unwrap();
338        let ctx = MarkdownRenderer::new(spec.clone()).with_multi(true);
339        let rendered = ctx.render_cmd(&spec.cmd.subcommands["ls"]).unwrap();
340        assert!(!rendered.contains("## Output Formats"), "{rendered}");
341        assert!(!rendered.contains("## Exit Status"), "{rendered}");
342    }
343
344    #[test]
345    fn compact_output_formats_collapse_only_long_catalogs() {
346        let short: Spec = r#"
347name "short"
348output "text"
349output "json" framing="json"
350        "#
351        .parse()
352        .unwrap();
353        let short = MarkdownRenderer::new(short).render_spec().unwrap();
354        assert!(short.contains("## Output Formats"), "{short}");
355        assert!(!short.contains("<details>"), "{short}");
356
357        let boundary: Spec = r#"
358name "boundary"
359output "text"
360output "json" framing="json"
361output "jsonl" framing="jsonl"
362output "xml"
363output "yaml"
364        "#
365        .parse()
366        .unwrap();
367        let boundary = MarkdownRenderer::new(boundary).render_spec().unwrap();
368        assert!(!boundary.contains("<details>"), "{boundary}");
369
370        let long: Spec = r#"
371name "long"
372output "text"
373output "json" framing="json"
374output "jsonl" framing="jsonl"
375output "xml"
376output "yaml"
377output "csv"
378        "#
379        .parse()
380        .unwrap();
381        let compact = MarkdownRenderer::new(long.clone()).render_spec().unwrap();
382        assert!(
383            compact.contains("<summary>6 available formats</summary>"),
384            "{compact}"
385        );
386        assert!(compact.contains("- **`csv`**"), "{compact}");
387
388        let detailed = MarkdownRenderer::new(long)
389            .with_theme(MarkdownTheme::Detailed)
390            .render_spec()
391            .unwrap();
392        assert!(detailed.contains("## Output Formats"), "{detailed}");
393        assert!(!detailed.contains("<details>"), "{detailed}");
394    }
395}