1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
7use crate::capability::handler::{CapabilityError, Context};
8use crate::capability::model::{
9 CachePolicy, Capability, CapabilityKind, CliExposure, Exposure, Stability,
10};
11use crate::capability::module::ModuleDescriptor;
12use crate::capability::registry::Summary;
13use crate::{capability, module};
14
15use super::{get, mcp};
16
17#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21pub struct CapabilitiesInput {
23 #[serde(default)]
25 pub kind: Option<String>,
26 #[serde(default)]
28 pub exposure: Option<String>,
29}
30
31impl BenchmarkCases for CapabilitiesInput {
32 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
33 vec![
34 NamedCase::new("all", CapabilitiesInput::default()),
35 NamedCase::new(
36 "queries",
37 CapabilitiesInput {
38 kind: Some("query".into()),
39 exposure: None,
40 },
41 ),
42 ]
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct CapabilitySummary {
52 pub id: crate::capability::CapabilityId,
54 pub module: crate::capability::ModuleId,
56 pub kind: CapabilityKind,
58 pub title: String,
60 pub description: String,
62 pub provenance: crate::capability::Provenance,
64 pub exposure: Exposure,
66 pub stability: Stability,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub tags: Vec<String>,
71 pub benchmark: crate::capability::BenchmarkPolicy,
73 pub cache: CachePolicy,
75}
76
77impl From<&Capability> for CapabilitySummary {
78 fn from(c: &Capability) -> Self {
79 CapabilitySummary {
80 id: c.id.clone(),
81 module: c.module.clone(),
82 kind: c.kind,
83 title: c.title.clone(),
84 description: c.description.clone(),
85 provenance: c.provenance.clone(),
86 exposure: c.exposure.clone(),
87 stability: c.stability,
88 tags: c.tags.clone(),
89 benchmark: c.benchmark,
90 cache: c.cache,
91 }
92 }
93}
94
95#[derive(Debug, Serialize, Deserialize, JsonSchema)]
96pub struct CapabilityList {
99 pub count: usize,
101 pub summary: Summary,
103 pub capabilities: Vec<CapabilitySummary>,
105}
106
107fn capabilities_list(
108 ctx: &Context,
109 input: CapabilitiesInput,
110) -> Result<CapabilityList, CapabilityError> {
111 let kind = match input.kind.as_deref() {
112 None => None,
113 Some("query") => Some(CapabilityKind::Query),
114 Some("command") => Some(CapabilityKind::Command),
115 Some("resource") => Some(CapabilityKind::Resource),
116 Some(other) => {
117 return Err(CapabilityError::InvalidInput(format!(
118 "kind '{other}' is not query, command or resource"
119 )))
120 }
121 };
122 let exposure = match input.exposure.as_deref() {
123 None | Some("mcp") | Some("http") | Some("cli") => input.exposure.clone(),
124 Some(other) => {
125 return Err(CapabilityError::InvalidInput(format!(
126 "exposure '{other}' is not mcp, http or cli"
127 )))
128 }
129 };
130 let capabilities: Vec<CapabilitySummary> = ctx
131 .registry
132 .iter()
133 .filter(|c| kind.is_none_or(|k| c.kind == k))
134 .filter(|c| match exposure.as_deref() {
135 None => true,
136 Some("mcp") => c.exposure.mcp.is_some(),
137 Some("http") => c.exposure.http.is_some(),
138 Some("cli") => c.exposure.cli.is_some(),
139 Some(_) => false,
140 })
141 .map(CapabilitySummary::from)
142 .collect();
143 Ok(CapabilityList {
144 count: capabilities.len(),
145 summary: ctx.registry.summary(),
146 capabilities,
147 })
148}
149
150#[derive(Debug, Serialize, Deserialize, JsonSchema)]
153#[serde(deny_unknown_fields)]
154pub struct DescribeInput {
156 pub id: String,
158}
159
160impl BenchmarkCases for DescribeInput {
161 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
162 vec![NamedCase::new(
163 "repository-info",
164 DescribeInput {
165 id: "repository.info".into(),
166 },
167 )]
168 }
169}
170
171fn capabilities_describe(
172 ctx: &Context,
173 input: DescribeInput,
174) -> Result<Capability, CapabilityError> {
175 ctx.registry
176 .get(&input.id)
177 .cloned()
178 .ok_or_else(|| CapabilityError::NotFound(format!("unknown capability: {}", input.id)))
179}
180
181#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185pub struct ProjectionsInput {
187 #[serde(default)]
189 pub module: Option<String>,
190 #[serde(default)]
193 pub unmet: bool,
194}
195
196impl BenchmarkCases for ProjectionsInput {
197 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
198 vec![
199 NamedCase::new("all", ProjectionsInput::default()),
200 NamedCase::new(
201 "unmet",
202 ProjectionsInput {
203 module: None,
204 unmet: true,
205 },
206 ),
207 ]
208 }
209}
210
211fn capabilities_projections(
212 ctx: &Context,
213 input: ProjectionsInput,
214) -> Result<crate::capability::closure::Matrix, CapabilityError> {
215 let mut m = crate::capability::closure::matrix(&ctx.registry, &crate::cli::tree());
218 if let Some(module) = &input.module {
219 if !ctx.registry.modules().any(|k| k.id.as_str() == module) {
220 return Err(CapabilityError::InvalidInput(format!(
221 "no module named '{module}'"
222 )));
223 }
224 m.rows.retain(|r| &r.module == module);
225 }
226 if input.unmet {
227 m.rows.retain(|r| !r.closed);
228 }
229 Ok(m)
230}
231
232pub fn module() -> ModuleDescriptor {
234 module! {
235 id: "capabilities",
236 title: "Capabilities",
237 description: "The registry seen through itself: every capability with its kind, stability, provenance, exposures, benchmark and cache policy, and one capability in full.",
238 stability: Stability::BehaviorallyVerified,
239 capabilities: [
240 capability! {
241 id: "capabilities.list",
242 title: "List capabilities",
243 description: "Every capability of this executable and this repository, summarised: kind, module, stability, provenance, the projections it declares, its benchmark and cache policy; the schemas are answered by capabilities.describe.",
244 input: CapabilitiesInput,
245 output: CapabilityList,
246 stability: Stability::BehaviorallyVerified,
247 exposure: Exposure {
248 mcp: mcp("majordomus_capabilities"),
249 http: get("/api/v1/capabilities"),
250 cli: Some(CliExposure { path: vec!["capabilities".into(), "list".into()] }),
251 },
252 tags: ["introspection"],
253 cache: CachePolicy::Process { max_entries: 16, ttl_seconds: None },
254 handler: capabilities_list,
255 },
256 capability! {
257 id: "capabilities.describe",
258 title: "Describe one capability",
259 description: "One capability by canonical id: its kind, schemas, provenance, stability, exposures, benchmark and cache policy.",
260 input: DescribeInput,
261 output: Capability,
262 stability: Stability::BehaviorallyVerified,
263 exposure: Exposure {
264 mcp: mcp("majordomus_capability"),
265 http: get("/api/v1/capability"),
266 cli: Some(CliExposure { path: vec!["capabilities".into(), "describe".into()] }),
267 },
268 tags: ["introspection"],
269 cache: CachePolicy::Process { max_entries: 512, ttl_seconds: None },
270 handler: capabilities_describe,
271 },
272 capability! {
273 id: "capabilities.projections",
274 title: "Where each capability is projected",
275 description: "A row per capability with the command line, HTTP route, MCP tool and MCP resource it reaches, whether every exposure it declares is answered by that surface, and the runnable commands no capability claims. Derived from the registry and the clap declaration; nothing is written down.",
276 input: ProjectionsInput,
277 output: crate::capability::closure::Matrix,
278 stability: Stability::BehaviorallyVerified,
279 exposure: Exposure {
280 mcp: mcp("majordomus_projections"),
281 http: get("/api/v1/capabilities/projections"),
282 cli: Some(CliExposure { path: vec!["capabilities".into(), "projections".into()] }),
283 },
284 tags: ["introspection", "projections"],
285 cache: CachePolicy::Process { max_entries: 8, ttl_seconds: None },
286 handler: capabilities_projections,
287 },
288 ],
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
302 fn the_declaration_yields_the_projections_it_claims() {
303 let m = module();
304 assert_eq!(m.id.as_str(), "capabilities");
305 let expected: &[(&str, &str, &str)] = &[
306 (
307 "capabilities.list",
308 "majordomus_capabilities",
309 "/api/v1/capabilities",
310 ),
311 (
312 "capabilities.describe",
313 "majordomus_capability",
314 "/api/v1/capability",
315 ),
316 (
317 "capabilities.projections",
318 "majordomus_projections",
319 "/api/v1/capabilities/projections",
320 ),
321 ];
322 let ids: Vec<&str> = m
323 .capabilities
324 .iter()
325 .map(|e| e.capability.id.as_str())
326 .collect();
327 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
328 assert_eq!(
329 ids, want,
330 "the module declares a different set of capabilities"
331 );
332 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
333 let exposure = &executable.capability.exposure;
334 assert_eq!(
335 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
336 Some(*tool),
337 "{id} lost or renamed its MCP tool"
338 );
339 assert_eq!(
340 exposure.http.as_ref().map(|h| h.path.as_str()),
341 Some(*path),
342 "{id} lost or renamed its HTTP route"
343 );
344 }
345 }
346
347 #[test]
351 fn projections_filters_by_module_and_refuses_a_module_that_does_not_exist() {
352 use crate::capability::{CapabilityRegistry, Context};
353 use crate::git::GitState;
354 use crate::index::{Index, RepositoryInfo, State};
355 use std::sync::Arc;
356
357 let index = Index {
360 repository: RepositoryInfo {
361 root: "/tmp/projections".into(),
362 layer_schema: "ai-repository/v1".into(),
363 sections: Default::default(),
364 git: GitState::Unavailable {
365 reason: "unit test".into(),
366 },
367 discovery: "filesystem".into(),
368 source_classes: vec![],
369 kind_sources: vec![],
370 scope_origin: crate::scope::Origin::Distribution,
371 scope_path: String::new(),
372 observed: Default::default(),
373 },
374 objects: vec![],
375 diagnostics: vec![],
376 state: State::Ok,
377 fingerprint: String::new(),
378 scoped: Default::default(),
379 distribution: None,
380 providers: Default::default(),
381 share: None,
382 };
383 let registry = Arc::new(
384 CapabilityRegistry::builder()
385 .with_modules(super::super::modules())
386 .build()
387 .expect("the builtin registry builds"),
388 );
389 let ctx = Context::new(Arc::new(index), Arc::clone(®istry));
390
391 let all = capabilities_projections(&ctx, ProjectionsInput::default()).expect("all rows");
393 assert_eq!(all.rows.len(), registry.len());
394 assert!(
395 all.unbacked.iter().any(|c| c == "majordomus serve"),
396 "the process commands are the debt this reports"
397 );
398
399 let one = capabilities_projections(
401 &ctx,
402 ProjectionsInput {
403 module: Some("capabilities".into()),
404 unmet: false,
405 },
406 )
407 .expect("one module");
408 assert!(!one.rows.is_empty());
409 assert!(one.rows.iter().all(|r| r.module == "capabilities"));
410 assert!(one.rows.len() < all.rows.len());
411
412 let unmet = capabilities_projections(
414 &ctx,
415 ProjectionsInput {
416 module: None,
417 unmet: true,
418 },
419 )
420 .expect("unmet");
421 assert!(unmet.rows.is_empty(), "the shipped declaration is closed");
422
423 let err = capabilities_projections(
426 &ctx,
427 ProjectionsInput {
428 module: Some("nonesuch".into()),
429 unmet: false,
430 },
431 )
432 .expect_err("an unknown module is refused");
433 assert!(
434 matches!(err, CapabilityError::InvalidInput(ref m) if m.contains("nonesuch")),
435 "{err:?}"
436 );
437 }
438}