Skip to main content

majordomus_cli/cli/
validate.rs

1//! The completeness contract of the native command line's documentation.
2//!
3//! A command of the Rust executable is not finished when it parses: it is finished when it
4//! says what it does, every argument says what it is for, and every command a person can
5//! run carries at least one example that the example tests execute. This module is the one
6//! place that contract is expressed. [`validate`] reports every violation of one run, so a
7//! missing piece is fixed in one edit rather than one violation at a time.
8
9use std::collections::BTreeMap;
10
11use super::docs::{CommandDoc, DECLARATION};
12use super::{Cli, EXAMPLES};
13
14/// One thing missing from the command line's documentation.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Violation {
17    /// The stable code, `CLI_DOC_MISSING_EXAMPLE`; what a reader greps for.
18    pub code: &'static str,
19    /// The command it is about, as a person types it.
20    pub command: String,
21    /// What is missing, in one line.
22    pub detail: String,
23    /// The rule that was broken, in one line.
24    pub rule: &'static str,
25}
26
27impl std::fmt::Display for Violation {
28    /// The actionable form: the code, the command, the file to edit, what is wrong and the
29    /// rule that says so.
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        write!(
32            f,
33            "{}:\n  command: {}\n  source: {}\n  detail: {}\n  rule: {}",
34            self.code, self.command, DECLARATION, self.detail, self.rule
35        )
36    }
37}
38
39fn v(code: &'static str, command: &str, detail: String, rule: &'static str) -> Violation {
40    Violation {
41        code,
42        command: command.to_string(),
43        detail,
44        rule,
45    }
46}
47
48/// Every violation in the tree, in command order, then in example order. An empty result
49/// is the contract met.
50///
51/// ```
52/// // the command line this crate declares documents itself completely
53/// assert!(majordomus_cli::cli::validate(&majordomus_cli::cli::tree()).is_empty());
54/// ```
55pub fn validate(tree: &CommandDoc) -> Vec<Violation> {
56    let mut out = Vec::new();
57    let commands: BTreeMap<String, &CommandDoc> = tree
58        .flatten()
59        .into_iter()
60        .map(|c| (c.path[1..].join(" "), c))
61        .collect();
62
63    // every example set names a command of the tree, and none names it twice: an example
64    // attached to a command that was renamed or removed is exactly the drift this catches
65    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
66    for set in EXAMPLES {
67        *seen.entry(set.command).or_default() += 1;
68        if !commands.contains_key(set.command) {
69            out.push(v(
70                "CLI_DOC_EXAMPLE_WRONG_COMMAND",
71                format!("majordomus {}", set.command).trim_end(),
72                format!(
73                    "{} example(s) are declared for a command the clap declaration does not have",
74                    set.examples.len()
75                ),
76                "an example set names a command of the command line",
77            ));
78        }
79    }
80    for (command, n) in seen.iter().filter(|(_, n)| **n > 1) {
81        out.push(v(
82            "CLI_DOC_DUPLICATE_EXAMPLE_SET",
83            format!("majordomus {command}").trim_end(),
84            format!("{n} example sets are declared for it; one command has one set"),
85            "each command declares its examples once",
86        ));
87    }
88
89    let mut ids: BTreeMap<&str, Vec<String>> = BTreeMap::new();
90    for set in EXAMPLES {
91        for e in set.examples {
92            ids.entry(e.id).or_default().push(set.command.to_string());
93        }
94    }
95    for (id, owners) in ids.iter().filter(|(_, o)| o.len() > 1) {
96        out.push(v(
97            "CLI_DOC_DUPLICATE_EXAMPLE_ID",
98            "majordomus",
99            format!(
100                "example id `{id}` is declared {} times ({})",
101                owners.len(),
102                owners.join(", ")
103            ),
104            "an example id is unique across the whole command line",
105        ));
106    }
107
108    for c in tree.flatten() {
109        let name = c.command();
110        if c.about.trim().is_empty() {
111            out.push(v(
112                "CLI_DOC_MISSING_ABOUT",
113                &name,
114                "the command has no one-line description".to_string(),
115                "every command says in one line what it does",
116            ));
117        }
118        // A parent that only groups commands is described by the commands under it; a
119        // command a person runs answers "what does running this do" in more than a line.
120        if !c.subcommands.is_empty()
121            && c.long_about
122                .as_deref()
123                .map(|l| l.trim().is_empty())
124                .unwrap_or(true)
125            && c.path.len() == 1
126        {
127            out.push(v(
128                "CLI_DOC_MISSING_LONG_ABOUT",
129                &name,
130                "the root of the command line has no long description".to_string(),
131                "the root command carries the long description of the executable",
132            ));
133        }
134        for a in &c.args {
135            if a.help.trim().is_empty() {
136                out.push(v(
137                    "CLI_DOC_EMPTY_ARG_HELP",
138                    &name,
139                    format!("argument `{}` has no help text", a.name),
140                    "every argument says what it is for",
141                ));
142            }
143            if a.takes_value && !a.possible_values.is_empty() {
144                for pv in a.possible_values.iter().filter(|p| {
145                    p.help
146                        .as_deref()
147                        .map(|h| h.trim().is_empty())
148                        .unwrap_or(true)
149                }) {
150                    out.push(v(
151                        "CLI_DOC_EMPTY_VALUE_HELP",
152                        &name,
153                        format!(
154                            "argument `{}` accepts `{}` and does not say what it means",
155                            a.name, pv.name
156                        ),
157                        "every accepted value of an enumerated argument says what it means",
158                    ));
159                }
160            }
161        }
162        if c.executable && c.examples.is_empty() {
163            out.push(v(
164                "CLI_DOC_MISSING_EXAMPLE",
165                &name,
166                "a command that can be run and has no example".to_string(),
167                "every command a person can run carries at least one executable example",
168            ));
169        }
170        if !c.executable && !c.examples.is_empty() {
171            out.push(v(
172                "CLI_DOC_EXAMPLE_ON_GROUP",
173                &name,
174                "the command only groups other commands and cannot be run, yet carries an example"
175                    .to_string(),
176                "an example belongs to a command that can be run",
177            ));
178        }
179        for e in &c.examples {
180            if e.title.trim().is_empty() || e.description.trim().is_empty() {
181                out.push(v(
182                    "CLI_DOC_EMPTY_EXAMPLE",
183                    &name,
184                    format!("example `{}` has no title or no description", e.id),
185                    "an example says what it shows and what comes back",
186                ));
187            }
188            // Level A: the argv shown to a reader is the argv this crate's own parser
189            // accepts. A renamed flag, a removed subcommand or an invalid enumerated value
190            // fails here, at the speed of a unit test.
191            for argv in std::iter::once(&e.argv).chain(e.setup.iter().map(|s| &s.argv)) {
192                if let Err(err) = parse(argv) {
193                    out.push(v(
194                        "CLI_DOC_EXAMPLE_DOES_NOT_PARSE",
195                        &name,
196                        format!(
197                            "example `{}`: `majordomus {}` is not accepted by the parser: {}",
198                            e.id,
199                            argv.join(" "),
200                            err.kind()
201                        ),
202                        "an example parses through the command line it documents",
203                    ));
204                }
205            }
206            if !e.argv.starts_with(&c.path[1..]) {
207                out.push(v(
208                    "CLI_DOC_EXAMPLE_WRONG_COMMAND",
209                    &name,
210                    format!(
211                        "example `{}` runs `majordomus {}`, which is not this command",
212                        e.id,
213                        e.argv.join(" ")
214                    ),
215                    "an example of a command runs that command",
216                ));
217            }
218        }
219    }
220    out
221}
222
223/// The example's argv through the canonical parser. Public so that a test can hold one
224/// argument vector to the declaration without going through the whole tree.
225pub fn parse(argv: &[String]) -> Result<Cli, clap::Error> {
226    use clap::Parser;
227    let mut full = vec!["majordomus".to_string()];
228    full.extend(argv.iter().cloned());
229    Cli::try_parse_from(full)
230}