Skip to main content

majordomus_cli/capability/builtin/
graph.rs

1//! The `graph` module: the derived graphs of the repository, listed and read. The
2//! derivations live in [`crate::graph`]; this file is the declaration that turns them
3//! into an MCP tool, an HTTP route, an OpenAPI operation and a benchmark target, exactly
4//! like every other capability.
5//!
6//! No graph is authored. A renderer — the Cockpit's Cytoscape view, a Mermaid block, a
7//! terminal — is a consumer of the canonical node and edge model and never a second
8//! definition of it.
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
14use crate::capability::handler::{CapabilityError, Context};
15use crate::capability::model::{Exposure, McpExposure, McpResource, Stability};
16use crate::capability::module::ModuleDescriptor;
17use crate::capability::CachePolicy;
18use crate::graph::{self, Graph, GraphInfo};
19use crate::{capability, module};
20
21use super::{get, mcp, Empty};
22
23/// The URI under which `graph.list` is read as an MCP resource.
24pub const GRAPHS_URI: &str = "majordomus://graphs";
25
26/// Every graph this executable derives.
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct GraphList {
29    /// How many.
30    pub count: usize,
31    /// Each, described but not derived.
32    pub graphs: Vec<GraphInfo>,
33}
34
35/// Which graph to derive.
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct GraphInput {
39    /// The graph's id, as `graph.list` gives it (`registry`, `layer`, `rules`, `adrs`,
40    /// `use-cases`).
41    pub id: String,
42}
43
44impl BenchmarkCases for GraphInput {
45    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
46        // one case per graph, so every derivation is timed and every derivation appears
47        // as an example in the OpenAPI document
48        graph::ids()
49            .into_iter()
50            .map(|id| NamedCase::new(id, GraphInput { id: id.into() }))
51            .collect()
52    }
53}
54
55fn graph_list(ctx: &Context, _: Empty) -> Result<GraphList, CapabilityError> {
56    let graphs = graph::list(&ctx.registry, &ctx.index);
57    Ok(GraphList {
58        count: graphs.len(),
59        graphs,
60    })
61}
62
63fn graph_get(ctx: &Context, input: GraphInput) -> Result<Graph, CapabilityError> {
64    graph::derive(&input.id, &ctx.registry, &ctx.index).ok_or_else(|| {
65        CapabilityError::NotFound(format!(
66            "no graph '{}'; this executable derives {}",
67            input.id,
68            graph::ids().join(", ")
69        ))
70    })
71}
72
73/// The module.
74pub fn module() -> ModuleDescriptor {
75    module! {
76        id: "graph",
77        title: "Graphs",
78        description: "The graphs derived from the registry and the index: the executable's own capability registry, the shape of the layer, the rule dependencies, the decisions and what they put in force, and the use cases and what they exercise. Canonical nodes and edges; a rendering library is a consumer, never the shape.",
79        stability: Stability::BehaviorallyVerified,
80        capabilities: [
81            capability! {
82                id: "graph.list",
83                title: "List graphs",
84                description: "Every graph this executable derives: its id, what it shows, and what it is derived from.",
85                input: Empty,
86                output: GraphList,
87                stability: Stability::BehaviorallyVerified,
88                exposure: Exposure {
89                    mcp: Some(McpExposure {
90                        tool: Some("majordomus_graphs".into()),
91                        resource: Some(McpResource { uri: GRAPHS_URI.into(), name: "graphs".into() }),
92                    }),
93                    http: get("/api/v1/graphs"),
94                    cli: None,
95                },
96                tags: ["graph", "introspection"],
97                handler: graph_list,
98            },
99            capability! {
100                id: "graph.get",
101                title: "Derive one graph",
102                description: "One graph by id: its nodes and edges with the vocabularies that say what each kind means, the file every node was derived from, and whether the result is acyclic. Deterministic for a given tree and executable.",
103                input: GraphInput,
104                output: Graph,
105                stability: Stability::BehaviorallyVerified,
106                exposure: Exposure {
107                    mcp: mcp("majordomus_graph"),
108                    http: get("/api/v1/graph"),
109                    cli: None,
110                },
111                tags: ["graph"],
112                cache: CachePolicy::Process { max_entries: 16, ttl_seconds: None },
113                handler: graph_get,
114            },
115        ],
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    /// The declaration is the only place these names exist, and every projection — the MCP
124    /// tool, the HTTP route, the OpenAPI operation, the benchmark target — is derived from
125    /// it. A refactor that dropped an exposure or renamed a route would still compile, and
126    /// the suites that exercise the behaviour behind it would still pass. This is the
127    /// assertion that would not.
128    #[test]
129    fn the_declaration_yields_the_projections_it_claims() {
130        let m = module();
131        assert_eq!(m.id.as_str(), "graph");
132        let expected: &[(&str, &str, &str)] = &[
133            ("graph.list", "majordomus_graphs", "/api/v1/graphs"),
134            ("graph.get", "majordomus_graph", "/api/v1/graph"),
135        ];
136        let ids: Vec<&str> = m
137            .capabilities
138            .iter()
139            .map(|e| e.capability.id.as_str())
140            .collect();
141        let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
142        assert_eq!(
143            ids, want,
144            "the module declares a different set of capabilities"
145        );
146        for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
147            let exposure = &executable.capability.exposure;
148            assert_eq!(
149                exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
150                Some(*tool),
151                "{id} lost or renamed its MCP tool"
152            );
153            assert_eq!(
154                exposure.http.as_ref().map(|h| h.path.as_str()),
155                Some(*path),
156                "{id} lost or renamed its HTTP route"
157            );
158        }
159    }
160}