majordomus_cli/capability/builtin/
graph.rs1use 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
23pub const GRAPHS_URI: &str = "majordomus://graphs";
25
26#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
28pub struct GraphList {
29 pub count: usize,
31 pub graphs: Vec<GraphInfo>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38pub struct GraphInput {
39 pub id: String,
42}
43
44impl BenchmarkCases for GraphInput {
45 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
46 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
73pub 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 #[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}