Skip to main content

xianyun_update/
docgen.rs

1//! Generates documentation from CLI applications.
2//!
3//! Ported from `internal/docgen/markdown.go`. The Go original reflects
4//! over `github.com/urfave/cli/v3`'s `cli.Command`/`cli.Flag` types; no
5//! equivalent introspectable CLI framework is wired into `xianyun-cli`
6//! yet, so [`Command`] and [`Flag`] are minimal structs/traits mirroring
7//! the subset of `cli.Command`/`cli.Flag` this package's tests actually
8//! exercise (`Name`, `Usage`, `Commands`, `Flags`, `Hidden`, `Aliases`,
9//! `ArgsUsage`, `Description`, and the `Flag` interface's `Names`/`Get`/
10//! `TypeName`/`GetUsage`/`GetEnvVars`). Whether the real `xianyun-cli`
11//! crate ends up using `clap` (whose derive API has no direct reflection
12//! equivalent) or a hand-rolled introspectable command tree is a Phase 4
13//! design decision -- see `PORT_NOTES.md`.
14
15use std::fmt;
16
17/// Ported from the subset of `github.com/urfave/cli/v3`'s `cli.Command`
18/// this package's functions read.
19#[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/// Ported from the `Flag` interface in `markdown.go` (itself a superset of
32/// `cli.Flag` adding `GetUsage`/`GetEnvVars`/`TypeName`).
33#[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/// Ported from the reflection `flag.Get()` result that
43/// `getFlagDefaultValue` compares against the zero value of its own type.
44/// A closed enum stands in for Go's `any`, since this port's `Flag` has no
45/// reflection to fall back on.
46#[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/// Ported from `ErrUnsupportedFlagType`. This port's `Flag` has no
64/// reflection escape hatch, so it is unused until a real CLI framework
65/// integration needs it; kept for interface parity.
66#[derive(Debug, thiserror::Error, PartialEq, Eq)]
67#[error("unsupported flag type: {0}")]
68pub struct UnsupportedFlagTypeError(pub String);
69
70/// Ported from `GenerateCommandFiles`. Writes one markdown file per
71/// visible top-level command into `out_dir`, plus an `_index.md` listing
72/// all commands. `cmd_base_dir` is the directory containing per-command
73/// packages (e.g. `"cmd"`); a `DOC.md` file found at
74/// `cmd_base_dir/{normalized_name}/DOC.md` is appended to each command's
75/// Description section.
76pub 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
104/// Ported from `readDocMD`. Normalizes the command name by stripping
105/// hyphens so that e.g. `"update-etc-hosts"` maps to
106/// `cmd/updateetchosts/DOC.md`.
107fn 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
117/// Ported from `visibleCommands`. Returns commands sorted by name,
118/// excluding hidden ones.
119fn 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
126/// Ported from the `generatedComment`/`cautionBlock` constants.
127const 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
135/// Ported from `flagSynopsisString`. Returns the synopsis fragment for a
136/// list of flags, e.g. `" [--foo=bar] [-x]"`.
137fn 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
164/// Ported from `commandFileContent`. Renders a single command's markdown
165/// file with Hugo front matter. `extra` is optional content from a
166/// `DOC.md` file appended after `cmd.description`.
167fn 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    // Synopsis (before Description, as in man pages)
186    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
259/// Ported from `indexFileContent`. Renders the `_index.md` listing all
260/// visible commands.
261fn 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
282/// Ported from `writeFlags`.
283fn 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
291/// Ported from `writeFlag`. Writes a single flag's documentation.
292fn 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
327/// Ported from `getFlagDefaultValue`. Unlike Go's reflection-based zero
328/// value comparison, this port's [`FlagValue`] is a closed enum, so the
329/// "zero value" per variant is hand-coded: `false` for bools (which are
330/// always excluded, matching `flag.TypeName() != "bool"`), empty string
331/// for strings, and `0` for ints.
332fn 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
352/// Ported from `writeCommands`. Writes command documentation to the
353/// writer, sorted by name for consistent output.
354pub 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
368/// Ported from `writeCommand`. Writes a single command's documentation to
369/// the writer.
370pub 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    // --- Ported from TestGenerateCommandFiles ---
398
399    #[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    // --- Ported from TestWriteCommands ---
453
454    #[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    // --- Ported from TestWriteCommand ---
486
487    #[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}