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 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 pub fn format_table_line(data: &[String]) -> String {
25 format!("| {} |", data.join(" | "))
26 }
27
28 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 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 let help = format!("```bash\n{}\n```", cmd.render_long_help()); 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 path: String,
250 }
251
252 #[derive(Subcommand, Debug, Clone)]
253 enum MySubCommands {
254 SubCommandA {
256 #[clap(long)]
258 param_of_a: bool,
259 },
260 SubCommandB(StructSubCommandB),
262 }
263
264 #[derive(Parser, Debug, Clone)]
265 #[command(version)]
266 pub struct MyCommand {
267 #[clap(subcommand)]
269 command: MySubCommands,
270
271 #[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 assert!(
343 doc.contains("sub-command-a Help for Subcommand A"),
344 "Generated doc: {doc}"
345 );
346
347 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 assert!(
365 doc.contains("sub-command-b Help for Subcommand B"),
366 "Generated doc: {doc}"
367 );
368
369 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}