1use std::fmt;
16
17#[derive(Debug, Default, Clone)]
20pub struct Command {
21 pub name: String,
22 pub usage: String,
23 pub args_usage: String,
24 pub description: String,
25 pub hidden: bool,
26 pub aliases: Vec<String>,
27 pub flags: Vec<Flag>,
28 pub commands: Vec<Command>,
29}
30
31#[derive(Debug, Clone)]
34pub struct Flag {
35 pub names: Vec<String>,
36 pub usage: String,
37 pub env_vars: Vec<String>,
38 pub type_name: String,
39 pub default_value: FlagValue,
40}
41
42#[derive(Debug, Clone, PartialEq)]
47pub enum FlagValue {
48 Bool(bool),
49 String(String),
50 Int(i64),
51}
52
53impl fmt::Display for FlagValue {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 match self {
56 FlagValue::Bool(b) => write!(f, "{b}"),
57 FlagValue::String(s) => write!(f, "{s}"),
58 FlagValue::Int(i) => write!(f, "{i}"),
59 }
60 }
61}
62
63#[derive(Debug, thiserror::Error, PartialEq, Eq)]
67#[error("unsupported flag type: {0}")]
68pub struct UnsupportedFlagTypeError(pub String);
69
70pub fn generate_command_files(
77 out_dir: &str,
78 cmd_base_dir: &str,
79 app: &Command,
80) -> std::io::Result<()> {
81 let visible = visible_commands(&app.commands);
82
83 for cmd in &visible {
84 let extra = read_doc_md(cmd_base_dir, &cmd.name);
85
86 let content = command_file_content(&app.name, cmd, &extra)
87 .map_err(|e| std::io::Error::other(format!("generating docs for {}: {e}", cmd.name)))?;
88
89 let path = format!("{out_dir}/{}.md", cmd.name);
90 std::fs::write(&path, content)
91 .map_err(|e| std::io::Error::other(format!("writing {path}: {e}")))?;
92 }
93
94 let index = index_file_content(&app.name, &visible)
95 .map_err(|e| std::io::Error::other(format!("generating index: {e}")))?;
96
97 let index_path = format!("{out_dir}/_index.md");
98 std::fs::write(&index_path, index)
99 .map_err(|e| std::io::Error::other(format!("writing {index_path}: {e}")))?;
100
101 Ok(())
102}
103
104fn read_doc_md(cmd_base_dir: &str, cmd_name: &str) -> String {
108 let dir = cmd_name.replace('-', "");
109 let path = format!("{cmd_base_dir}/{dir}/DOC.md");
110
111 match std::fs::read_to_string(path) {
112 Ok(data) => data.trim().to_string(),
113 Err(_) => String::new(),
114 }
115}
116
117fn visible_commands(commands: &[Command]) -> Vec<Command> {
120 let mut out: Vec<Command> = commands.iter().filter(|c| !c.hidden).cloned().collect();
121 out.sort_by(|a, b| a.name.cmp(&b.name));
122
123 out
124}
125
126const GENERATED_COMMENT: &str = "<!-- Code generated by go run ./cmd/gendocs; DO NOT EDIT. -->\n";
128
129const CAUTION_BLOCK: &str = "> [!WARNING]
130> Caproni is under development. This documentation is intended to be aspirational and is
131> intended to guide the development process. Differences may exist between this
132> documentation and the implementation during early stages of development.
133";
134
135fn flag_synopsis_string(flags: &[Flag]) -> Result<String, UnsupportedFlagTypeError> {
138 let mut sb = String::new();
139
140 for flag in flags {
141 let Some(name) = flag.names.first() else {
142 continue;
143 };
144
145 if name.len() > 1 {
146 sb.push_str(" [--");
147 sb.push_str(name);
148 } else {
149 sb.push_str(" [-");
150 sb.push_str(name);
151 }
152
153 if let Some(default_value) = get_flag_default_value(flag) {
154 sb.push('=');
155 sb.push_str(&default_value);
156 }
157
158 sb.push(']');
159 }
160
161 Ok(sb)
162}
163
164fn command_file_content(
168 app_name: &str,
169 cmd: &Command,
170 extra: &str,
171) -> Result<Vec<u8>, UnsupportedFlagTypeError> {
172 let mut buf = String::new();
173
174 buf.push_str(&format!(
175 "---\ntitle: \"{app_name} {}\"\ndescription: \"Command reference for `{app_name} {}`: {}.\"\ngenerated: true\n---\n\n{GENERATED_COMMENT}\n",
176 cmd.name, cmd.name, cmd.usage
177 ));
178
179 buf.push_str(&format!("{CAUTION_BLOCK}\n"));
180
181 if !cmd.usage.is_empty() {
182 buf.push_str(&format!("{}\n\n", cmd.usage));
183 }
184
185 let parent_flags = flag_synopsis_string(&cmd.flags)?;
187 let prefix = format!("{app_name} {}{parent_flags}", cmd.name);
188
189 buf.push_str("## Synopsis\n\n```shell\n");
190
191 let subs = visible_commands(&cmd.commands);
192
193 if !cmd.args_usage.is_empty() {
194 buf.push_str(&format!("{prefix} {}\n", cmd.args_usage));
195 } else if !subs.is_empty() {
196 for sub in &subs {
197 let sub_flags = flag_synopsis_string(&sub.flags)?;
198 buf.push_str(&format!("{prefix} {}{sub_flags}\n", sub.name));
199 }
200 } else {
201 buf.push_str(&format!("{prefix}\n"));
202 }
203
204 buf.push_str("```\n\n");
205
206 if !cmd.aliases.is_empty() {
207 let joined = cmd
208 .aliases
209 .iter()
210 .map(|a| format!("{app_name} {a}"))
211 .collect::<Vec<_>>()
212 .join("`, `");
213 buf.push_str(&format!("**Aliases:** `{joined}`\n\n"));
214 }
215
216 if !cmd.description.is_empty() || !extra.is_empty() {
217 buf.push_str("## Description\n\n");
218
219 if !extra.is_empty() {
220 buf.push_str(&format!("{extra}\n\n"));
221 } else if !cmd.description.is_empty() {
222 buf.push_str(&format!("{}\n\n", cmd.description.trim()));
223 }
224 }
225
226 if !cmd.flags.is_empty() {
227 buf.push_str("## Options\n\n");
228 write_flags(&mut buf, &cmd.flags)?;
229 }
230
231 if !subs.is_empty() && extra.is_empty() {
232 buf.push_str("## Subcommands\n\n");
233
234 for sub in &subs {
235 buf.push_str(&format!("### `{app_name} {} {}`\n\n", cmd.name, sub.name));
236
237 if !sub.usage.is_empty() {
238 buf.push_str(&format!("{}\n\n", sub.usage));
239 }
240
241 if !sub.args_usage.is_empty() {
242 buf.push_str(&format!("**Arguments:** `{}`\n\n", sub.args_usage));
243 }
244
245 if !sub.description.is_empty() {
246 buf.push_str(&format!("{}\n\n", sub.description.trim()));
247 }
248
249 if !sub.flags.is_empty() {
250 buf.push_str("**Options:**\n\n");
251 write_flags(&mut buf, &sub.flags)?;
252 }
253 }
254 }
255
256 Ok(format!("{}\n", buf.trim_end_matches('\n')).into_bytes())
257}
258
259fn index_file_content(
262 app_name: &str,
263 commands: &[Command],
264) -> Result<Vec<u8>, UnsupportedFlagTypeError> {
265 let mut buf = String::new();
266
267 buf.push_str(&format!(
268 "---\ntitle: Commands\ndescription: \"All available {app_name} CLI commands with descriptions and synopsis.\"\ngenerated: true\n---\n\n{GENERATED_COMMENT}\n"
269 ));
270 buf.push_str("| Command | Description |\n|---------|-------------|\n");
271
272 for cmd in commands {
273 buf.push_str(&format!(
274 "| [`{app_name} {}`]({}.md) | {} |\n",
275 cmd.name, cmd.name, cmd.usage
276 ));
277 }
278
279 Ok(format!("{}\n", buf.trim_end_matches('\n')).into_bytes())
280}
281
282fn write_flags(w: &mut String, flags: &[Flag]) -> Result<(), UnsupportedFlagTypeError> {
284 for flag in flags {
285 write_flag(w, flag)?;
286 }
287
288 Ok(())
289}
290
291fn write_flag(w: &mut String, flag: &Flag) -> Result<(), UnsupportedFlagTypeError> {
293 w.push_str("### ");
294
295 for (i, alias) in flag.names.iter().enumerate() {
296 if i > 0 {
297 w.push_str(", ");
298 }
299
300 if alias.len() > 1 {
301 w.push_str(&format!("`--{alias}`"));
302 } else {
303 w.push_str(&format!("`-{alias}`"));
304 }
305 }
306
307 if let Some(default_value) = get_flag_default_value(flag) {
308 w.push_str(&format!(" (default: `{default_value}`)"));
309 }
310
311 w.push_str("\n\n");
312
313 if !flag.usage.is_empty() {
314 w.push_str(&format!("{}\n\n", flag.usage));
315 }
316
317 if !flag.env_vars.is_empty() {
318 w.push_str(&format!(
319 "Environment variable: `{}`\n\n",
320 flag.env_vars.join("`, `")
321 ));
322 }
323
324 Ok(())
325}
326
327fn get_flag_default_value(flag: &Flag) -> Option<String> {
333 if flag.type_name == "bool" {
334 return None;
335 }
336
337 let is_zero = match &flag.default_value {
338 FlagValue::Bool(b) => !*b,
339 FlagValue::String(s) => s.is_empty(),
340 FlagValue::Int(i) => *i == 0,
341 };
342
343 let rendered = flag.default_value.to_string();
344
345 if !rendered.is_empty() && !is_zero {
346 Some(rendered)
347 } else {
348 None
349 }
350}
351
352pub fn write_commands(
355 w: &mut String,
356 commands: &[Command],
357) -> Result<(), UnsupportedFlagTypeError> {
358 let mut sorted: Vec<&Command> = commands.iter().collect();
359 sorted.sort_by(|a, b| a.name.cmp(&b.name));
360
361 for cmd in sorted {
362 write_command(w, cmd)?;
363 }
364
365 Ok(())
366}
367
368pub fn write_command(w: &mut String, cmd: &Command) -> Result<(), UnsupportedFlagTypeError> {
371 w.push_str(&format!("### `{}`\n\n", cmd.name));
372
373 if !cmd.usage.is_empty() {
374 w.push_str(&format!("{}\n\n", cmd.usage));
375 }
376
377 if !cmd.args_usage.is_empty() {
378 w.push_str(&format!("**Arguments:** `{}`\n\n", cmd.args_usage));
379 }
380
381 if !cmd.description.is_empty() {
382 w.push_str(&format!("{}\n\n", cmd.description.trim()));
383 }
384
385 if !cmd.flags.is_empty() {
386 w.push_str("**Options:**\n\n");
387 write_flags(w, &cmd.flags)?;
388 }
389
390 Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
400 fn generate_command_files_writes_expected_files() {
401 let app = Command {
402 name: "myapp".to_string(),
403 usage: "My test application".to_string(),
404 commands: vec![
405 Command {
406 name: "start".to_string(),
407 usage: "Start the service".to_string(),
408 flags: vec![Flag {
409 names: vec!["daemon".to_string()],
410 usage: "Run as daemon".to_string(),
411 env_vars: vec![],
412 type_name: "bool".to_string(),
413 default_value: FlagValue::Bool(false),
414 }],
415 commands: vec![Command {
416 name: "fast".to_string(),
417 usage: "Start fast".to_string(),
418 ..Default::default()
419 }],
420 ..Default::default()
421 },
422 Command {
423 name: "hidden".to_string(),
424 usage: "Hidden command".to_string(),
425 hidden: true,
426 ..Default::default()
427 },
428 ],
429 ..Default::default()
430 };
431
432 let dir = tempfile::tempdir().unwrap();
433 let dir_path = dir.path().to_str().unwrap();
434
435 generate_command_files(dir_path, "", &app).unwrap();
436
437 let index = std::fs::read_to_string(format!("{dir_path}/_index.md")).unwrap();
438 assert!(index.contains("myapp start"));
439 assert!(!index.contains("hidden"));
440
441 let start = std::fs::read_to_string(format!("{dir_path}/start.md")).unwrap();
442 assert!(start.contains("myapp start"));
443 assert!(start.contains("--daemon"));
444 assert!(start.contains("myapp start fast"));
445
446 assert!(
447 std::fs::read_to_string(format!("{dir_path}/hidden.md")).is_err(),
448 "hidden command should not have a file"
449 );
450 }
451
452 #[test]
455 fn write_commands_sorts_alphabetically() {
456 let commands = vec![
457 Command {
458 name: "zebra".to_string(),
459 usage: "Zebra command".to_string(),
460 description: "This should appear last".to_string(),
461 ..Default::default()
462 },
463 Command {
464 name: "alpha".to_string(),
465 usage: "Alpha command".to_string(),
466 description: "This should appear first".to_string(),
467 ..Default::default()
468 },
469 ];
470
471 let mut buf = String::new();
472 write_commands(&mut buf, &commands).expect("writeCommands should not return an error");
473
474 let alpha_pos = buf.find("### `alpha`");
475 let zebra_pos = buf.find("### `zebra`");
476
477 assert!(alpha_pos.is_some(), "Should contain alpha command");
478 assert!(zebra_pos.is_some(), "Should contain zebra command");
479 assert!(
480 alpha_pos.unwrap() < zebra_pos.unwrap(),
481 "Alpha should appear before zebra"
482 );
483 }
484
485 #[test]
488 fn write_command_renders_full_command() {
489 let cmd = Command {
490 name: "deploy".to_string(),
491 usage: "Deploy the application".to_string(),
492 args_usage: "<environment>".to_string(),
493 description: "Deploy the application to the specified environment.\nSupports multiple deployment strategies.".to_string(),
494 flags: vec![Flag {
495 names: vec!["strategy".to_string()],
496 usage: "Deployment strategy".to_string(),
497 env_vars: vec![],
498 type_name: "string".to_string(),
499 default_value: FlagValue::String("rolling".to_string()),
500 }],
501 ..Default::default()
502 };
503
504 let mut buf = String::new();
505 write_command(&mut buf, &cmd).expect("writeCommand should not return an error");
506
507 assert!(buf.contains("### `deploy`"), "Should contain command name");
508 assert!(
509 buf.contains("Deploy the application"),
510 "Should contain usage"
511 );
512 assert!(
513 buf.contains("**Arguments:** `<environment>`"),
514 "Should contain arguments"
515 );
516 assert!(
517 buf.contains("Deploy the application to the specified environment"),
518 "Should contain description"
519 );
520 assert!(buf.contains("**Options:**"), "Should have options section");
521 assert!(buf.contains("--strategy"), "Should contain flag");
522 }
523}