Skip to main content

xbp_cli/
docs_export.rs

1use clap::{Arg, ArgAction, Command, CommandFactory};
2use serde::Serialize;
3
4use crate::cli::commands::Cli;
5
6#[derive(Debug, Clone, Serialize)]
7pub struct CommandReferenceExport {
8    pub generated_by: String,
9    pub command_path: Vec<String>,
10    pub command: CommandNode,
11}
12
13#[derive(Debug, Clone, Serialize)]
14pub struct CommandNode {
15    pub name: String,
16    pub display_name: String,
17    pub usage: String,
18    pub about: Option<String>,
19    pub long_about: Option<String>,
20    pub after_help: Option<String>,
21    pub aliases: Vec<String>,
22    pub visible_aliases: Vec<String>,
23    pub arguments: Vec<ArgumentNode>,
24    pub subcommands: Vec<CommandNode>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct ArgumentNode {
29    pub id: String,
30    pub kind: String,
31    pub display: String,
32    pub short: Option<String>,
33    pub long: Option<String>,
34    pub help: Option<String>,
35    pub long_help: Option<String>,
36    pub required: bool,
37    pub global: bool,
38    pub repeatable: bool,
39    pub positional_index: Option<usize>,
40    pub default_values: Vec<String>,
41    pub possible_values: Vec<String>,
42}
43
44pub fn export_command_reference(path: &[String]) -> Result<CommandReferenceExport, String> {
45    let root = sanitize_command_tree(Cli::command());
46    let command = select_command(root, path)?;
47
48    Ok(CommandReferenceExport {
49        generated_by: "xbp-docs-export".to_string(),
50        command_path: path.to_vec(),
51        command: export_command_node(&command, path),
52    })
53}
54
55fn sanitize_command_tree(command: Command) -> Command {
56    command
57        .disable_help_flag(true)
58        .mut_subcommands(sanitize_command_tree)
59}
60
61fn select_command(mut command: Command, path: &[String]) -> Result<Command, String> {
62    for segment in path {
63        let next = command
64            .get_subcommands()
65            .find(|candidate| command_matches(candidate, segment))
66            .cloned()
67            .ok_or_else(|| {
68                let available = command
69                    .get_subcommands()
70                    .map(|candidate| candidate.get_name().to_string())
71                    .collect::<Vec<_>>()
72                    .join(", ");
73                format!(
74                    "Unknown command path segment `{segment}` under `{}`. Available subcommands: {}",
75                    command.get_name(),
76                    if available.is_empty() { "<none>" } else { &available }
77                )
78            })?;
79        command = next;
80    }
81
82    Ok(command)
83}
84
85fn command_matches(command: &Command, segment: &str) -> bool {
86    command.get_name() == segment
87        || command.get_all_aliases().any(|alias| alias == segment)
88        || command.get_visible_aliases().any(|alias| alias == segment)
89}
90
91fn export_command_node(command: &Command, path: &[String]) -> CommandNode {
92    let display_name = if path.is_empty() {
93        "xbp".to_string()
94    } else {
95        format!("xbp {}", path.join(" "))
96    };
97    let usage = {
98        let mut usage_command = command.clone();
99        usage_command
100            .render_usage()
101            .to_string()
102            .replace(
103                &format!("Usage: {}", command.get_name()),
104                &format!("Usage: {display_name}"),
105            )
106    };
107
108    CommandNode {
109        name: command.get_name().to_string(),
110        display_name,
111        usage,
112        about: command.get_about().map(|value| value.to_string()),
113        long_about: command.get_long_about().map(|value| value.to_string()),
114        after_help: command.get_after_help().map(|value| value.to_string()),
115        aliases: command.get_all_aliases().map(str::to_string).collect(),
116        visible_aliases: command
117            .get_visible_aliases()
118            .map(str::to_string)
119            .collect(),
120        arguments: command
121            .get_arguments()
122            .filter(|arg| !arg.is_hide_set())
123            .map(export_argument_node)
124            .collect(),
125        subcommands: command
126            .get_subcommands()
127            .filter(|subcommand| !subcommand.is_hide_set())
128            .map(|subcommand| {
129                let mut next_path = path.to_vec();
130                next_path.push(subcommand.get_name().to_string());
131                export_command_node(subcommand, &next_path)
132            })
133            .collect(),
134    }
135}
136
137fn export_argument_node(arg: &Arg) -> ArgumentNode {
138    let default_values = arg
139        .get_default_values()
140        .iter()
141        .map(|value| value.to_string_lossy().to_string())
142        .collect();
143    let possible_values = arg
144        .get_possible_values()
145        .into_iter()
146        .map(|value| value.get_name().to_string())
147        .collect();
148    let short = arg.get_short().map(|value| format!("-{value}"));
149    let long = arg.get_long().map(|value| format!("--{value}"));
150    let positional_index = arg.get_index().map(|value| value as usize);
151    let value_suffix = if argument_takes_value(arg) {
152        format!(" {}", argument_value_placeholder(arg))
153    } else {
154        String::new()
155    };
156
157    let display = if arg.is_positional() {
158        argument_value_placeholder(arg)
159    } else if let Some(long) = long.as_deref() {
160        format!("{long}{value_suffix}")
161    } else if let Some(short) = short.as_deref() {
162        format!("{short}{value_suffix}")
163    } else {
164        arg.get_id().as_str().to_string()
165    };
166
167    ArgumentNode {
168        id: arg.get_id().as_str().to_string(),
169        kind: if arg.is_positional() {
170            "positional".to_string()
171        } else {
172            "option".to_string()
173        },
174        display,
175        short,
176        long,
177        help: arg.get_help().map(|value| value.to_string()),
178        long_help: arg.get_long_help().map(|value| value.to_string()),
179        required: arg.is_required_set(),
180        global: arg.is_global_set(),
181        repeatable: matches!(arg.get_action(), ArgAction::Append | ArgAction::Count),
182        positional_index,
183        default_values,
184        possible_values,
185    }
186}
187
188fn argument_takes_value(arg: &Arg) -> bool {
189    matches!(arg.get_action(), ArgAction::Set | ArgAction::Append)
190}
191
192fn argument_value_placeholder(arg: &Arg) -> String {
193    if let Some(names) = arg.get_value_names() {
194        let rendered = names
195            .iter()
196            .map(|name| format!("<{}>", name.to_ascii_uppercase()))
197            .collect::<Vec<_>>();
198        if !rendered.is_empty() {
199            return rendered.join(" ");
200        }
201    }
202
203    format!("<{}>", arg.get_id().as_str().replace('-', "_").to_ascii_uppercase())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::export_command_reference;
209
210    #[cfg(feature = "openapi-gen")]
211    #[test]
212    fn exports_generate_openapi_reference() {
213        let export = export_command_reference(&["generate".into(), "openapi".into()])
214            .expect("generate openapi export");
215
216        assert_eq!(export.command.name, "openapi");
217        assert!(
218            export
219                .command
220                .arguments
221                .iter()
222                .any(|argument| argument.id == "service" && argument.display == "--service <SERVICE>")
223        );
224        assert!(
225            export
226                .command
227                .arguments
228                .iter()
229                .any(|argument| argument.id == "all" && argument.display == "--all")
230        );
231    }
232}