1use std::path::Path;
49
50use crate::capability::CapabilityRegistry;
51use crate::cli::CommandDoc;
52use crate::error::Error;
53
54use super::model::{
55 Exemption, ModuleQuality, PublicApiQuality, QualityReport, Violation, ViolationCode, SCHEMA,
56};
57use super::policy::{ExampleRequirement, Verdict, MIN_MODULE_PROSE_WORDS, MIN_PROSE_WORDS};
58use super::source::{Inventory, ItemKind};
59
60pub fn rust_only(crate_dir: &Path, target: &str) -> Result<QualityReport, Error> {
78 let inventory = Inventory::of_crate(crate_dir)?;
79 Ok(assemble(&inventory, target, None))
80}
81
82pub fn inspect(
89 crate_dir: &Path,
90 target: &str,
91 registry: &CapabilityRegistry,
92 tree: &CommandDoc,
93 openapi: &serde_json::Value,
94) -> Result<QualityReport, Error> {
95 let inventory = Inventory::of_crate(crate_dir)?;
96 Ok(assemble(
97 &inventory,
98 target,
99 Some((registry, tree, openapi)),
100 ))
101}
102
103fn assemble(
104 inventory: &Inventory,
105 target: &str,
106 operations: Option<(&CapabilityRegistry, &CommandDoc, &serde_json::Value)>,
107) -> QualityReport {
108 let mut report = QualityReport {
109 schema: SCHEMA.to_string(),
110 target: target.to_string(),
111 ..QualityReport::default()
112 };
113 let (api, module_quality, mut violations) = rust_surface(inventory, target);
114 report.public_api = api;
115 report.modules = module_quality;
116
117 if let Some((registry, tree, openapi)) = operations {
118 let (counts, findings) = super::parity::inspect(registry, tree, openapi);
119 report.operations = counts;
120 violations.extend(findings);
121 }
122
123 report.violations = violations;
124 report.sort();
125 report
126}
127
128fn rust_surface(
130 inventory: &Inventory,
131 target: &str,
132) -> (PublicApiQuality, ModuleQuality, Vec<Violation>) {
133 let mut api = PublicApiQuality::default();
134 let mut modules = ModuleQuality::default();
135 let mut violations = Vec::new();
136 let mut exempt: std::collections::BTreeMap<&'static str, usize> = Default::default();
137
138 for item in inventory.exported() {
139 let at = |file: &str| format!("{target}/{file}");
140 api.items += 1;
141 let documented = !item.doc.trim().is_empty();
142 if documented {
143 api.documented += 1;
144 }
145
146 if item.kind == ItemKind::Module {
147 modules.modules += 1;
148 measure_module(item, inventory, target, &mut modules, &mut violations);
149 continue;
150 }
151
152 if !documented {
159 violations.push(Violation::new(
160 ViolationCode::RustPublicMissingDocs,
161 &item.path,
162 at(&item.file),
163 Some(item.line),
164 format!(
165 "this exported {} carries no documentation",
166 item.kind.noun()
167 ),
168 ));
169 } else if item.kind.carries_behaviour() && item.prose_words() < MIN_PROSE_WORDS {
170 violations.push(Violation::new(
171 ViolationCode::RustPublicThinDocs,
172 &item.path,
173 at(&item.file),
174 Some(item.line),
175 format!(
176 "{} words of prose: too few to say anything the signature has not",
177 item.prose_words()
178 ),
179 ));
180 }
181
182 let requirement = ExampleRequirement::of(item);
184 let Some(reason) = requirement.reason() else {
185 api.owe_example += 1;
186 match Verdict::best(&item.executable_examples_or_all(), &item.name) {
187 Some(Verdict::Counts) => api.exampled += 1,
188 Some(verdict) => {
189 let code = verdict.code().expect("a non-counting verdict has a code");
190 violations.push(Violation::new(
191 code,
192 &item.path,
193 at(&item.file),
194 Some(item.line),
195 example_message(verdict, &item.name),
196 ));
197 }
198 None => violations.push(Violation::new(
199 ViolationCode::RustPublicMissingExample,
200 &item.path,
201 at(&item.file),
202 Some(item.line),
203 format!(
204 "this exported {} carries behaviour and no example of it",
205 item.kind.noun()
206 ),
207 )),
208 }
209 continue;
210 };
211 *exempt.entry(reason).or_default() += 1;
212 }
213
214 api.exempt = exempt
215 .into_iter()
216 .map(|(reason, items)| Exemption {
217 reason: reason.to_string(),
218 items,
219 })
220 .collect();
221 (api, modules, violations)
222}
223
224fn measure_module(
225 item: &super::source::Item,
226 inventory: &Inventory,
227 target: &str,
228 modules: &mut ModuleQuality,
229 violations: &mut Vec<Violation>,
230) {
231 let at = format!("{target}/{}", item.file);
232 let documented = !item.doc.trim().is_empty();
233 if documented {
234 modules.documented += 1;
235 }
236 if !documented {
237 violations.push(Violation::new(
238 ViolationCode::RustModuleMissingDocs,
239 &item.path,
240 &at,
241 Some(item.line),
242 "this exported module carries no //! header".to_string(),
243 ));
244 } else if item.prose_words() < MIN_MODULE_PROSE_WORDS {
245 violations.push(Violation::new(
246 ViolationCode::RustModuleMissingDocs,
247 &item.path,
248 &at,
249 Some(item.line),
250 format!(
251 "{} words of header: a module is a boundary and this does not explain one",
252 item.prose_words()
253 ),
254 ));
255 }
256
257 let subject = &item.name;
260 match Verdict::best(&item.executable_examples_or_all(), subject) {
261 Some(Verdict::Counts) => modules.exampled += 1,
262 Some(Verdict::DoesNotNameSubject) => {
263 if item
266 .executable_examples()
267 .iter()
268 .any(|e| names_something_of(&e.code(), inventory, &item.path))
269 {
270 modules.exampled += 1;
271 } else {
272 violations.push(Violation::new(
273 ViolationCode::RustModuleMissingExample,
274 &item.path,
275 &at,
276 Some(item.line),
277 "the header's example exercises nothing this module declares".to_string(),
278 ));
279 }
280 }
281 Some(verdict) => violations.push(Violation::new(
282 verdict.code().expect("a non-counting verdict has a code"),
283 &item.path,
284 &at,
285 Some(item.line),
286 example_message(verdict, subject),
287 )),
288 None => violations.push(Violation::new(
289 ViolationCode::RustModuleMissingExample,
290 &item.path,
291 &at,
292 Some(item.line),
293 "the //! header carries no executable example of the module's lifecycle".to_string(),
294 )),
295 }
296
297 if inventory.exercised_by_a_test(&item.path) {
298 modules.behaviourally_tested += 1;
299 } else {
300 violations.push(Violation::new(
301 ViolationCode::RustModuleMissingBehaviouralTest,
302 &item.path,
303 &at,
304 Some(item.line),
305 "no #[cfg(test)] test stands beside it and no test under tests/ names it".to_string(),
306 ));
307 }
308}
309
310fn names_something_of(code: &str, inventory: &Inventory, module: &str) -> bool {
312 let prefix = format!("{module}::");
313 inventory
314 .items
315 .iter()
316 .filter(|i| i.exported && i.owner == module && i.path.starts_with(&prefix))
317 .any(|i| super::policy::names(code, &i.name))
318}
319
320fn example_message(verdict: Verdict, subject: &str) -> String {
321 match verdict {
322 Verdict::Counts => String::new(),
323 Verdict::Placeholder => {
324 "every example asserts only what is true of any program".to_string()
325 }
326 Verdict::DoesNotNameSubject => {
327 format!("no example names `{subject}`, so none is evidence about it")
328 }
329 Verdict::NotExecutable => {
330 "every fenced block is ignored or is prose, so the toolchain compiles none of them"
331 .to_string()
332 }
333 }
334}
335
336impl super::source::Item {
337 fn executable_examples_or_all(&self) -> Vec<&super::source::Example> {
341 let executable = self.executable_examples();
342 if executable.is_empty() {
343 self.examples.iter().collect()
344 } else {
345 executable
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn crate_of(lib: &str) -> tempfile::TempDir {
355 let dir = tempfile::tempdir().unwrap();
356 let src = dir.path().join("src");
357 std::fs::create_dir_all(&src).unwrap();
358 std::fs::write(src.join("lib.rs"), lib).unwrap();
359 dir
360 }
361
362 const HEADER: &str = "//! A fixture crate whose header is long enough to be a header: it says what the\n//! module owns, which is the one function below it and nothing else at all.\n//!\n//! ```\n//! assert_eq!(majordomus_cli::two(), 2);\n//! ```\n";
363
364 #[test]
365 fn a_documented_exampled_and_tested_crate_has_no_findings() {
366 let dir = crate_of(&format!(
367 "{HEADER}/// Answers with two, which the signature does not promise.\n///\n/// ```\n/// assert_eq!(majordomus_cli::two(), 2);\n/// ```\npub fn two() -> usize {{ 2 }}\n#[cfg(test)]\nmod tests {{ #[test] fn it_is_two() {{ assert_eq!(super::two(), 2); }} }}\n"
368 ));
369 let r = rust_only(dir.path(), "fixture").unwrap();
370 assert!(r.passes(), "{:#?}", r.violations);
371 assert_eq!(r.public_api.owe_example, 1);
372 assert_eq!(r.public_api.exampled, 1);
373 assert_eq!(r.modules.behaviourally_tested, 1);
374 }
375
376 #[test]
377 fn each_way_of_failing_raises_its_own_code_and_nothing_else() {
378 let dir = crate_of(&format!(
380 "{HEADER}/// Answers with two, which the signature does not promise.\npub fn two() -> usize {{ 2 }}\n#[cfg(test)]\nmod tests {{ #[test] fn t() {{ assert_eq!(super::two(), 2); }} }}\n"
381 ));
382 let r = rust_only(dir.path(), "f").unwrap();
383 assert_eq!(r.of(ViolationCode::RustPublicMissingExample).len(), 1);
384 assert_eq!(r.violations.len(), 1, "{:#?}", r.violations);
385
386 let dir = crate_of(&format!(
388 "{HEADER}/// Answers with two, which the signature does not promise.\n///\n/// ```ignore\n/// two();\n/// ```\npub fn two() -> usize {{ 2 }}\n#[cfg(test)]\nmod tests {{ #[test] fn t() {{ assert_eq!(super::two(), 2); }} }}\n"
389 ));
390 let r = rust_only(dir.path(), "f").unwrap();
391 assert_eq!(r.of(ViolationCode::RustExampleNotExecutable).len(), 1);
392
393 let dir = crate_of(&format!(
395 "{HEADER}/// Answers with two, which the signature does not promise.\n///\n/// ```\n/// let _ = two;\n/// assert!(true);\n/// ```\npub fn two() -> usize {{ 2 }}\n#[cfg(test)]\nmod tests {{ #[test] fn t() {{ assert_eq!(super::two(), 2); }} }}\n"
396 ));
397 let r = rust_only(dir.path(), "f").unwrap();
398 assert_eq!(r.of(ViolationCode::RustExamplePlaceholder).len(), 1);
399
400 let dir = crate_of(&format!(
402 "{HEADER}/// Answers with two, which the signature does not promise.\n///\n/// ```\n/// assert_eq!(1 + 1, 2, \"{{}}\", 2);\n/// let v: Vec<u8> = Vec::new();\n/// assert!(v.is_empty());\n/// ```\npub fn two() -> usize {{ 2 }}\n#[cfg(test)]\nmod tests {{ #[test] fn t() {{ assert_eq!(super::two(), 2); }} }}\n"
403 ));
404 let r = rust_only(dir.path(), "f").unwrap();
405 assert_eq!(
406 r.of(ViolationCode::RustExampleDoesNotNameSubject).len(),
407 1,
408 "{:#?}",
409 r.violations
410 );
411 }
412
413 #[test]
414 fn a_module_with_no_test_beside_it_and_none_naming_it_is_reported() {
415 let dir = crate_of(&format!(
416 "{HEADER}/// Answers with two, which the signature does not promise.\n///\n/// ```\n/// assert_eq!(majordomus_cli::two(), 2);\n/// ```\npub fn two() -> usize {{ 2 }}\n"
417 ));
418 let r = rust_only(dir.path(), "f").unwrap();
419 assert_eq!(
420 r.of(ViolationCode::RustModuleMissingBehaviouralTest).len(),
421 1
422 );
423 assert_eq!(r.modules.behaviourally_tested, 0);
424 }
425
426 #[test]
427 fn what_the_policy_does_not_ask_for_is_counted_and_named() {
428 let dir = crate_of(&format!(
429 "{HEADER}/// A limit that exists for a reason worth writing down here.\npub const LIMIT: usize = 3;\n#[cfg(test)]\nmod tests {{ #[test] fn t() {{ assert_eq!(super::LIMIT, 3); }} }}\n"
430 ));
431 let r = rust_only(dir.path(), "f").unwrap();
432 assert_eq!(r.public_api.owe_example, 0);
433 assert_eq!(r.public_api.exempt.len(), 1);
434 assert_eq!(r.public_api.exempt[0].items, 1);
435 assert!(r.public_api.exempt[0].reason.contains("value"));
436 assert!(r.passes(), "{:#?}", r.violations);
437 }
438
439 #[test]
440 fn a_report_of_the_same_tree_twice_is_the_same_report() {
441 let dir = crate_of(&format!("{HEADER}pub fn two() -> usize {{ 2 }}\n"));
442 let a = rust_only(dir.path(), "f").unwrap();
443 let b = rust_only(dir.path(), "f").unwrap();
444 assert_eq!(a, b);
445 }
446}