majordomus_cli/capability/builtin/
repository.rs1use 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
18pub const REPOSITORY_URI: &str = "majordomus://repository";
21
22#[derive(Debug, Serialize, Deserialize, JsonSchema)]
24pub struct RepositoryReport {
25 pub repository: RepositoryInfo,
27 pub state: State,
29 pub objects: usize,
31 pub kinds: std::collections::BTreeMap<String, usize>,
33 pub diagnostics: Vec<Diagnostic>,
35 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
55pub const SCOPE_CLASSIFY: &str = "repository.scope_classify";
65
66pub 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 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 #[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}