Skip to main content

majordomus_cli/capability/builtin/
capabilities.rs

1//! The `capabilities` module: the registry's introspection of itself.
2
3use 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// ---------------------------------------------------------------- capabilities.list
18
19#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
20#[serde(deny_unknown_fields)]
21/// The input of `capabilities.list`: optional filters by kind and by projection.
22pub struct CapabilitiesInput {
23    /// Only capabilities of this kind: `query`, `command` or `resource`.
24    #[serde(default)]
25    pub kind: Option<String>,
26    /// Only capabilities exposed through this projection: `mcp`, `http` or `cli`.
27    #[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/// One capability as a listing shows it: everything the descriptor says except its two
47/// schemas, which `capabilities.describe` answers for one capability. A listing of a
48/// repository's registry runs to hundreds of entries; their schemas would be megabytes
49/// of the same object view repeated.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51pub struct CapabilitySummary {
52    /// The canonical identity.
53    pub id: crate::capability::CapabilityId,
54    /// The module that composes it.
55    pub module: crate::capability::ModuleId,
56    /// Query, command or resource.
57    pub kind: CapabilityKind,
58    /// The short name.
59    pub title: String,
60    /// The one-paragraph description.
61    pub description: String,
62    /// Where it came from.
63    pub provenance: crate::capability::Provenance,
64    /// Where it is projected.
65    pub exposure: Exposure,
66    /// Where it stands.
67    pub stability: Stability,
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    /// Free tags.
70    pub tags: Vec<String>,
71    /// Whether it is a benchmark target.
72    pub benchmark: crate::capability::BenchmarkPolicy,
73    /// Whether the executor keeps its results.
74    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)]
96/// The answer of `capabilities.list`: the matching capabilities, summarised, and the
97/// registry counted.
98pub struct CapabilityList {
99    /// How many capabilities matched the filters.
100    pub count: usize,
101    /// The whole registry, counted by kind, stability and projection.
102    pub summary: Summary,
103    /// The matching capabilities, by id, without their schemas.
104    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// ---------------------------------------------------------------- capabilities.describe
151
152#[derive(Debug, Serialize, Deserialize, JsonSchema)]
153#[serde(deny_unknown_fields)]
154/// The input of `capabilities.describe`: which capability.
155pub struct DescribeInput {
156    /// The canonical id, e.g. `repository.info` or `rule.majordomus.scope-integrity@1`.
157    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// ---------------------------------------------------------------- capabilities.projections
182
183#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
184#[serde(deny_unknown_fields)]
185/// The input of `capabilities.projections`: which rows, and whether only the unmet claims.
186pub struct ProjectionsInput {
187    /// Only capabilities composed in this module.
188    #[serde(default)]
189    pub module: Option<String>,
190    /// Only the capabilities whose declared exposures are not all answered by their
191    /// surface. Empty is the closure the rule asks for.
192    #[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    // the clap tree is a pure function of the declaration compiled into this executable:
216    // no repository is read, and the walk is the same one `cli::validate` runs
217    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
232/// The module.
233pub 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    /// The declaration is the only place these names exist, and every projection — the MCP
297    /// tool, the HTTP route, the OpenAPI operation, the benchmark target — is derived from
298    /// it. A refactor that dropped an exposure or renamed a route would still compile, and
299    /// the suites that exercise the behaviour behind it would still pass. This is the
300    /// assertion that would not.
301    #[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    /// The handler's two filters and its one refusal, over the registry this crate ships.
348    /// `Context::new` needs no repository: the filters read the registry and the clap tree,
349    /// and neither touches the index.
350    #[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        // an index with no objects: the filters read the registry and the clap tree, and
358        // neither of them touches the layer
359        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(&registry));
390
391        // every row, unfiltered
392        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        // one module
400        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        // the closure this crate ships: nothing unmet
413        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        // a module nobody composes is a refusal naming it, not an empty answer that reads
424        // as "this module has no capabilities"
425        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}