Skip to main content

majordomus_cli/capability/builtin/
layout.rs

1//! The `layout` module: the boundary between the layer's two halves, checked against the
2//! layouts their contracts declare (I2871, I1102).
3//!
4//! One capability, `layout.check`, and it is a gate: a path a contract excludes is a
5//! finding that names the path and the contract. The judgement lives in [`crate::layout`];
6//! this module declares it once and every projection — the command line, MCP, HTTP, the
7//! benchmark — is derived from the declaration. `directories` answers what a contract says;
8//! this answers whether the tree keeps it.
9//!
10//! ```
11//! use majordomus_cli::capability::builtin::modules;
12//! let m = modules().into_iter().find(|m| m.id.as_str() == "layout").unwrap();
13//! let c = &m.capabilities[0].capability;
14//! assert_eq!(c.id.as_str(), "layout.check");
15//! assert_eq!(c.exposure.cli.as_ref().unwrap().path, ["layout", "check"]);
16//! ```
17
18use 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
37/// The `layout` module: the one capability that judges where an artifact lives against the
38/// layout each contract declares, composed into the registry by `builtin::modules`.
39///
40/// The gate refuses by its answer and never by changing anything: its one capability is a
41/// read, reached under the same name from the command line, MCP and HTTP.
42///
43/// ```
44/// use majordomus_cli::capability::builtin::layout;
45/// use majordomus_cli::capability::Effect;
46/// let m = layout::module();
47/// assert_eq!(m.id.as_str(), "layout");
48/// let check = &m.capabilities[0].capability;
49/// assert_eq!(check.id.as_str(), "layout.check");
50/// assert_eq!(check.execution.effect, Effect::Read);
51/// assert_eq!(check.exposure.http.as_ref().unwrap().path, "/api/v1/layout/check");
52/// let tool = check.exposure.mcp.as_ref().and_then(|m| m.tool.as_deref());
53/// assert_eq!(tool, Some("majordomus_layout_check"));
54/// ```
55pub 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    /// The declaration is the only place these names exist; a refactor that dropped an
87    /// exposure would still compile, and this is the assertion that would not.
88    #[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    /// A repository whose manifest is gone by the time the gate runs is an internal error
108    /// naming the re-read, never an answer about a tree nobody could read.
109    #[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    /// Kinds that do not parse — the distribution's or the repository's own — are an
123    /// internal error naming the kinds, never an answer judged without them.
124    #[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}