Skip to main content

majordomus_cli/command_graph/
mod.rs

1//! The canonical command graph: every command this repository offers, from whichever
2//! program offers it, with where each one is projected.
3//!
4//! ```text
5//!   clap tree            share/commands.yaml         just --dump
6//!   (the executable)     (the shell tool)            (the workflows)
7//!         │                     │                          │
8//!         └──────────┬──────────┴──────────┬───────────────┘
9//!                    ▼                     ▼
10//!              contributors          capability registry
11//!                    └──────────┬──────────┘
12//!                               ▼
13//!                        CommandGraph  ── fingerprint
14//!                               │
15//!    ┌──────────┬───────────────┼───────────────┬──────────────┐
16//!    ▼          ▼               ▼               ▼              ▼
17//!  bridge   completion      MCP · HTTP       Cockpit         docs
18//! ```
19//!
20//! # What this is not
21//!
22//! It is not a registry. Nothing is declared here and nothing registers with it: every
23//! node is read from a declaration that already existed and is already authority for what
24//! it states — clap for the shape of the native command line, the shipped command registry
25//! for the shell tool, the workflow runner's own dump for the recipes. Adding a command to
26//! any of those puts it in the graph, and every projection follows, because a projection
27//! asks the graph rather than keeping a list.
28//!
29//! # Dependency direction
30//!
31//! The graph depends on the contributors and on the repository environment. Nothing it
32//! projects depends on it in return: the generated bridge, the completion engine, the
33//! capability module that serves it and the reference pages are all consumers. A
34//! projection that fed the graph would make the graph a rendering of its own output.
35
36pub mod bridge;
37pub mod complete;
38pub mod load;
39pub mod model;
40pub mod native;
41pub mod policy;
42pub mod semantics;
43pub mod shell;
44pub mod tool;
45pub mod workflow;
46
47pub use model::{
48    ArgumentSpec, Availability, CommandGraph, CommandId, CommandNode, Diagnostic, Effect,
49    Execution, Interactivity, Origin, Projections, Provenance, Requirement, Secrecy, Severity,
50    Surface, ValueChoice, ValueSource, SCHEMA,
51};
52
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::Path;
55
56use sha2::{Digest, Sha256};
57
58use crate::capability::registry::CapabilityRegistry;
59use crate::environment::WorkflowCatalogue;
60
61/// What a build reads.
62///
63/// Every field is optional in the sense that a build without it produces a smaller graph
64/// rather than an error: a checkout with no shell tool, no workflow runner or no built
65/// registry still has a command line, and the completion for it must still work.
66#[derive(Default)]
67pub struct Inputs<'a> {
68    /// The capability registry, for the join that gives a command its typed schema, its
69    /// MCP tool and its HTTP route. Absent on the fast path, where nothing needs them.
70    pub registry: Option<&'a CapabilityRegistry>,
71    /// The share directory, where the shell tool's command registry is shipped.
72    pub share: Option<&'a Path>,
73    /// The workflows the runner reports.
74    pub workflows: Option<&'a WorkflowCatalogue>,
75    /// The recipe names the last materialised bridge emitted. A recipe with one of these
76    /// names is this graph's own projection and is not read back as a declaration.
77    pub bridged: BTreeSet<String>,
78}
79
80/// Build the graph.
81///
82/// Deterministic: given the same inputs it produces the same document, byte for byte,
83/// including the fingerprint. Nothing here reads the clock, the network or the
84/// environment.
85pub fn build(inputs: &Inputs<'_>) -> CommandGraph {
86    let mut nodes = Vec::new();
87    let mut diagnostics = Vec::new();
88
89    let native = native::contribute(inputs.registry);
90    nodes.extend(native.nodes);
91    diagnostics.extend(native.diagnostics);
92
93    if let Some(share) = inputs.share {
94        let tool = tool::contribute(share);
95        nodes.extend(tool.nodes);
96        diagnostics.extend(tool.diagnostics);
97    }
98
99    // The two programs' commands are projected first, because the set of recipe names the
100    // bridge would emit is what decides whether a recipe the runner reports is a
101    // declaration or this graph's own output.
102    for node in &mut nodes {
103        let capability = node
104            .provenance
105            .capability
106            .as_ref()
107            .and_then(|id| inputs.registry.and_then(|r| r.get(id.as_str())));
108        let exposures = capability.map(|c| {
109            (
110                c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
111                c.exposure.http.as_ref().map(|h| h.path.clone()),
112            )
113        });
114        node.projections = match &exposures {
115            Some((mcp, http)) => policy::project(node, Some((mcp, http))),
116            None => policy::project(node, None),
117        };
118    }
119
120    qualify_collisions(&mut nodes, &mut diagnostics);
121
122    if let Some(catalogue) = inputs.workflows {
123        let contribution = workflow::contribute(catalogue, &inputs.bridged);
124        diagnostics.extend(contribution.diagnostics);
125        let mut declared = contribution.nodes;
126        for node in &mut declared {
127            node.projections = policy::project(node, None);
128        }
129        // A declared workflow owns its name. A bridge that would take the same name is
130        // withheld and says so, rather than one of them silently winning by the order a
131        // directory happened to be walked.
132        let taken: BTreeSet<String> = declared
133            .iter()
134            .filter_map(|n| n.projections.workflow.clone())
135            .collect();
136        for node in &mut nodes {
137            let Some(name) = node.projections.workflow.clone() else {
138                continue;
139            };
140            if taken.contains(&name) {
141                node.projections.workflow = None;
142                diagnostics.push(
143                    Diagnostic::error(
144                        "workflow-name-collision",
145                        format!(
146                            "`just {name}` is a declared workflow, so the bridge for `{}` cannot take that name",
147                            node.invocation
148                        ),
149                        vec![node.id.clone()],
150                    )
151                    .with_remedy(format!(
152                        "rename the declared recipe, or accept that `{}` has no workflow bridge",
153                        node.invocation
154                    )),
155                );
156            }
157        }
158        nodes.extend(declared);
159    }
160
161    nodes.sort_by(|a, b| (a.origin, &a.path).cmp(&(b.origin, &b.path)));
162    diagnostics.extend(validate(&nodes));
163    diagnostics
164        .sort_by(|a, b| (a.severity, &a.code, &a.message).cmp(&(b.severity, &b.code, &b.message)));
165
166    let fingerprint = fingerprint(&nodes);
167    CommandGraph {
168        schema: SCHEMA.into(),
169        fingerprint,
170        commands: nodes,
171        diagnostics,
172    }
173}
174
175/// Two programs, one recipe name: qualify both rather than let one win.
176///
177/// `majordomus bench` is a command of the executable and a command of the shell tool, and
178/// the derived recipe name for each is `bench`. Choosing between them by origin order would
179/// make the meaning of a recipe depend on the order a contributor happens to run in, so
180/// neither keeps the bare name: both are prefixed with the program that owns them, and the
181/// graph says so. A short spelling for either is then a decision, declared once as an alias
182/// beside the command line.
183fn qualify_collisions(nodes: &mut [CommandNode], diagnostics: &mut Vec<Diagnostic>) {
184    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
185    for node in nodes.iter() {
186        if let Some(name) = &node.projections.workflow {
187            *counts.entry(name.clone()).or_default() += 1;
188        }
189    }
190    for node in nodes.iter_mut() {
191        let Some(name) = node.projections.workflow.clone() else {
192            continue;
193        };
194        if counts.get(&name).copied().unwrap_or(0) < 2 {
195            continue;
196        }
197        let qualified = format!("{}-{name}", node.origin.prefix());
198        diagnostics.push(Diagnostic::warning(
199            "workflow-name-qualified",
200            format!(
201                "`just {name}` would name two commands, so `{}` is projected as `just {qualified}`",
202                node.invocation
203            ),
204            vec![node.id.clone()],
205        ));
206        node.projections.workflow = Some(qualified);
207    }
208}
209
210/// The invariants a graph must hold for its projections to be safe to write.
211///
212/// Every one of these is a defect that would otherwise surface as a surface behaving
213/// oddly: two recipes with one name, a tool advertised twice, a bridge that calls itself.
214fn validate(nodes: &[CommandNode]) -> Vec<Diagnostic> {
215    let mut out = Vec::new();
216
217    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
218    for node in nodes {
219        *seen.entry(node.id.as_str()).or_default() += 1;
220    }
221    for (id, count) in seen.iter().filter(|(_, c)| **c > 1) {
222        out.push(Diagnostic::error(
223            "duplicate-command-id",
224            format!("{count} commands claim the identity `{id}`"),
225            Vec::new(),
226        ));
227    }
228
229    let mut recipes: BTreeMap<String, Vec<CommandId>> = BTreeMap::new();
230    let mut tools: BTreeMap<String, Vec<CommandId>> = BTreeMap::new();
231    for node in nodes {
232        if let Some(name) = &node.projections.workflow {
233            recipes
234                .entry(name.clone())
235                .or_default()
236                .push(node.id.clone());
237        }
238        if let Some(name) = &node.projections.mcp {
239            tools.entry(name.clone()).or_default().push(node.id.clone());
240        }
241    }
242    for (name, ids) in recipes.iter().filter(|(_, v)| v.len() > 1) {
243        out.push(Diagnostic::error(
244            "duplicate-workflow-projection",
245            format!("`just {name}` would be written by {} commands", ids.len()),
246            ids.clone(),
247        ));
248    }
249    for (name, ids) in tools.iter().filter(|(_, v)| v.len() > 1) {
250        out.push(Diagnostic::error(
251            "duplicate-mcp-projection",
252            format!("the MCP tool `{name}` is claimed by {} commands", ids.len()),
253            ids.clone(),
254        ));
255    }
256
257    // No bridge may run a bridge. The execution descriptor names the program directly, so
258    // this can only fail if a contributor is wrong — which is exactly why it is checked.
259    for node in nodes {
260        if node.projections.workflow.is_some()
261            && node.execution.origin == Origin::Workflow
262            && node.origin != Origin::Workflow
263        {
264            out.push(Diagnostic::error(
265                "execution-cycle",
266                format!(
267                    "`{}` is projected as a recipe and executed through the recipe runner",
268                    node.invocation
269                ),
270                vec![node.id.clone()],
271            ));
272        }
273    }
274
275    // Every compatibility alias names a command that still exists — among the programs
276    // this graph actually read. A graph built without the shell tool's registry carries no
277    // command of the shell tool, and an alias to one is unresolved rather than stale.
278    let ids: Vec<String> = nodes.iter().map(|n| n.id.to_string()).collect();
279    let origins: BTreeSet<Origin> = nodes.iter().map(|n| n.origin).collect();
280    for alias in semantics::unused_aliases(&ids) {
281        let target_origin = CommandId::derive(Origin::Executable, &[])
282            .origin()
283            .filter(|_| alias.command.starts_with("executable."))
284            .or_else(|| alias.command.starts_with("tool.").then_some(Origin::Tool))
285            .or_else(|| {
286                alias
287                    .command
288                    .starts_with("workflow.")
289                    .then_some(Origin::Workflow)
290            });
291        if target_origin.is_some_and(|o| !origins.contains(&o)) {
292            continue;
293        }
294        out.push(
295            Diagnostic::error(
296                "alias-names-no-command",
297                format!(
298                    "the workflow alias `just {}` names `{}`, which the graph does not carry",
299                    alias.alias, alias.command
300                ),
301                Vec::new(),
302            )
303            .with_remedy("point the alias at the command that replaced it, or remove it"),
304        );
305    }
306
307    // A secret must never be enumerable anywhere.
308    for node in nodes {
309        for arg in &node.arguments {
310            if arg.secrecy == Secrecy::Secret && arg.source.suggestible() {
311                out.push(Diagnostic::error(
312                    "secret-is-suggestible",
313                    format!(
314                        "`{}` takes `{}`, which is a secret with a value source that would enumerate it",
315                        node.invocation, arg.name
316                    ),
317                    vec![node.id.clone()],
318                ));
319            }
320        }
321    }
322
323    out
324}
325
326/// The identity of a graph's semantic content.
327///
328/// Over the nodes only: the diagnostics are a reading of the same content, and a graph
329/// whose diagnostics changed because a message was reworded is the same graph as far as
330/// every cache and every generated file is concerned. Nothing time-dependent is in it, so
331/// two builds over one tree agree.
332fn fingerprint(nodes: &[CommandNode]) -> String {
333    let mut hasher = Sha256::new();
334    hasher.update(SCHEMA.as_bytes());
335    for node in nodes {
336        // serde_json with a BTreeMap-free struct keeps declaration order, and the node
337        // order is already deterministic, so the serialisation is stable.
338        let bytes = serde_json::to_vec(node).unwrap_or_default();
339        hasher.update(&bytes);
340    }
341    format!("{:x}", hasher.finalize())[..16].to_string()
342}
343
344/// The commands a newcomer is offered, in a deterministic order.
345///
346/// The rule is one sentence and the declarations decide its outcome: an entry point is a
347/// runnable command its declaration marks as one — a top-level command of the executable,
348/// a public command of the shell tool, a recipe named after its group. Nothing here names
349/// a command, so a banner rendered from this cannot outlive the command it offers.
350pub fn entrypoints(graph: &CommandGraph, limit: usize) -> Vec<&CommandNode> {
351    let mut out: Vec<&CommandNode> = graph
352        .commands
353        .iter()
354        .filter(|c| c.runnable && c.entrypoint == Some(true) && c.availability.available)
355        .collect();
356    out.sort_by_key(|c| {
357        (
358            // A workflow first: the repository declared it for a person to run here.
359            match c.origin {
360                Origin::Workflow => 0,
361                Origin::Tool => 1,
362                Origin::Executable => 2,
363            },
364            c.effect,
365            c.path.join("-"),
366        )
367    });
368    out.truncate(limit);
369    out
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn a_graph_builds_from_the_command_line_alone() {
378        let graph = build(&Inputs::default());
379        assert_eq!(graph.schema, SCHEMA);
380        assert!(graph.commands.len() > 40);
381        assert_eq!(graph.fingerprint.len(), 16);
382        assert!(graph.errors().is_empty(), "errors: {:?}", graph.errors());
383    }
384
385    #[test]
386    fn the_fingerprint_is_deterministic() {
387        let a = build(&Inputs::default());
388        let b = build(&Inputs::default());
389        assert_eq!(a.fingerprint, b.fingerprint);
390    }
391
392    #[test]
393    fn every_command_has_a_page_and_a_command_line() {
394        let graph = build(&Inputs::default());
395        for node in &graph.commands {
396            assert!(
397                node.projections.cli.is_some(),
398                "{} has no command line",
399                node.id
400            );
401            assert!(
402                node.projections.docs.starts_with('/') && node.projections.docs.ends_with('/'),
403                "{} has no page",
404                node.id
405            );
406        }
407    }
408
409    #[test]
410    fn a_surface_spelling_resolves_back_to_one_node() {
411        let graph = build(&Inputs::default());
412        let node = graph
413            .resolve(Surface::Workflow, "worktree-status")
414            .expect("the bridge spells worktree status this way");
415        assert_eq!(node.id.as_str(), "executable.worktree.status");
416        let same = graph
417            .resolve(Surface::Cli, "majordomus worktree status")
418            .expect("the command line spells it this way");
419        assert_eq!(same.id, node.id);
420    }
421
422    #[test]
423    fn no_mutation_reaches_a_machine_surface() {
424        let graph = build(&Inputs::default());
425        for node in &graph.commands {
426            if node.effect > policy::MACHINE_CEILING {
427                assert!(
428                    node.projections.mcp.is_none() && node.projections.http.is_none(),
429                    "{} is above the ceiling and reaches a machine surface",
430                    node.id
431                );
432                assert!(node.projections.withheld.is_some());
433            }
434        }
435    }
436
437    #[test]
438    fn entrypoints_come_from_the_declarations() {
439        let graph = build(&Inputs::default());
440        let offered = entrypoints(&graph, 5);
441        assert!(!offered.is_empty());
442        assert!(offered.iter().all(|c| c.runnable));
443    }
444}