1use std::collections::{BTreeMap, BTreeSet};
37
38use super::model::Capability;
39use super::registry::CapabilityRegistry;
40use crate::cli::{CommandDoc, DECLARATION};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum Projection {
48 Cli,
50}
51
52impl Projection {
53 pub fn name(self) -> &'static str {
60 match self {
61 Projection::Cli => "cli",
62 }
63 }
64
65 pub fn declaration(self) -> &'static str {
67 match self {
68 Projection::Cli => DECLARATION,
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Finding {
80 pub code: &'static str,
82 pub capability: String,
84 pub projection: Projection,
86 pub claim: String,
88 pub detail: String,
90 pub source: String,
92 pub rule: &'static str,
94}
95
96impl std::fmt::Display for Finding {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 write!(
99 f,
100 "{}:\n capability: {}\n claims: {}\n source: {}\n projection: {}\n detail: {}\n rule: {}\n reproduce: majordomus capabilities projections --unmet",
101 self.code,
102 self.capability,
103 self.claim,
104 self.source,
105 self.projection.declaration(),
106 self.detail,
107 self.rule,
108 )
109 }
110}
111
112const RULE: &str =
114 "a capability is defined once and every external interface is derived from that definition";
115
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
123pub struct Unbacked {
124 pub command: String,
126 pub path: Vec<String>,
129}
130
131#[derive(
137 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
138)]
139pub struct Row {
140 pub id: String,
142 pub module: String,
144 pub kind: String,
146 pub stability: String,
148 pub effect: super::model::Effect,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub cli: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub http: Option<String>,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub mcp_tool: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub mcp_resource: Option<String>,
164 pub closed: bool,
166 pub source: String,
168}
169
170#[derive(
173 Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
174)]
175#[schemars(rename = "ClosureMatrix")]
176pub struct Matrix {
177 pub rows: Vec<Row>,
179 #[serde(skip, default)]
181 pub findings: Vec<Finding>,
182 pub unbacked: Vec<String>,
184 pub unclassified: Vec<String>,
189}
190
191pub fn findings(registry: &CapabilityRegistry, tree: &CommandDoc) -> Vec<Finding> {
206 let commands = index(tree);
207 let mut out: Vec<Finding> = registry
208 .iter()
209 .filter_map(|c| check(c, &commands))
210 .collect();
211 out.sort_by(|a, b| {
212 (a.capability.as_str(), a.projection, a.code).cmp(&(
213 b.capability.as_str(),
214 b.projection,
215 b.code,
216 ))
217 });
218 out
219}
220
221pub fn unbacked(registry: &CapabilityRegistry, tree: &CommandDoc) -> Vec<Unbacked> {
234 let declared: BTreeSet<&[String]> = registry
235 .iter()
236 .filter_map(|c| c.exposure.cli.as_ref().map(|e| e.path.as_slice()))
237 .collect();
238 let mut out: Vec<Unbacked> = tree
239 .flatten()
240 .into_iter()
241 .filter(|c| c.executable && c.path.len() > 1)
243 .filter(|c| !declared.contains(&c.path[1..]))
244 .map(|c| Unbacked {
245 command: c.command(),
246 path: c.path[1..].to_vec(),
247 })
248 .collect();
249 out.sort();
250 out
251}
252
253impl crate::order::Ordered for Row {
256 fn order_key(&self) -> crate::order::OrderKey<'_> {
257 crate::order::OrderKey::plain(&self.id, &self.id)
258 }
259}
260
261pub fn matrix(registry: &CapabilityRegistry, tree: &CommandDoc) -> Matrix {
263 let commands = index(tree);
264 let mut rows: Vec<Row> = registry.iter().map(|c| row(c, &commands)).collect();
265 crate::order::canonical(&mut rows);
266 let debt = unbacked(registry, tree);
267 Matrix {
268 rows,
269 findings: findings(registry, tree),
270 unbacked: debt.iter().map(|u| u.command.clone()).collect(),
271 unclassified: debt
272 .iter()
273 .filter(|u| crate::cli::local::find(&u.path.join(" ")).is_none())
274 .map(|u| u.command.clone())
275 .collect(),
276 }
277}
278
279fn row(c: &Capability, commands: &BTreeMap<&[String], &CommandDoc>) -> Row {
280 let cli = c.exposure.cli.as_ref();
281 let cli_present = cli
284 .map(|e| {
285 commands
286 .get(e.path.as_slice())
287 .is_some_and(|cmd| cmd.executable)
288 })
289 .unwrap_or(false);
290 Row {
291 id: c.id.to_string(),
292 module: c.module.to_string(),
293 kind: format!("{:?}", c.kind).to_lowercase(),
294 stability: format!("{:?}", c.stability).to_lowercase(),
295 effect: c.execution.effect,
296 cli: cli
297 .filter(|_| cli_present)
298 .map(|e| format!("majordomus {}", e.path.join(" "))),
299 http: c
300 .exposure
301 .http
302 .as_ref()
303 .map(|h| format!("{} {}", h.method.as_str(), h.path)),
304 mcp_tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
305 mcp_resource: c
306 .exposure
307 .mcp
308 .as_ref()
309 .and_then(|m| m.resource.as_ref().map(|r| r.uri.clone())),
310 closed: cli.is_none() || cli_present,
311 source: c.provenance.source_path(),
312 }
313}
314
315fn check(c: &Capability, commands: &BTreeMap<&[String], &CommandDoc>) -> Option<Finding> {
321 let cli = c.exposure.cli.as_ref()?;
322 let claim = format!("majordomus {}", cli.path.join(" "));
323 let finding = |code, detail| Finding {
324 code,
325 capability: c.id.to_string(),
326 projection: Projection::Cli,
327 claim: claim.clone(),
328 detail,
329 source: c.provenance.source_path(),
330 rule: RULE,
331 };
332 match commands.get(cli.path.as_slice()) {
333 None => Some(finding(
334 "CLOSURE_CLI_ABSENT",
335 format!(
336 "the clap declaration has no command `{}`",
337 cli.path.join(" ")
338 ),
339 )),
340 Some(cmd) if !cmd.executable => Some(finding(
341 "CLOSURE_CLI_NOT_RUNNABLE",
342 "the command exists but only groups other commands and cannot be run on its own"
343 .to_string(),
344 )),
345 Some(_) => None,
346 }
347}
348
349fn index(tree: &CommandDoc) -> BTreeMap<&[String], &CommandDoc> {
351 tree.flatten()
352 .into_iter()
353 .filter(|c| c.path.len() > 1)
354 .map(|c| (&c.path[1..], c))
355 .collect()
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::capability::model::{
362 Availability, BenchmarkPolicy, CachePolicy, CapabilityId, CapabilityKind, CliExposure,
363 Exposure, ModuleId, Provenance, Stability, Visibility,
364 };
365 use crate::capability::schema::CanonicalSchema;
366
367 fn claiming(id: &str, path: &[&str]) -> Capability {
369 let exposure = Exposure {
370 cli: Some(CliExposure {
371 path: path.iter().map(|w| w.to_string()).collect(),
372 }),
373 ..Default::default()
374 };
375 Capability {
376 id: CapabilityId::unchecked(id),
377 module: ModuleId::unchecked(id.split('.').next().unwrap()),
378 kind: CapabilityKind::Query,
379 execution: crate::capability::ExecutionPolicy::classify(CapabilityKind::Query),
382 skill: None,
383 title: "T".into(),
384 description: "D".into(),
385 input: CanonicalSchema::of::<()>(),
386 output: CanonicalSchema::of::<()>(),
387 provenance: Provenance::Builtin {
388 module: "majordomus_cli::capability::builtin::demo".into(),
389 },
390 availability: Availability::classify(CapabilityKind::Query, &exposure),
391 visibility: Visibility::classify(&exposure),
392 exposure,
393 stability: Stability::Experimental,
394 tags: vec![],
395 benchmark: BenchmarkPolicy::Required,
396 cache: CachePolicy::Disabled,
397 }
398 }
399
400 fn check_one(c: &Capability, tree: &CommandDoc) -> Option<Finding> {
402 check(c, &index(tree))
403 }
404
405 #[test]
406 fn a_claim_the_command_line_does_not_answer_is_a_finding() {
407 let tree = crate::cli::tree();
408 let bogus = claiming("demo.ghost", &["ghost", "walks"]);
409 let found = check_one(&bogus, &tree).expect("an absent command is a finding");
410 assert_eq!(found.code, "CLOSURE_CLI_ABSENT");
411 assert_eq!(found.capability, "demo.ghost");
412 assert_eq!(found.claim, "majordomus ghost walks");
413 assert!(found.detail.contains("ghost walks"));
414 assert_eq!(
416 found.source,
417 "apps/majordomus-cli/src/capability/builtin/demo.rs"
418 );
419 assert!(!found.source.starts_with('/'), "never a machine path");
420
421 let shown = found.to_string();
423 for part in [
424 "CLOSURE_CLI_ABSENT",
425 "demo.ghost",
426 "majordomus ghost walks",
427 "apps/majordomus-cli/src/capability/builtin/demo.rs",
428 Projection::Cli.declaration(),
429 RULE,
430 "majordomus capabilities projections --unmet",
431 ] {
432 assert!(
433 shown.contains(part),
434 "the finding does not show {part}:\n{shown}"
435 );
436 }
437 }
438
439 #[test]
440 fn a_claim_on_a_group_that_cannot_be_run_is_a_finding() {
441 let tree = crate::cli::tree();
442 let group = tree
445 .flatten()
446 .into_iter()
447 .find(|c| !c.executable && c.path.len() > 1)
448 .expect("the command line has at least one command that only groups others");
449 let words: Vec<&str> = group.path[1..].iter().map(String::as_str).collect();
450 let found = check_one(&claiming("demo.group", &words), &tree)
451 .unwrap_or_else(|| panic!("{} should be unrunnable", group.command()));
452 assert_eq!(found.code, "CLOSURE_CLI_NOT_RUNNABLE");
453 assert!(found.detail.contains("cannot be run"));
454 }
455
456 #[test]
457 fn a_claim_the_command_line_answers_is_no_finding() {
458 let tree = crate::cli::tree();
459 let runnable: Vec<Vec<&str>> = tree
460 .flatten()
461 .into_iter()
462 .filter(|c| c.executable && c.path.len() > 1)
463 .map(|c| c.path[1..].iter().map(String::as_str).collect())
464 .collect();
465 assert!(
466 !runnable.is_empty(),
467 "the command line has runnable commands"
468 );
469 for words in &runnable {
470 assert!(
471 check_one(&claiming("demo.ok", words), &tree).is_none(),
472 "majordomus {} is runnable and must not be a finding",
473 words.join(" ")
474 );
475 }
476 }
477
478 #[test]
479 fn the_declaration_this_crate_ships_is_closed() {
480 let registry = CapabilityRegistry::builder()
481 .with_modules(crate::capability::builtin::modules())
482 .build()
483 .expect("the builtin registry builds");
484 let tree = crate::cli::tree();
485 let unmet = findings(®istry, &tree);
486 assert!(
487 unmet.is_empty(),
488 "every declared CLI exposure must be a runnable command:\n{}",
489 unmet
490 .iter()
491 .map(|f| f.to_string())
492 .collect::<Vec<_>>()
493 .join("\n")
494 );
495 }
496
497 #[test]
498 fn the_matrix_reports_a_row_per_capability_and_hides_nothing_it_cannot_answer() {
499 let registry = CapabilityRegistry::builder()
500 .with_modules(crate::capability::builtin::modules())
501 .build()
502 .unwrap();
503 let tree = crate::cli::tree();
504 let m = matrix(®istry, &tree);
505 assert_eq!(m.rows.len(), registry.len());
506 assert!(m.findings.is_empty());
507 assert!(
508 m.rows.iter().all(|r| r.closed),
509 "the shipped tree is closed"
510 );
511 let ids: Vec<&str> = m.rows.iter().map(|r| r.id.as_str()).collect();
513 let mut sorted = ids.clone();
514 sorted.sort_unstable();
515 assert_eq!(ids, sorted);
516 let commands = index(&tree);
518 for r in m.rows.iter().filter(|r| r.cli.is_some()) {
519 let words: Vec<String> = r.cli.as_ref().unwrap()["majordomus ".len()..]
520 .split(' ')
521 .map(str::to_string)
522 .collect();
523 assert!(
524 commands.get(words.as_slice()).is_some_and(|c| c.executable),
525 "{} reports a command that cannot be run",
526 r.id
527 );
528 }
529 }
530
531 #[test]
532 fn a_row_never_reports_a_command_line_the_claim_does_not_have() {
533 let tree = crate::cli::tree();
536 let commands = index(&tree);
537 let bogus = claiming("demo.ghost", &["ghost", "walks"]);
538 let r = row(&bogus, &commands);
539 assert_eq!(r.id, "demo.ghost");
540 assert_eq!(r.module, "demo");
541 assert_eq!(r.kind, "query");
542 assert_eq!(
543 r.cli, None,
544 "a claim clap cannot answer is not shown as a command"
545 );
546 assert!(!r.closed, "and the row says the claim is unmet");
547 assert_eq!(r.http, None);
548 assert_eq!(r.mcp_tool, None);
549 assert_eq!(r.mcp_resource, None);
550
551 let ok = claiming("demo.ok", &["scope"]);
553 let r = row(&ok, &commands);
554 assert_eq!(r.cli.as_deref(), Some("majordomus scope"));
555 assert!(r.closed);
556 }
557
558 #[test]
559 fn the_matrix_serialises_as_the_shape_every_transport_answers() {
560 let tree = crate::cli::tree();
563 let commands = index(&tree);
564 let m = Matrix {
565 rows: vec![row(&claiming("demo.ok", &["scope"]), &commands)],
566 findings: vec![],
567 unbacked: vec!["majordomus serve".to_string()],
568 unclassified: vec![],
569 };
570 let v = serde_json::to_value(&m).expect("the matrix serialises");
571 assert_eq!(v["rows"][0]["id"], "demo.ok");
572 assert_eq!(v["rows"][0]["cli"], "majordomus scope");
573 assert_eq!(v["rows"][0]["closed"], true);
574 assert_eq!(v["unbacked"][0], "majordomus serve");
575 assert!(
576 v["unclassified"].as_array().expect("an array").is_empty(),
577 "the classified half is carried on the wire too, and is empty here"
578 );
579 assert!(v["rows"][0].get("http").is_none());
582 assert!(v.get("findings").is_none());
585 }
586
587 #[test]
588 fn the_projection_names_itself_and_the_file_it_is_declared_in() {
589 assert_eq!(Projection::Cli.name(), "cli");
590 assert_eq!(Projection::Cli.declaration(), crate::cli::DECLARATION);
591 assert!(
592 !Projection::Cli.declaration().starts_with('/'),
593 "repository-relative, never a machine path"
594 );
595 }
596
597 #[test]
598 fn the_debt_is_the_commands_no_capability_claims() {
599 let registry = CapabilityRegistry::builder()
600 .with_modules(crate::capability::builtin::modules())
601 .build()
602 .unwrap();
603 let tree = crate::cli::tree();
604 let debt = unbacked(®istry, &tree);
605 for expected in ["majordomus serve", "majordomus mcp", "majordomus generate"] {
607 assert!(
608 debt.iter().any(|u| u.command == expected),
609 "{expected} should be in the inventory"
610 );
611 }
612 assert!(
614 !debt.iter().any(|u| u.command == "majordomus why list"),
615 "a derived command is not debt"
616 );
617 assert!(!debt.iter().any(|u| u.command == "majordomus"));
619 let mut sorted = debt.clone();
620 sorted.sort();
621 assert_eq!(
622 debt, sorted,
623 "the inventory is ordered, so a report is stable"
624 );
625 }
626
627 #[test]
628 fn every_command_no_capability_claims_says_why_it_is_local() {
629 let registry = CapabilityRegistry::builder()
634 .with_modules(crate::capability::builtin::modules())
635 .build()
636 .unwrap();
637 let m = matrix(®istry, &crate::cli::tree());
638 assert!(
639 !m.unbacked.is_empty(),
640 "the process commands are always in the inventory"
641 );
642 assert!(
643 m.unclassified.is_empty(),
644 "unclassified commands: {:?}",
645 m.unclassified
646 );
647 }
648}