Skip to main content

majordomus_cli/command_graph/
tool.rs

1//! The shell tool's commands as command-graph nodes.
2//!
3//! `bin/majordomus` is a different program with the same name: the task lifecycle, written
4//! in shell. Its command surface already has a canonical owner — `share/commands.yaml`,
5//! shipped beside the rules, reconciled against the tool's own dispatch table by the
6//! `command_surface_complete` doctrine. This reads that owner. It does not parse the help
7//! text, and it does not keep a list of its own: a command added to the registry and the
8//! dispatch table appears here on the next build, and one removed disappears.
9//!
10//! # What is translated, and what is read as it stands
11//!
12//! Two fields are translated into the graph's vocabulary, because the graph spans three
13//! programs and the registry speaks for one:
14//!
15//! | registry `class` | graph effect | why |
16//! |---|---|---|
17//! | `read-only` | read-only | writes nothing |
18//! | `state-mutating` | local mutation | writes under `.ai/local/`, which no commit carries |
19//! | `generated-output-mutating` | repository mutation | rewrites tracked, generated files |
20//!
21//! and interactivity is read from the declared `syntax`: a command whose synopsis takes a
22//! body on standard input holds a terminal, and no request/response surface may carry it.
23//! Both are derivations from what the registry already states, not new declarations.
24
25use std::path::Path;
26
27use serde::Deserialize;
28
29use crate::metadata::yaml;
30
31use super::model::{
32    Availability, CommandId, CommandNode, Diagnostic, Effect, Execution, Interactivity, Origin,
33    Projections, Provenance, Requirement,
34};
35
36/// The shipped registry this reads, relative to the share directory.
37pub const REGISTRY: &str = "commands.yaml";
38
39/// The path a projection shows as the declaration.
40pub const DECLARATION: &str = "share/commands.yaml";
41
42/// The dispatch table the registry is reconciled against, named so a diagnostic can point
43/// at it.
44pub const DISPATCH: &str = "bin/majordomus";
45
46/// The shipped command registry, as far as the graph reads it.
47#[derive(Debug, Deserialize)]
48struct Registry {
49    version: u32,
50    #[serde(default)]
51    commands: Vec<Entry>,
52}
53
54/// One command of the shell tool.
55#[derive(Debug, Deserialize)]
56struct Entry {
57    id: String,
58    #[serde(default)]
59    summary: String,
60    #[serde(default)]
61    category: Option<String>,
62    #[serde(default)]
63    visibility: Option<String>,
64    #[serde(default)]
65    class: Option<String>,
66    #[serde(default)]
67    requires_repository: Option<bool>,
68    #[serde(default)]
69    requires_task: Option<String>,
70    #[serde(default)]
71    syntax: Option<String>,
72    #[serde(default)]
73    note: Option<String>,
74}
75
76/// What the read produced.
77pub struct Contribution {
78    /// The nodes, in registry order.
79    pub nodes: Vec<CommandNode>,
80    /// What the read found.
81    pub diagnostics: Vec<Diagnostic>,
82}
83
84/// Read the shell tool's commands from the shipped registry under `share`.
85///
86/// A repository without the registry contributes nothing and is not an error: the graph is
87/// a projection of what is here, and an installation that ships only the executable has no
88/// shell tool to describe.
89pub fn contribute(share: &Path) -> Contribution {
90    let path = share.join(REGISTRY);
91    let Ok(text) = std::fs::read_to_string(&path) else {
92        return Contribution {
93            nodes: Vec::new(),
94            diagnostics: Vec::new(),
95        };
96    };
97    let registry: Registry = match yaml::parse_into(&text) {
98        Ok(r) => r,
99        Err(e) => {
100            return Contribution {
101                nodes: Vec::new(),
102                diagnostics: vec![Diagnostic::error(
103                    "tool-registry-unreadable",
104                    format!("{DECLARATION} could not be read: {e}"),
105                    Vec::new(),
106                )],
107            }
108        }
109    };
110    let mut diagnostics = Vec::new();
111    if registry.version != 1 {
112        diagnostics.push(Diagnostic::warning(
113            "tool-registry-version",
114            format!(
115                "{DECLARATION} declares version {}, and this reader knows version 1",
116                registry.version
117            ),
118            Vec::new(),
119        ));
120    }
121
122    let nodes = registry
123        .commands
124        .iter()
125        // An internal command is dispatched and absent from the help text; the graph shows
126        // what a person may run, and the registry is what decides which those are.
127        .filter(|e| e.visibility.as_deref() != Some("internal"))
128        .map(node)
129        .collect();
130
131    Contribution { nodes, diagnostics }
132}
133
134/// One node from one registry entry.
135fn node(entry: &Entry) -> CommandNode {
136    let path = vec![entry.id.clone()];
137    let effect = effect_of(entry.class.as_deref());
138    let interactivity = interactivity_of(entry.syntax.as_deref());
139
140    let mut requires = Vec::new();
141    if entry.requires_repository.unwrap_or(true) {
142        requires.push(Requirement::Repository);
143    }
144    requires.push(Requirement::Layer);
145    if entry.requires_task.as_deref() == Some("required") {
146        requires.push(Requirement::Task);
147    }
148
149    CommandNode {
150        id: CommandId::derive(Origin::Tool, &path),
151        origin: Origin::Tool,
152        invocation: format!("majordomus {}", entry.id),
153        summary: entry.summary.clone(),
154        description: entry.note.clone(),
155        runnable: true,
156        // The shell tool's arguments are prose in its own help text and are not declared
157        // in a form anything can read. The graph says so by carrying none rather than by
158        // inventing some: a completion offers the command, and the command explains
159        // itself.
160        arguments: Vec::new(),
161        execution: Execution {
162            origin: Origin::Tool,
163            argv: path.clone(),
164        },
165        effect,
166        interactivity,
167        stability: crate::capability::Stability::BehaviorallyVerified,
168        availability: Availability {
169            available: true,
170            reason: None,
171            requires,
172        },
173        group: entry.category.clone(),
174        tags: Vec::new(),
175        aliases: Vec::new(),
176        deprecation: None,
177        provenance: Provenance {
178            declared_in: DECLARATION.into(),
179            read_by: Some(format!(
180                "{DISPATCH} (the dispatch table it is reconciled against)"
181            )),
182            capability: None,
183        },
184        projections: Projections::default(),
185        entrypoint: Some(true),
186        path,
187    }
188}
189
190/// The graph's effect for the registry's class.
191///
192/// An unknown class is the strongest reading rather than the weakest: a class this reader
193/// does not recognise is a class added after it, and treating it as read-only would
194/// advertise it to every machine surface.
195fn effect_of(class: Option<&str>) -> Effect {
196    match class {
197        Some("read-only") => Effect::ReadOnly,
198        Some("state-mutating") => Effect::LocalMutation,
199        Some("generated-output-mutating") => Effect::RepositoryMutation,
200        _ => Effect::RepositoryMutation,
201    }
202}
203
204/// Whether the declared synopsis holds a terminal.
205fn interactivity_of(syntax: Option<&str>) -> Interactivity {
206    match syntax {
207        Some(s) if s.contains('<') && !s.contains("<--") => Interactivity::Interactive,
208        _ => Interactivity::NonInteractive,
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn classes_translate_to_effects() {
218        assert_eq!(effect_of(Some("read-only")), Effect::ReadOnly);
219        assert_eq!(effect_of(Some("state-mutating")), Effect::LocalMutation);
220        assert_eq!(
221            effect_of(Some("generated-output-mutating")),
222            Effect::RepositoryMutation
223        );
224        // A class from a later version of the registry is read as the strongest, not the
225        // weakest: an unknown effect must not reach a machine surface.
226        assert_eq!(effect_of(Some("something-new")), Effect::RepositoryMutation);
227        assert_eq!(effect_of(None), Effect::RepositoryMutation);
228    }
229
230    #[test]
231    fn a_body_on_standard_input_is_interactive() {
232        assert_eq!(
233            interactivity_of(Some("majordomus checkpoint [--derive] < body.md")),
234            Interactivity::Interactive
235        );
236        assert_eq!(
237            interactivity_of(Some("majordomus check [--json]")),
238            Interactivity::NonInteractive
239        );
240    }
241
242    #[test]
243    fn the_shipped_registry_reads() {
244        let share = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
245            .parent()
246            .and_then(|p| p.parent())
247            .map(|root| root.join("share"))
248            .expect("the repository root is two levels above the crate");
249        if !share.join(REGISTRY).exists() {
250            return; // an installation without the shell tool contributes nothing
251        }
252        let c = contribute(&share);
253        assert!(c.nodes.len() > 20, "read {} commands", c.nodes.len());
254        let check = c
255            .nodes
256            .iter()
257            .find(|n| n.id.as_str() == "tool.check")
258            .expect("check is a public command of the shell tool");
259        assert_eq!(check.invocation, "majordomus check");
260        assert!(c.diagnostics.is_empty(), "{:?}", c.diagnostics);
261    }
262}