Skip to main content

majordomus_cli/cli/
docs.rs

1//! The command line as documentation data.
2//!
3//! clap is the one declaration of the command line: every command, argument, default and
4//! value set. This module walks the built [`clap::Command`] into [`CommandDoc`], attaches
5//! the typed examples declared beside the declaration in [`super::EXAMPLES`], and derives
6//! the route each command has on the website. Every projection of the native command line
7//! — `--help`, `docs/generated/cli.md`, `docs/generated/cli.json`, the site dataset and
8//! `/docs/cli/**` — is a rendering of this one tree, never of `--help` prose and never of
9//! a list kept beside the declaration.
10
11use clap::CommandFactory;
12
13use super::{Cli, ExampleDoc};
14
15/// The file the whole command line is declared in; the provenance every projection shows.
16pub const DECLARATION: &str = "apps/majordomus-cli/src/cli.rs";
17
18/// The prefix of every command's route on the website. The native command line's reference
19/// owns this section; the *shell* tool's specification (`docs/CLI.md`) is a different
20/// program and renders elsewhere.
21pub const ROUTE_PREFIX: &str = "/docs/cli";
22
23/// The schema of `docs/generated/cli.json`, the machine-readable projection of this tree.
24pub const SCHEMA: &str = "majordomus/cli/v1";
25
26/// One command of the executable's command line, as clap declares it, with the commands
27/// under it. The root is `majordomus`.
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
29pub struct CommandDoc {
30    /// The full path, `["majordomus", "bench", "coverage"]`.
31    pub path: Vec<String>,
32    /// The route of this command's page, `/docs/cli/bench/coverage/`. Derived here once so
33    /// that no consumer downstream repeats the rule.
34    pub route: String,
35    /// Whether the command can be run on its own: a leaf, or a parent that does something
36    /// when no subcommand follows it. A command that only groups others cannot be run and
37    /// carries no example.
38    pub executable: bool,
39    /// The usage line, `majordomus bench coverage [OPTIONS]`, derived here so that no
40    /// projection assembles one of its own.
41    pub usage: String,
42    /// The one-line description.
43    pub about: String,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    /// The long description, when the command has one.
46    pub long_about: Option<String>,
47    /// The arguments in declaration order: positionals and options, `--help` and
48    /// `--version` excluded.
49    pub args: Vec<ArgDoc>,
50    /// The documented examples of this command, in declaration order. Every one of them is
51    /// parsed by this crate's own parser and run against the built executable by the
52    /// example tests; nothing is shown that is not run.
53    pub examples: Vec<ExampleView>,
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    /// The other names this command answers to, `["wt"]`, as clap declares them.
56    ///
57    /// An alias is declared once, on the command, and every projection that has a use for
58    /// one — the completion, the generated workflow bridge, the reference page — reads it
59    /// here rather than keeping a table of its own.
60    pub aliases: Vec<String>,
61    /// The subcommands in declaration order.
62    pub subcommands: Vec<CommandDoc>,
63}
64
65/// One argument of a command.
66#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
67pub struct ArgDoc {
68    /// The argument's id, `transport`.
69    pub name: String,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    /// `--transport`, without the dashes.
72    pub long: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    /// `-t`, without the dash.
75    pub short: Option<char>,
76    /// Given by position rather than by flag.
77    pub positional: bool,
78    /// The help text.
79    pub help: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    /// The placeholder for the value, `PATH`.
82    pub value_name: Option<String>,
83    /// Whether the argument takes a value at all (a flag does not).
84    pub takes_value: bool,
85    /// Must be given.
86    pub required: bool,
87    /// Accepted by every command under the one that declares it.
88    pub global: bool,
89    /// The values a value-enum argument accepts, with the help of each; empty for any
90    /// other argument. Always present, so a template can ask for its length.
91    pub possible_values: Vec<PossibleValueDoc>,
92    /// The default value(s), as clap renders them; empty when there is none.
93    pub defaults: Vec<String>,
94}
95
96/// One value of a value-enum argument.
97#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
98pub struct PossibleValueDoc {
99    /// The value as typed.
100    pub name: String,
101    /// Its help, when it has one.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub help: Option<String>,
104}
105
106/// One example, as every projection shows it. The command lines are rendered from the
107/// declaration's `argv`, never written out a second time, so what a reader copies is what
108/// the example test executed.
109#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
110pub struct ExampleView {
111    /// The example's id, unique across the whole command line.
112    pub id: String,
113    /// One line: what this example shows.
114    pub title: String,
115    /// What it does and what comes back.
116    pub description: String,
117    /// The argument vector, without the executable's own name.
118    pub argv: Vec<String>,
119    /// The command line as a person types it, rendered from `argv`.
120    pub command: String,
121    /// The commands that run before it in the same session; empty for an example that
122    /// needs nothing prepared.
123    pub setup: Vec<SetupView>,
124    /// What the example test asserts, in one phrase; the evidence the page may cite.
125    pub expectation: String,
126}
127
128impl ExampleView {
129    fn of(e: &ExampleDoc) -> Self {
130        ExampleView {
131            id: e.id.to_string(),
132            title: e.title.to_string(),
133            description: e.description.to_string(),
134            argv: e.argv.iter().map(|a| a.to_string()).collect(),
135            command: render(e.argv),
136            setup: e
137                .setup
138                .iter()
139                .map(|s| SetupView {
140                    argv: s.iter().map(|a| a.to_string()).collect(),
141                    command: render(s),
142                })
143                .collect(),
144            expectation: e.expect.describe(),
145        }
146    }
147}
148
149/// One command run before an example, in the same session.
150#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
151pub struct SetupView {
152    /// The argument vector, without the executable's own name.
153    pub argv: Vec<String>,
154    /// The command line as a person types it, rendered from `argv`.
155    pub command: String,
156}
157
158/// `["bench", "coverage", "--format", "json"]` as `majordomus bench coverage --format json`.
159/// A word that would not survive a shell unquoted is quoted; nothing else is decorated.
160pub fn render(argv: &[&str]) -> String {
161    let mut s = String::from("majordomus");
162    for word in argv {
163        s.push(' ');
164        if word.is_empty()
165            || word.contains(|c: char| c.is_whitespace() || "'\"\\$`*?|&;<>()[]{}#~!".contains(c))
166        {
167            s.push('\'');
168            s.push_str(&word.replace('\'', "'\\''"));
169            s.push('\'');
170        } else {
171            s.push_str(word);
172        }
173    }
174    s
175}
176
177/// The route of a command path: `["majordomus", "bench", "coverage"]` becomes
178/// `/docs/cli/bench/coverage/`, and the root becomes `/docs/cli/`. The one rule; every
179/// other consumer reads [`CommandDoc::route`] rather than repeating it.
180pub fn route(path: &[String]) -> String {
181    let mut s = String::from(ROUTE_PREFIX);
182    for word in path.iter().skip(1) {
183        s.push('/');
184        s.push_str(word);
185    }
186    s.push('/');
187    s
188}
189
190/// The whole command line, from the built clap declaration and the examples declared with
191/// it. Deterministic and pure: clap keeps declaration order, and nothing here reads the
192/// environment, the repository or the network.
193pub fn tree() -> CommandDoc {
194    let mut root = Cli::command();
195    root.build();
196    walk(&root, Vec::new())
197}
198
199fn walk(cmd: &clap::Command, mut path: Vec<String>) -> CommandDoc {
200    path.push(cmd.get_name().to_string());
201    let args: Vec<ArgDoc> = cmd
202        .get_arguments()
203        .filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
204        .map(|a| ArgDoc {
205            name: a.get_id().to_string(),
206            long: a.get_long().map(str::to_string),
207            short: a.get_short(),
208            positional: a.is_positional(),
209            help: a.get_help().map(|h| h.to_string()).unwrap_or_default(),
210            value_name: a
211                .get_value_names()
212                .and_then(|names| names.first())
213                .map(|n| n.to_string()),
214            takes_value: a.get_action().takes_values(),
215            required: a.is_required_set(),
216            global: a.is_global_set(),
217            possible_values: a
218                .get_possible_values()
219                .into_iter()
220                .filter(|v| !v.is_hide_set())
221                .map(|v| PossibleValueDoc {
222                    name: v.get_name().to_string(),
223                    help: v.get_help().map(|h| h.to_string()),
224                })
225                .collect(),
226            defaults: a
227                .get_default_values()
228                .iter()
229                .map(|v| v.to_string_lossy().into_owned())
230                .collect(),
231        })
232        .collect();
233    // clap adds a `help` subcommand to every command that has subcommands; it documents
234    // nothing of its own and is left out, as `--help` is among the arguments
235    let subcommands: Vec<CommandDoc> = cmd
236        .get_subcommands()
237        .filter(|s| !s.is_hide_set() && s.get_name() != "help")
238        .map(|s| walk(s, path.clone()))
239        .collect();
240    // A command a person can run: one with nothing under it, or one whose subcommand is
241    // optional and which therefore does something on its own. Read from clap, never from a
242    // list: a command that grows a required subcommand stops needing an example of its own
243    // on the same edit that makes it unrunnable.
244    let executable = subcommands.is_empty() || !cmd.is_subcommand_required_set();
245    let examples = super::EXAMPLES
246        .iter()
247        .filter(|e| e.command == path[1..].join(" "))
248        .flat_map(|e| e.examples.iter())
249        .map(ExampleView::of)
250        .collect();
251    CommandDoc {
252        route: route(&path),
253        usage: usage(&path, &args, &subcommands, executable),
254        path,
255        executable,
256        about: cmd.get_about().map(|a| a.to_string()).unwrap_or_default(),
257        long_about: cmd.get_long_about().map(|a| a.to_string()),
258        args,
259        examples,
260        aliases: cmd
261            .get_all_aliases()
262            .map(str::to_string)
263            .collect::<Vec<_>>(),
264        subcommands,
265    }
266}
267
268impl CommandDoc {
269    /// Every command, this one first, then the subcommands depth first.
270    pub fn flatten(&self) -> Vec<&CommandDoc> {
271        let mut out = vec![self];
272        for s in &self.subcommands {
273            out.extend(s.flatten());
274        }
275        out
276    }
277
278    /// The command as a person types it: `majordomus bench coverage`.
279    pub fn command(&self) -> String {
280        self.path.join(" ")
281    }
282
283    /// The route of the command this one is under, or `None` for the root.
284    pub fn parent_route(&self) -> Option<String> {
285        (self.path.len() > 1).then(|| route(&self.path[..self.path.len() - 1]))
286    }
287}
288
289/// The usage line of a command: what it is called, that it takes options, whether a
290/// subcommand follows it and whether one must, and its positionals in order.
291fn usage(path: &[String], args: &[ArgDoc], subcommands: &[CommandDoc], executable: bool) -> String {
292    let mut s = path.join(" ");
293    if args.iter().any(|a| !a.positional) {
294        s.push_str(" [OPTIONS]");
295    }
296    if !subcommands.is_empty() {
297        s.push_str(if executable {
298            " [COMMAND]"
299        } else {
300            " <COMMAND>"
301        });
302    }
303    for a in args.iter().filter(|a| a.positional) {
304        let name = a
305            .value_name
306            .clone()
307            .unwrap_or_else(|| a.name.to_uppercase());
308        s.push_str(&if a.required {
309            format!(" <{name}>")
310        } else {
311            format!(" [{name}]")
312        });
313    }
314    s
315}
316
317/// The machine-readable projection of the whole tree: `docs/generated/cli.json`, the file
318/// the website's generator reads. One document, deterministic, with no provenance of its
319/// own beyond the crate version.
320#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
321pub struct CliDocument {
322    /// [`SCHEMA`].
323    pub schema: &'static str,
324    /// The crate version that wrote it.
325    pub version: String,
326    /// [`DECLARATION`]: the file every command in this document is declared in.
327    pub source: &'static str,
328    /// The route prefix the command routes are under.
329    pub route_prefix: &'static str,
330    /// The command tree, root first.
331    pub cli: CommandDoc,
332}
333
334/// The whole tree as the committed JSON projection.
335pub fn document(tree: &CommandDoc, version: &str) -> CliDocument {
336    CliDocument {
337        schema: SCHEMA,
338        version: version.to_string(),
339        source: DECLARATION,
340        route_prefix: ROUTE_PREFIX,
341        cli: tree.clone(),
342    }
343}