mithril_doc/
markdown_formatter.rs

1use clap::{builder::StyledStr, Arg, Command};
2
3use crate::extract_clap_info;
4
5use super::StructDoc;
6
7mod markdown {
8    /// Format a list of label and a list of text list into a markdown table.
9    pub fn format_table(header: &[&str], lines: &[Vec<String>]) -> String {
10        format!(
11            "{}\n{}",
12            format_table_header(header),
13            lines
14                .iter()
15                .map(|line| format_table_line(line))
16                .collect::<Vec<String>>()
17                .join("\n"),
18        )
19    }
20
21    /// Format a list of text as a markdown table line.
22    pub fn format_table_line(data: &[String]) -> String {
23        format!("| {} |", data.join(" | "))
24    }
25
26    /// Format a list of label to a markdown header.
27    /// To align the text to left, right or to center it, you need to add `:` at left, right or both.
28    /// Example:  :Description:
29    pub fn format_table_header(data: &[&str]) -> String {
30        let headers = data
31            .iter()
32            .map(|header| {
33                let align_left = header.chars().next().map(|c| c == ':').unwrap_or(false);
34                let align_right = header.chars().last().map(|c| c == ':').unwrap_or(false);
35                let label = &header[(if align_left { 1 } else { 0 })
36                    ..(header.len() - (if align_right { 1 } else { 0 }))];
37                (label, align_left, align_right)
38            })
39            .collect::<Vec<(&str, bool, bool)>>();
40
41        let sublines = headers
42            .iter()
43            .map(|(label, left, right)| {
44                format!(
45                    "{}{}{}",
46                    if *left { ":" } else { "-" },
47                    "-".repeat(label.len()),
48                    if *right { ":" } else { "-" }
49                )
50            })
51            .collect::<Vec<String>>();
52
53        let labels = headers
54            .iter()
55            .map(|(label, _, _)| label.to_string())
56            .collect::<Vec<String>>();
57
58        format!("| {} |\n|{}|", labels.join(" | "), sublines.join("|"))
59    }
60}
61
62pub fn doc_markdown_with_config(cmd: &mut Command, struct_doc: Option<&StructDoc>) -> String {
63    // See: https://github1s.com/clap-rs/clap/blob/HEAD/clap_builder/src/builder/command.rs#L1989
64
65    fn format_parameters(
66        cmd: &Command,
67        struct_doc: Option<&StructDoc>,
68        parameters_explanation: &str,
69    ) -> String {
70        if cmd
71            .get_arguments()
72            .filter(|arg| argument_to_document(arg))
73            .peekable()
74            .peek()
75            .is_some()
76        {
77            let mut command_parameters = extract_clap_info::extract_parameters(cmd);
78            if let Some(config_doc) = struct_doc {
79                if !config_doc.data.is_empty() {
80                    command_parameters = command_parameters.merge_struct_doc(config_doc);
81                }
82            }
83
84            let parameters_table = doc_config_to_markdown(&command_parameters);
85
86            format!("{}\n{}", parameters_explanation, parameters_table)
87        } else {
88            "".to_string()
89        }
90    }
91
92    fn format_subcommand(cmd: &Command) -> String {
93        let sub_commands = &mut cmd.get_subcommands().peekable();
94        if sub_commands.peek().is_some() {
95            let subcommands_lines = sub_commands
96                .map(|command| {
97                    vec![
98                        format!("**{}**", command.get_name()),
99                        command.get_about().map_or("".into(), StyledStr::to_string),
100                    ]
101                })
102                .collect::<Vec<Vec<String>>>();
103
104            markdown::format_table(&["Subcommand", "Performed action"], &subcommands_lines)
105        } else {
106            String::from("")
107        }
108    }
109
110    fn format_command(
111        cmd: &mut Command,
112        parent: Option<String>,
113        struct_doc: Option<&StructDoc>,
114        level: usize,
115        parameters_explanation: &str,
116    ) -> String {
117        // The Command object need to be completed calling `render_help` function.
118        // Otherwise there is no parameter default values and the `help` command is absent.
119
120        let help = format!("```bash\n{}\n```", cmd.render_long_help()); // More readable than help
121        format_command_internal(cmd, parent, help, struct_doc, level, parameters_explanation)
122    }
123
124    fn name_to_document(name: &str) -> bool {
125        name != "help"
126    }
127
128    fn argument_to_document(arg: &Arg) -> bool {
129        name_to_document(arg.get_id().as_str())
130    }
131
132    fn command_to_document(cmd: &Command) -> bool {
133        name_to_document(cmd.get_name())
134    }
135
136    fn format_command_internal(
137        cmd: &Command,
138        parent: Option<String>,
139        help: String,
140        struct_doc: Option<&StructDoc>,
141        level: usize,
142        parameters_explanation: &str,
143    ) -> String {
144        let parent_ancestors = parent.clone().map_or("".into(), |s| format!("{} ", s));
145        let title = format!(
146            "{} {}{}\n",
147            "#".repeat(level),
148            parent_ancestors,
149            cmd.get_name()
150        );
151        let description = cmd.get_about().map_or("".into(), StyledStr::to_string);
152
153        let subcommands_table = format_subcommand(cmd);
154
155        let parameters = format_parameters(cmd, struct_doc, parameters_explanation);
156
157        let subcommands = cmd
158            .get_subcommands()
159            .filter(|cmd| command_to_document(cmd))
160            .map(|sub_command: &Command| {
161                format_command(
162                    &mut sub_command.clone(),
163                    Some(format!("{} {}", parent_ancestors, cmd.get_name())),
164                    None,
165                    level + 1,
166                    "",
167                )
168            })
169            .collect::<Vec<String>>()
170            .join("\n");
171
172        format!("{title}\n{description}\n{help}\n{subcommands_table}\n{parameters}\n{subcommands}")
173    }
174
175    let parameters_explanation = "\n\
176                The configuration parameters can be set in either of the following ways:\n\
177                \n\
178                1. In a configuration file, depending on the `--run-mode` parameter. If the runtime mode is `testnet`, the file is located in `./conf/testnet.json`.\n\
179                \n\
180                2. The value can be overridden by an environment variable with the parameter name in uppercase.\n\
181                ";
182    format_command(cmd, None, struct_doc, 3, parameters_explanation)
183}
184
185pub fn doc_config_to_markdown(struct_doc: &StructDoc) -> String {
186    let subcommands_lines = struct_doc
187        .data
188        .iter()
189        .map(|config| {
190            let config = config.clone();
191            vec![
192                format!("`{}`", config.parameter),
193                if config.command_line_long.is_empty() {
194                    "-".to_string()
195                } else {
196                    format!("`{}`", config.command_line_long)
197                },
198                if config.command_line_short.is_empty() {
199                    "-".to_string()
200                } else {
201                    format!("`{}`", config.command_line_short)
202                },
203                config
204                    .environment_variable
205                    .map_or_else(|| "-".to_string(), |x| format!("`{}`", x)),
206                config.description.replace('\n', "<br>"),
207                config
208                    .default_value
209                    .map(|value| format!("`{}`", value))
210                    .unwrap_or("-".to_string()),
211                config
212                    .example
213                    .map(|value| value.to_owned())
214                    .unwrap_or("-".to_string()),
215                String::from(if config.is_mandatory {
216                    ":heavy_check_mark:"
217                } else {
218                    "-"
219                }),
220            ]
221        })
222        .collect::<Vec<Vec<String>>>();
223    markdown::format_table(
224        &[
225            "Parameter",
226            "Command line (long)",
227            ":Command line (short):",
228            "Environment variable",
229            "Description",
230            "Default value",
231            "Example",
232            ":Mandatory:",
233        ],
234        &subcommands_lines,
235    )
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use clap::{Args, CommandFactory, Parser, Subcommand};
242    use regex::Regex;
243
244    #[derive(Args, Clone, Debug)]
245    struct StructSubCommandB {
246        /// The path of SubCommandB
247        path: String,
248    }
249
250    #[derive(Subcommand, Debug, Clone)]
251    enum MySubCommands {
252        /// Help for Subcommand A.
253        SubCommandA {
254            /// First param.
255            #[clap(long)]
256            param_of_a: bool,
257        },
258        /// Help for Subcommand B.
259        SubCommandB(StructSubCommandB),
260    }
261
262    #[derive(Parser, Debug, Clone)]
263    #[command(version)]
264    pub struct MyCommand {
265        /// Available commands
266        #[clap(subcommand)]
267        command: MySubCommands,
268
269        /// Run Mode
270        #[clap(short, long, default_value = "dev")]
271        run_mode: String,
272
273        #[clap()]
274        param_without_default: String,
275
276        #[clap(long, env = "ENV_VARIABLE")]
277        from_env: String,
278    }
279
280    #[derive(Parser, Debug, Clone)]
281    pub struct MyCommandWithOnlySubCommand {}
282
283    #[test]
284    fn test_format_arg_without_struct_doc() {
285        let mut command = MyCommand::command();
286        let doc = doc_markdown_with_config(&mut command, None);
287
288        assert!(
289            doc.contains("| `run_mode` | `--run-mode` | `-r` | - | Run Mode | `dev` | - | - |"),
290            "Generated doc: {doc}"
291        );
292        assert!(
293            doc.contains(
294                "| `param_without_default` | - | - | - | - | - | - | :heavy_check_mark: |"
295            ),
296            "Generated doc: {doc}"
297        );
298    }
299
300    #[test]
301    fn test_format_parameter_with_env_variable() {
302        let mut command = MyCommand::command();
303        let doc = doc_markdown_with_config(&mut command, None);
304
305        assert!(
306            doc.contains("| `from_env` | `--from-env` | - | `ENV_VARIABLE` | - | - | - | :heavy_check_mark: |"),
307            "Generated doc: {doc}"
308        );
309    }
310
311    #[test]
312    fn test_format_arg_with_empty_struct_doc() {
313        let mut command = MyCommand::command();
314        let merged_struct_doc = StructDoc::new();
315        let doc = doc_markdown_with_config(&mut command, Some(&merged_struct_doc));
316
317        assert!(
318            doc.contains("| `run_mode` | `--run-mode` | `-r` | - | Run Mode | `dev` | - | - |"),
319            "Generated doc: {doc}"
320        );
321        assert!(
322            doc.contains(
323                "| `param_without_default` | - | - | - | - | - | - | :heavy_check_mark: |"
324            ),
325            "Generated doc: {doc}"
326        );
327    }
328
329    #[test]
330    fn test_format_subcommand_inlined() {
331        let mut command = MyCommand::command();
332        let doc = doc_markdown_with_config(&mut command, None);
333
334        assert!(
335            doc.contains("###  mithril-doc sub-command-a"),
336            "Generated doc: {doc}"
337        );
338        // In `Commands:` part.
339        assert!(
340            doc.contains("sub-command-a  Help for Subcommand A"),
341            "Generated doc: {doc}"
342        );
343
344        // In `Subcommand` table
345        assert!(
346            doc.contains("| **sub-command-a** | Help for Subcommand A |"),
347            "Generated doc: {doc}"
348        );
349    }
350
351    #[test]
352    fn test_format_subcommand_on_separate_struct() {
353        let mut command = MyCommand::command();
354        let doc = doc_markdown_with_config(&mut command, None);
355
356        assert!(
357            doc.contains("###  mithril-doc sub-command-b"),
358            "Generated doc: {doc}"
359        );
360        // In `Commands:` part.
361        assert!(
362            doc.contains("sub-command-b  Help for Subcommand B"),
363            "Generated doc: {doc}"
364        );
365
366        // In `Subcommand` table
367        assert!(
368            doc.contains("| **sub-command-b** | Help for Subcommand B |"),
369            "Generated doc: {doc}"
370        );
371        assert!(
372            doc.contains("| `path` | - | - | - | The path of SubCommandB"),
373            "Generated doc: {doc}"
374        );
375
376        assert!(
377            Regex::new(r"Arguments:\s+<PATH>\s+The path of SubCommandB")
378                .unwrap()
379                .is_match(&doc),
380            "Generated doc: {doc}"
381        );
382    }
383
384    #[test]
385    fn test_should_not_create_chapter_for_subcommand_help() {
386        let mut command = MyCommand::command();
387        let doc = doc_markdown_with_config(&mut command, None);
388
389        assert!(
390            doc.contains("###  mithril-doc sub-command-b"),
391            "Generated doc: {doc}"
392        );
393        assert!(
394            !doc.contains("###  mithril-doc help"),
395            "Generated doc: {doc}"
396        );
397    }
398    #[test]
399    fn test_should_not_display_parameter_table_when_only_help_argument() {
400        {
401            let mut command = MyCommand::command();
402            let doc = doc_markdown_with_config(&mut command, None);
403            assert!(
404                doc.contains("| `help` | `--help` | `-h` |"),
405                "Generated doc: {doc}"
406            );
407        }
408        {
409            let mut command = MyCommandWithOnlySubCommand::command();
410            let doc = doc_markdown_with_config(&mut command, None);
411            assert!(
412                !doc.contains("| `help` | `--help` | `-h` |"),
413                "Generated doc: {doc}"
414            );
415        }
416    }
417
418    #[test]
419    fn test_doc_markdown_include_config_parameters() {
420        {
421            let mut command = MyCommand::command();
422            let doc = doc_markdown_with_config(&mut command, None);
423
424            assert!(
425                !doc.contains("| Param A from config |"),
426                "Generated doc: {doc}"
427            );
428            assert!(
429                !doc.contains("| `ConfigA` | - | - |"),
430                "Generated doc: {doc}"
431            );
432        }
433        {
434            let struct_doc = {
435                let mut s = StructDoc::default();
436                s.add_param(
437                    "ConfigA",
438                    "Param A from config",
439                    Some("CONFIGA".to_string()),
440                    Some("default config A".to_string()),
441                    None,
442                );
443                s.add_param("ConfigB", "Param B from config", None, None, None);
444                s
445            };
446
447            let mut command = MyCommand::command();
448            let doc = doc_markdown_with_config(&mut command, Some(&struct_doc));
449
450            assert!(
451                doc.contains("| Param A from config |"),
452                "Generated doc: {doc}"
453            );
454            assert!(
455                doc.contains(
456                    "| `ConfigA` | - | - | `CONFIGA` | Param A from config | `default config A` |"
457                ),
458                "Generated doc: {doc}"
459            );
460        }
461    }
462}