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}