Skip to main content

majordomus_cli/capability/builtin/
repository.rs

1//! The `repository` module: what the repository and this process's index are.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::capability::handler::{CapabilityError, Context};
7use crate::capability::model::{CliExposure, Exposure, McpExposure, McpResource, Stability};
8use crate::capability::module::ModuleDescriptor;
9use crate::capability::registry::Summary;
10use crate::index::{RepositoryInfo, State};
11use crate::model::Diagnostic;
12use crate::scope::Classification;
13use crate::{capability, module};
14
15use super::scope::{scope_classify, scope_info, ClassifyInput, ScopeReport, SCOPE_URI};
16use super::{get, mcp, Empty};
17
18/// The URI under which `repository.info` is read as an MCP resource, and the one URI
19/// `objects.get` answers by executing a query rather than by reading the index.
20pub const REPOSITORY_URI: &str = "majordomus://repository";
21
22/// The repository, its layer, its git state, and the state of this process's index.
23#[derive(Debug, Serialize, Deserialize, JsonSchema)]
24pub struct RepositoryReport {
25    /// The repository: root, layer schema, sections, git state, discovery mode, source classes, kind sources.
26    pub repository: RepositoryInfo,
27    /// `ok` when every discovered file became an object, `degraded` otherwise.
28    pub state: State,
29    /// How many objects the index holds.
30    pub objects: usize,
31    /// Objects per kind.
32    pub kinds: std::collections::BTreeMap<String, usize>,
33    /// Every diagnostic the index produced.
34    pub diagnostics: Vec<Diagnostic>,
35    /// The capability registry, counted.
36    pub capabilities: Summary,
37}
38
39fn repository_info(ctx: &Context, _: Empty) -> Result<RepositoryReport, CapabilityError> {
40    let index = &ctx.index;
41    Ok(RepositoryReport {
42        repository: index.repository.clone(),
43        state: index.state,
44        objects: index.objects.len(),
45        kinds: index
46            .kinds()
47            .into_iter()
48            .map(|(k, v)| (k.to_string(), v))
49            .collect(),
50        diagnostics: index.diagnostics.clone(),
51        capabilities: ctx.registry.summary(),
52    })
53}
54
55/// The capability `majordomus scope <PATHS>` answers with when it is given paths.
56///
57/// Named here, beside the declaration, because the command module must reach it by
58/// identity: it has no command line of its own to be found by.
59///
60/// ```
61/// use majordomus_cli::capability::builtin::repository::SCOPE_CLASSIFY;
62/// assert_eq!(SCOPE_CLASSIFY, "repository.scope_classify");
63/// ```
64pub const SCOPE_CLASSIFY: &str = "repository.scope_classify";
65
66/// The module.
67pub fn module() -> ModuleDescriptor {
68    module! {
69        id: "repository",
70        title: "Repository",
71        description: "The repository this process serves: its layer, its git state, the state of the index built from it, and its scope: what a worker reads of it and what it never reads.",
72        stability: Stability::BehaviorallyVerified,
73        capabilities: [
74            capability! {
75                id: "repository.info",
76                title: "Repository and index state",
77                description: "The repository root, layer sections, git state, discovery mode, kinds present, every diagnostic, and the capability registry counted.",
78                input: Empty,
79                output: RepositoryReport,
80                stability: Stability::BehaviorallyVerified,
81                exposure: Exposure {
82                    mcp: Some(McpExposure {
83                        tool: Some("majordomus_repository".into()),
84                        resource: Some(McpResource { uri: REPOSITORY_URI.into(), name: "repository".into() }),
85                    }),
86                    http: get("/api/v1/repository"),
87                    cli: None,
88                },
89                tags: ["repository", "introspection"],
90                cache: crate::capability::CachePolicy::Generation { max_entries: 1 },
91                handler: repository_info,
92            },
93            capability! {
94                id: "repository.scope",
95                title: "The repository scope",
96                description: "The scope declaration as read, where it came from (the repository's own or the distribution's default), and every tracked file tallied against it: how many are in, how many are out for each reason, and which.",
97                input: Empty,
98                output: ScopeReport,
99                stability: Stability::BehaviorallyVerified,
100                exposure: Exposure {
101                    mcp: Some(McpExposure {
102                        tool: Some("majordomus_scope".into()),
103                        resource: Some(McpResource { uri: SCOPE_URI.into(), name: "scope".into() }),
104                    }),
105                    http: get("/api/v1/scope"),
106                    cli: Some(CliExposure { path: vec!["scope".into()] }),
107                },
108                tags: ["repository", "scope", "introspection"],
109                handler: scope_info,
110            },
111            capability! {
112                id: "repository.scope_classify",
113                title: "Judge a path against the scope",
114                description: "Whether a repository-relative path is in or out of the scope, the reason when it is out, and the pattern or limit that decided; an existing file is judged by name, then size, then content.",
115                input: ClassifyInput,
116                output: Classification,
117                stability: Stability::BehaviorallyVerified,
118                // No CLI exposure: `majordomus scope <PATHS>` is one command that answers
119                // with this capability when it is given paths and with `repository.scope`
120                // when it is not, and clap has no `scope classify` subcommand for anybody
121                // to type. The path used to be declared here so that the command module
122                // could find the id with `by_cli`, which made a lookup key look like a
123                // public command line — `capabilities describe`, `docs/generated/cli.md`
124                // and the site's registry all repeated a command that does not exist. The
125                // command module names [`SCOPE_CLASSIFY`] instead, and this exposure says
126                // what is true: the capability is reached through the command above.
127                exposure: Exposure {
128                    mcp: mcp("majordomus_scope_classify"),
129                    http: get("/api/v1/scope/classify"),
130                    cli: None,
131                },
132                tags: ["repository", "scope"],
133                handler: scope_classify,
134            },
135        ],
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// The declaration is the only place these names exist, and every projection — the MCP
144    /// tool, the HTTP route, the OpenAPI operation, the benchmark target — is derived from
145    /// it. A refactor that dropped an exposure or renamed a route would still compile, and
146    /// the suites that exercise the behaviour behind it would still pass. This is the
147    /// assertion that would not.
148    #[test]
149    fn the_declaration_yields_the_projections_it_claims() {
150        let m = module();
151        assert_eq!(m.id.as_str(), "repository");
152        let expected: &[(&str, &str, &str)] = &[
153            (
154                "repository.info",
155                "majordomus_repository",
156                "/api/v1/repository",
157            ),
158            ("repository.scope", "majordomus_scope", "/api/v1/scope"),
159            (
160                "repository.scope_classify",
161                "majordomus_scope_classify",
162                "/api/v1/scope/classify",
163            ),
164        ];
165        let ids: Vec<&str> = m
166            .capabilities
167            .iter()
168            .map(|e| e.capability.id.as_str())
169            .collect();
170        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
171        assert_eq!(
172            ids, want,
173            "the module declares a different set of capabilities"
174        );
175        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
176            let exposure = &executable.capability.exposure;
177            assert_eq!(
178                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
179                Some(*tool),
180                "{id} lost or renamed its MCP tool"
181            );
182            assert_eq!(
183                exposure.http.as_ref().map(|h| h.path.as_str()),
184                Some(*path),
185                "{id} lost or renamed its HTTP route"
186            );
187        }
188    }
189}