mithril_doc/
markdown_formatter.rs

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