1use std::path::{Path, PathBuf};
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
38use crate::capability::handler::{CapabilityError, Context};
39use crate::capability::model::{
40 CachePolicy, CliExposure, Exposure, McpExposure, McpResource, Stability,
41};
42use crate::capability::module::ModuleDescriptor;
43use crate::quality::rustdoc::{RustdocFindingKind, RustdocReport, Subject, Tree};
44use crate::quality::{QualityReport, ViolationCode};
45use crate::web::discover::{discover, Runtime, GENERATED_ROOT};
46use crate::{capability, module};
47
48use super::get;
49
50pub const QUALITY_URI: &str = "majordomus://quality";
52
53pub const RUSTDOC_URI: &str = "majordomus://quality/rustdoc";
55
56pub const CRATE_DIR: &str = crate::capability::model::CRATE_DIR;
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
62pub struct QualityInput {
63 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub code: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub path: Option<String>,
72 #[serde(default)]
75 pub summary_only: bool,
76 #[serde(default)]
83 pub include_baselined: bool,
84}
85
86impl BenchmarkCases for QualityInput {
87 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
88 vec![NamedCase::new("default", QualityInput::default())]
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97pub struct QualityAnswer {
98 pub measured: bool,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub reason: Option<String>,
105 pub report: QualityReport,
108 pub passes: bool,
112 pub baselined: usize,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
125pub(crate) struct RustdocInput {
126 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub kind: Option<RustdocFindingKind>,
131 #[serde(default)]
134 pub summary_only: bool,
135}
136
137impl BenchmarkCases for RustdocInput {
138 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
139 vec![
142 NamedCase::new("default", RustdocInput::default()),
143 NamedCase::new(
144 "missing-pages-summary",
145 RustdocInput {
146 kind: Some(RustdocFindingKind::MissingPage),
147 summary_only: true,
148 },
149 ),
150 ]
151 }
152}
153
154pub(crate) fn rustdoc_tree(root: &Path) -> Tree {
161 use crate::quality::rustdoc::{PRODUCER, SURFACE};
162 let shown = format!("{GENERATED_ROOT}/{SURFACE}");
163 let topology = match discover(root, Runtime::full()) {
164 Ok(t) => t,
165 Err(e) => {
166 let reason =
167 format!("the web topology cannot be read, so '{SURFACE}' cannot be found: {e}");
168 return Tree::Absent { shown, reason };
169 }
170 };
171 let Some(surface) = topology.get(SURFACE) else {
172 return Tree::Absent {
173 shown,
174 reason: format!(
175 "the web topology declares no '{SURFACE}' surface: discovery declares it from the crate, which this repository does not have, so {PRODUCER} has nothing to build"
176 ),
177 };
178 };
179 let Some(artifact) = &surface.artifact else {
180 return Tree::Absent {
181 shown,
182 reason: format!("the '{SURFACE}' surface declares no artifact to judge"),
183 };
184 };
185 Tree::At {
186 dir: root.join(artifact),
187 shown: artifact.to_string_lossy().replace('\\', "/"),
188 mount: Some(surface.mount.as_str().to_string()),
189 }
190}
191
192pub(crate) fn judge_rustdoc(root: &Path, tree: Tree) -> Result<RustdocReport, crate::Error> {
195 let dir = crate_dir(root);
196 let head = crate::worktree::git::head_of(root).ok().flatten();
197 crate::quality::rustdoc::judge(&Subject {
198 root,
199 crate_dir: dir.as_deref(),
200 tree,
201 head: head.as_deref(),
202 })
203}
204
205fn rustdoc(ctx: &Context, input: RustdocInput) -> Result<RustdocReport, CapabilityError> {
206 let root = Path::new(&ctx.index.repository.root);
207 let report = judge_rustdoc(root, rustdoc_tree(root))
208 .map_err(|e| CapabilityError::Internal(e.to_string()))?;
209 Ok(report.filtered(input.kind, input.summary_only))
210}
211
212pub fn crate_dir(root: &Path) -> Option<PathBuf> {
224 let dir = root.join(CRATE_DIR);
225 (dir.join("Cargo.toml").is_file() && dir.join("src/lib.rs").is_file()).then_some(dir)
226}
227
228pub fn baseline_keys(root: &Path) -> std::collections::BTreeSet<String> {
240 let path = root.join(BASELINE);
241 let Ok(text) = std::fs::read_to_string(path) else {
242 return Default::default();
243 };
244 text.lines()
245 .map(str::trim)
246 .filter(|l| !l.is_empty() && !l.starts_with('#'))
247 .map(str::to_string)
248 .collect()
249}
250
251pub const BASELINE: &str = ".ai/repo/rust-quality-baseline.txt";
253
254pub fn baseline_key(violation: &crate::quality::Violation) -> String {
268 format!(
269 "{}\t{}\t{}",
270 violation.code.as_str(),
271 violation.path,
272 violation.symbol
273 )
274}
275
276fn report(ctx: &Context, input: QualityInput) -> Result<QualityAnswer, CapabilityError> {
277 if let Some(code) = &input.code {
278 if !ViolationCode::all().iter().any(|c| c.as_str() == code) {
279 return Err(CapabilityError::InvalidInput(format!(
280 "'{code}' is not a violation code; the codes are: {}",
281 ViolationCode::all()
282 .iter()
283 .map(|c| c.as_str())
284 .collect::<Vec<_>>()
285 .join(", ")
286 )));
287 }
288 }
289 let root = Path::new(&ctx.index.repository.root);
290 let Some(dir) = crate_dir(root) else {
291 return Ok(QualityAnswer {
292 measured: false,
293 reason: Some(format!(
294 "no Rust crate at {CRATE_DIR}; there is nothing here for this rule to apply to"
295 )),
296 report: QualityReport {
297 schema: crate::quality::SCHEMA.to_string(),
298 target: CRATE_DIR.to_string(),
299 ..QualityReport::default()
300 },
301 passes: true,
302 baselined: 0,
303 });
304 };
305
306 let openapi = crate::http::openapi::document(&ctx.registry, crate::VERSION, None)
307 .map_err(|e| CapabilityError::Internal(e.to_string()))?;
308 let mut report = crate::quality::report::inspect(
309 &dir,
310 CRATE_DIR,
311 &ctx.registry,
312 &crate::cli::tree(),
313 &openapi,
314 )
315 .map_err(|e| CapabilityError::Internal(e.to_string()))?;
316
317 let accepted = baseline_keys(root);
318 let baselined = report
319 .violations
320 .iter()
321 .filter(|v| accepted.contains(&baseline_key(v)))
322 .count();
323 let passes = report
327 .violations
328 .iter()
329 .all(|v| accepted.contains(&baseline_key(v)));
330 if !input.include_baselined {
331 report
332 .violations
333 .retain(|v| !accepted.contains(&baseline_key(v)));
334 }
335
336 if let Some(code) = &input.code {
337 report.violations.retain(|v| v.code.as_str() == code);
338 }
339 if let Some(prefix) = &input.path {
340 report.violations.retain(|v| v.path.starts_with(prefix));
341 }
342 if input.summary_only {
343 report.violations.clear();
344 }
345 Ok(QualityAnswer {
346 measured: true,
347 reason: None,
348 report,
349 passes,
350 baselined,
351 })
352}
353
354pub fn module() -> ModuleDescriptor {
356 module! {
357 id: "quality",
358 title: "Public API quality",
359 description: "What this executable's own public surface is held to, measured from its syntax tree: documentation that says more than the signature, an executable example on everything that carries behaviour, a module boundary something exercises, and every command of the command line accounted for against the capability registry. The rules are project.rust-public-api-quality and project.operation-transport-parity; this is the measurement of them.",
360 stability: Stability::BehaviorallyVerified,
361 capabilities: [
362 capability! {
363 id: "quality.report",
364 title: "Public API quality report",
365 description: "The crate's exported surface measured against the repository's rules: how many items are documented and exampled, how many modules are documented, exampled and behaviourally tested, how the canonical operations stand against the command line, HTTP, OpenAPI and MCP, and one finding per violation carrying a stable code, the rule that requires it, its file and line, why it matters and what to do about it.",
366 input: QualityInput,
367 output: QualityAnswer,
368 stability: Stability::BehaviorallyVerified,
369 exposure: Exposure {
370 mcp: Some(McpExposure {
371 tool: Some("majordomus_quality".into()),
372 resource: Some(McpResource { uri: QUALITY_URI.into(), name: "quality".into() }),
373 }),
374 http: get("/api/v1/quality"),
375 cli: Some(CliExposure { path: vec!["quality".into(), "report".into()] }),
376 },
377 tags: ["quality", "introspection", "rust"],
378 cache: CachePolicy::Process { max_entries: 8, ttl_seconds: Some(10) },
382 handler: report,
383 },
384 capability! {
385 id: "quality.rustdoc",
386 title: "Rustdoc tree integrity",
387 description: "The crate's rustdoc tree — the rustdoc surface's artifact, as the web topology resolves it — judged against the crate's own inventory of exported items: every item that owns a page has it at the route rustdoc gives it, no item page is left without an item, the tree declares it was built from HEAD, the library's index is present and names the crate, its assets are present, every relative link resolves, and no file names the machine it was built on or carries a credential. Answers the verdict (clean, findings, or no_tree when there is nothing to judge), the counts it joined, every exported module with its page, and one typed finding per defect with the file and what to do.",
388 input: RustdocInput,
389 output: RustdocReport,
390 stability: Stability::BehaviorallyVerified,
391 exposure: Exposure {
392 mcp: Some(McpExposure {
393 tool: Some("majordomus_quality_rustdoc".into()),
394 resource: Some(McpResource { uri: RUSTDOC_URI.into(), name: "quality-rustdoc".into() }),
395 }),
396 http: get("/api/v1/quality/rustdoc"),
397 cli: Some(CliExposure { path: vec!["quality".into(), "rustdoc".into()] }),
398 },
399 tags: ["quality", "introspection", "rust", "documentation"],
400 cache: CachePolicy::Process { max_entries: 8, ttl_seconds: Some(10) },
403 handler: rustdoc,
404 },
405 ],
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
417 fn the_declaration_yields_the_projections_it_claims() {
418 let m = module();
419 assert_eq!(m.id.as_str(), "quality");
420 let ids: Vec<&str> = m
421 .capabilities
422 .iter()
423 .map(|e| e.capability.id.as_str())
424 .collect();
425 assert_eq!(ids, ["quality.report", "quality.rustdoc"]);
426 let e = &m.capabilities[0].capability.exposure;
427 assert_eq!(
428 e.mcp.as_ref().and_then(|m| m.tool.as_deref()),
429 Some("majordomus_quality")
430 );
431 assert_eq!(
432 e.mcp
433 .as_ref()
434 .and_then(|m| m.resource.as_ref())
435 .map(|r| r.uri.as_str()),
436 Some(QUALITY_URI)
437 );
438 assert_eq!(
439 e.http.as_ref().map(|h| h.path.as_str()),
440 Some("/api/v1/quality")
441 );
442 assert_eq!(
443 e.cli.as_ref().map(|c| c.path.clone()),
444 Some(vec!["quality".to_string(), "report".to_string()])
445 );
446 }
447
448 #[test]
449 fn a_baseline_key_survives_the_finding_moving_down_its_file() {
450 use crate::quality::{Violation, ViolationCode};
451 let at = |line| {
452 Violation::new(
453 ViolationCode::RustPublicMissingExample,
454 "majordomus_cli::a::b",
455 "apps/majordomus-cli/src/a.rs",
456 Some(line),
457 "none",
458 )
459 };
460 assert_eq!(baseline_key(&at(9)), baseline_key(&at(400)));
461 }
462
463 #[test]
464 fn a_repository_with_no_crate_is_answered_and_never_failed() {
465 let dir = tempfile::tempdir().unwrap();
466 assert_eq!(crate_dir(dir.path()), None);
467 let answer = QualityAnswer {
469 measured: false,
470 reason: Some("no crate".into()),
471 report: QualityReport::default(),
472 passes: true,
473 baselined: 0,
474 };
475 let json = serde_json::to_value(&answer).unwrap();
476 assert_eq!(json["measured"], false);
477 assert_eq!(json["passes"], true);
478 assert!(json.get("report").is_some(), "one shape either way");
479 }
480
481 fn repository_with_the_crate() -> tempfile::TempDir {
484 let dir = tempfile::tempdir().unwrap();
485 let krate = dir.path().join(CRATE_DIR);
486 std::fs::create_dir_all(krate.join("src")).unwrap();
487 std::fs::write(
488 krate.join("Cargo.toml"),
489 "[package]\nname = \"majordomus-cli\"\n[lib]\nname = \"majordomus_cli\"\n",
490 )
491 .unwrap();
492 std::fs::write(krate.join("src/lib.rs"), "//! Root.\n").unwrap();
493 dir
494 }
495
496 #[test]
497 fn the_rustdoc_tree_is_the_topologys_surface_and_never_a_callers_path() {
498 use crate::quality::rustdoc::{PRODUCER, SURFACE};
499 let bare = tempfile::tempdir().unwrap();
501 match rustdoc_tree(bare.path()) {
502 Tree::Absent { shown, reason } => {
503 assert_eq!(shown, format!("{GENERATED_ROOT}/{SURFACE}"));
504 assert!(reason.contains(PRODUCER), "{reason}");
505 }
506 other => panic!("{other:?}"),
507 }
508 let repo = repository_with_the_crate();
510 let out = repo.path().join(GENERATED_ROOT).join(SURFACE);
511 std::fs::create_dir_all(&out).unwrap();
512 std::fs::write(
513 out.join("surface.json"),
514 r#"{"schema":"web-surface/v1","id":"rustdoc","mount":"/rustdoc","built_from":"abc"}"#,
515 )
516 .unwrap();
517 match rustdoc_tree(repo.path()) {
518 Tree::At { dir, shown, mount } => {
519 assert_eq!(dir, out);
520 assert_eq!(shown, "target/web/rustdoc");
521 assert_eq!(mount.as_deref(), Some("/rustdoc"));
522 }
523 other => panic!("{other:?}"),
524 }
525 }
526
527 #[test]
528 fn a_repository_without_the_tree_or_the_crate_is_no_tree_and_exits_twelve() {
529 use crate::quality::rustdoc::RustdocVerdict;
530 let bare = tempfile::tempdir().unwrap();
531 let report = judge_rustdoc(bare.path(), rustdoc_tree(bare.path())).unwrap();
532 assert_eq!(report.verdict, RustdocVerdict::NoTree);
533 assert_eq!(report.exit_code(), 12);
534 assert!(report.reason.unwrap().contains("no Rust crate"));
535
536 let repo = repository_with_the_crate();
539 let report = judge_rustdoc(repo.path(), rustdoc_tree(repo.path())).unwrap();
540 assert_eq!(report.exit_code(), 12, "{report:?}");
541 assert_eq!(report.krate, "majordomus_cli");
542 assert_eq!(report.modules[0].route, "majordomus_cli/index.html");
543 }
544}