1use std::collections::BTreeMap;
10
11use super::docs::{CommandDoc, DECLARATION};
12use super::{Cli, EXAMPLES};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Violation {
17 pub code: &'static str,
19 pub command: String,
21 pub detail: String,
23 pub rule: &'static str,
25}
26
27impl std::fmt::Display for Violation {
28 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
48pub 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 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 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 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
223pub 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}