majordomus_cli/capability/builtin/
layout.rs1use crate::capability::handler::{CapabilityError, Context};
19use crate::capability::model::{CliExposure, Exposure, Stability};
20use crate::capability::module::ModuleDescriptor;
21use crate::layout::{self, LayoutReport};
22use crate::{capability, module};
23
24use super::{get, mcp, Empty};
25
26fn check(ctx: &Context, _: Empty) -> Result<LayoutReport, CapabilityError> {
27 let repository = ctx.repository().map_err(|e| {
28 CapabilityError::Internal(format!("the repository could not be re-read: {e}"))
29 })?;
30 let share = crate::share::Share::locate(None, repository.root())
31 .map_err(|e| CapabilityError::Internal(format!("the distribution was not found: {e}")))?;
32 let kinds = crate::metadata::KindSchema::load(&share, &repository)
33 .map_err(|e| CapabilityError::Internal(format!("the kinds could not be read: {e}")))?;
34 Ok(layout::check(&repository, &ctx.index, &kinds))
35}
36
37pub fn module() -> ModuleDescriptor {
56 module! {
57 id: "layout",
58 title: "Layer layout",
59 description: "The boundary between the layer's tracked and local halves, checked against the layouts their contracts declare: what lives where, and what must never appear there (I1102).",
60 stability: Stability::BehaviorallyVerified,
61 capabilities: [
62 capability! {
63 id: "layout.check",
64 title: "Refuse a misplaced artifact",
65 description: "Every path a declared layout excludes, named with the contract it violates: a tracked file under the tracked half that git's ignore rules match (state that does not travel, under a contract that excludes cache or runtime), and a file under the local half that is a byte copy of a tracked canonical file or declares the schema of a kind share/kinds.yaml calls canonical (under the manifest's `local.excludes: [canonical]`). Only what a contract declares is judged. Reads git and the local half's files; no build and no network.",
66 input: Empty,
67 output: LayoutReport,
68 stability: Stability::BehaviorallyVerified,
69 exposure: Exposure {
70 mcp: mcp("majordomus_layout_check"),
71 http: get("/api/v1/layout/check"),
72 cli: Some(CliExposure { path: vec!["layout".into(), "check".into()] }),
73 },
74 tags: ["layout", "governance", "two-folder"],
75 handler: check,
76 },
77 ],
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::synthetic::SyntheticRepository;
85
86 #[test]
89 fn the_declaration_yields_the_projections_it_claims() {
90 let m = module();
91 assert_eq!(m.id.as_str(), "layout");
92 let ids: Vec<&str> = m
93 .capabilities
94 .iter()
95 .map(|e| e.capability.id.as_str())
96 .collect();
97 assert_eq!(ids, ["layout.check"]);
98 let exposure = &m.capabilities[0].capability.exposure;
99 assert_eq!(
100 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
101 Some("majordomus_layout_check")
102 );
103 assert_eq!(exposure.http.as_ref().unwrap().path, "/api/v1/layout/check");
104 assert_eq!(exposure.cli.as_ref().unwrap().path, ["layout", "check"]);
105 }
106
107 #[test]
110 fn a_repository_that_cannot_be_re_read_is_refused() {
111 let repo = SyntheticRepository::small().unwrap();
112 let ctx = repo.context().unwrap();
113 std::fs::remove_file(repo.root().join(".ai/manifest.yaml")).unwrap();
114
115 let err = check(&ctx, Empty {}).unwrap_err();
116 assert!(
117 matches!(&err, CapabilityError::Internal(m) if m.contains("could not be re-read")),
118 "{err:?}"
119 );
120 }
121
122 #[test]
125 fn kinds_that_do_not_parse_are_refused() {
126 let repo = SyntheticRepository::small().unwrap();
127 let ctx = repo.context().unwrap();
128 let broken = "kinds: [unclosed\n";
129 std::fs::create_dir_all(repo.root().join("share")).unwrap();
130 std::fs::write(repo.root().join("share/kinds.yaml"), broken).unwrap();
131 std::fs::write(repo.root().join(".ai/repo/knowledge/kinds.yaml"), broken).unwrap();
132
133 let err = check(&ctx, Empty {}).unwrap_err();
134 assert!(
135 matches!(&err, CapabilityError::Internal(m) if m.contains("kinds could not be read")),
136 "{err:?}"
137 );
138 }
139}