Skip to main content

majordomus_cli/command_graph/
native.rs

1//! The native command line as command-graph nodes.
2//!
3//! clap is the declaration; this walks it. Nothing here restates a command, an argument, a
4//! default or a description — [`crate::cli::docs::tree`] already turns the built
5//! `clap::Command` into data for the reference pages, and this is a second reading of the
6//! same tree, not a second tree.
7//!
8//! Three things are added that clap cannot hold, each from a place that already owns it:
9//!
10//! - the **semantics** — effect, interactivity, requirements — from [`super::semantics`];
11//! - the **capability** behind a command, joined through the registry's own
12//!   [`crate::capability::model::CliExposure`], which also proves the two agree;
13//! - the **value source** of each argument, inferred from the declaration first and
14//!   annotated only where the type clap erased is one a registry here knows.
15//!
16//! # Why the join is checked
17//!
18//! A capability may declare that it is reached as `majordomus x y`. Nothing dispatched
19//! from that declaration before this module existed: the clap arm was written separately,
20//! and a capability whose `cli` exposure named a command that was never added produced a
21//! documented command that did not exist. The join reports that as an error, which turns a
22//! silent documentation defect into a failing build.
23
24use crate::capability::model::CliExposure;
25use crate::capability::registry::CapabilityRegistry;
26use crate::cli::docs::{self, ArgDoc, CommandDoc};
27
28use super::model::{
29    ArgumentSpec, Availability, CommandId, CommandNode, Diagnostic, Execution, Origin, Projections,
30    Provenance, Requirement, Secrecy, ValueChoice, ValueSource,
31};
32use super::semantics;
33
34/// What the walk produced.
35pub struct Contribution {
36    /// The nodes, in declaration order.
37    pub nodes: Vec<CommandNode>,
38    /// What the walk found: a stale annotation, a capability that names a command that
39    /// does not exist.
40    pub diagnostics: Vec<Diagnostic>,
41}
42
43/// Walk the built command line into nodes, joined against the capability registry.
44///
45/// `registry` may be `None`: the graph is buildable without one, which is what keeps the
46/// completion path off the index. The join, and the errors it can report, are then absent
47/// rather than wrong — the caller that wants them passes a registry.
48pub fn contribute(registry: Option<&CapabilityRegistry>) -> Contribution {
49    let tree = docs::tree();
50    let mut nodes = Vec::new();
51    let mut diagnostics = Vec::new();
52    let mut paths: Vec<Vec<String>> = Vec::new();
53
54    for doc in tree.flatten() {
55        // `flatten` includes the root, which is the executable itself and not a command.
56        if doc.path.len() < 2 {
57            continue;
58        }
59        let path: Vec<String> = doc.path[1..].to_vec();
60        paths.push(path.clone());
61        nodes.push(node(doc, &path, registry));
62    }
63
64    for stale in semantics::unused(&paths) {
65        diagnostics.push(
66            Diagnostic::error(
67                "semantics-names-no-command",
68                format!("the semantics beside the command line name `{stale}`, which the command line does not declare"),
69                Vec::new(),
70            )
71            .with_remedy("remove the entry, or restore the command it names"),
72        );
73    }
74
75    if let Some(registry) = registry {
76        for capability in registry.iter() {
77            let Some(CliExposure { path: words }) = &capability.exposure.cli else {
78                continue;
79            };
80            if !paths.iter().any(|p| p == words) {
81                diagnostics.push(
82                    Diagnostic::error(
83                        "capability-names-no-command",
84                        format!(
85                            "capability `{}` is exposed as `majordomus {}`, which the command line does not declare",
86                            capability.id,
87                            words.join(" ")
88                        ),
89                        Vec::new(),
90                    )
91                    .with_remedy("add the clap arm, or drop the cli exposure"),
92                );
93            }
94        }
95    }
96
97    Contribution { nodes, diagnostics }
98}
99
100/// One command of the native command line.
101fn node(doc: &CommandDoc, path: &[String], registry: Option<&CapabilityRegistry>) -> CommandNode {
102    let sem = semantics::of(path);
103    let capability = registry.and_then(|r| r.by_cli(path));
104
105    let provenance = Provenance {
106        declared_in: docs::DECLARATION.into(),
107        read_by: Some("majordomus commands list".into()),
108        capability: capability.map(|c| c.id.clone()),
109    };
110
111    // A capability behind a command is the stronger statement about what running it does:
112    // the registry classified its kind, and the classification is verified there. The
113    // annotation beside the declaration answers only for the commands that have none.
114    let effect = capability
115        .map(|c| super::model::Effect::of_capability(c.execution))
116        .unwrap_or(sem.effect);
117    let stability = capability
118        .map(|c| c.stability)
119        .unwrap_or(crate::capability::Stability::Implemented);
120
121    let arguments = doc
122        .args
123        .iter()
124        .map(|a| argument(a, path, &doc.usage))
125        .collect();
126
127    CommandNode {
128        id: CommandId::derive(Origin::Executable, path),
129        origin: Origin::Executable,
130        invocation: format!("majordomus {}", path.join(" ")),
131        summary: doc.about.clone(),
132        description: doc.long_about.clone(),
133        runnable: doc.executable,
134        arguments,
135        execution: Execution {
136            origin: Origin::Executable,
137            argv: path.to_vec(),
138        },
139        effect,
140        interactivity: sem.interactivity,
141        stability,
142        availability: Availability {
143            available: true,
144            reason: None,
145            requires: sem.requires.to_vec(),
146        },
147        group: path.first().cloned(),
148        tags: capability.map(|c| c.tags.clone()).unwrap_or_default(),
149        aliases: doc.aliases.clone(),
150        deprecation: None,
151        provenance,
152        projections: Projections::default(),
153        entrypoint: Some(path.len() == 1),
154        path: path.to_vec(),
155    }
156}
157
158/// One argument, with its value source inferred and then annotated.
159fn argument(arg: &ArgDoc, path: &[String], usage: &str) -> ArgumentSpec {
160    let values: Vec<ValueChoice> = arg
161        .possible_values
162        .iter()
163        .map(|v| ValueChoice {
164            value: v.name.clone(),
165            description: v.help.clone(),
166        })
167        .collect();
168
169    let source = infer(arg, path, &values);
170    let secrecy = semantics::secrecy(&arg.name, arg.value_name.as_deref());
171    // A secret is never enumerated, whatever the inference concluded.
172    let source = if secrecy == Secrecy::Secret {
173        ValueSource::Secret
174    } else {
175        source
176    };
177
178    ArgumentSpec {
179        name: arg.name.clone(),
180        long: arg.long.clone(),
181        short: arg.short,
182        positional: arg.positional,
183        takes_value: arg.takes_value,
184        required: arg.required,
185        variadic: arg.positional && usage.contains("..."),
186        global: arg.global,
187        help: arg.help.clone(),
188        value_name: arg.value_name.clone(),
189        values,
190        defaults: arg.defaults.clone(),
191        source,
192        secrecy,
193    }
194}
195
196/// Where an argument's values come from, decided from the declaration before anything is
197/// annotated.
198///
199/// The order is the point: a value set the declaration carries always wins, then the
200/// annotation for an identifier whose registry is here, then the placeholder's own meaning.
201/// A command author who gives an argument a value-enum type or a `PATH` placeholder has
202/// already said everything a completion needs, and is not asked to say it again.
203fn infer(arg: &ArgDoc, path: &[String], values: &[ValueChoice]) -> ValueSource {
204    if !arg.takes_value {
205        return ValueSource::Free;
206    }
207    if !values.is_empty() {
208        return ValueSource::Enumerated;
209    }
210    if let Some(source) = semantics::binding(path, &arg.name) {
211        return source;
212    }
213    let placeholder = arg.value_name.as_deref().unwrap_or("").to_ascii_uppercase();
214    match placeholder.as_str() {
215        "PATH" | "DIR" | "FILE" | "OUT" => ValueSource::Path,
216        "BRANCH" => ValueSource::Branch,
217        "SHELL" => ValueSource::Shell,
218        _ => match arg.name.as_str() {
219            "repo" | "out" | "path" => ValueSource::Path,
220            "branch" | "base" => ValueSource::Branch,
221            "shell" => ValueSource::Shell,
222            _ => ValueSource::Free,
223        },
224    }
225}
226
227/// The requirements every native command shares, for a reader that wants the default
228/// without constructing a node.
229pub const NATIVE_REQUIREMENTS: &[Requirement] = &[Requirement::Repository, Requirement::Layer];
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn the_walk_finds_the_command_line() {
237        let c = contribute(None);
238        assert!(
239            c.nodes.len() > 40,
240            "the command line has more commands than {}",
241            c.nodes.len()
242        );
243        let status = c
244            .nodes
245            .iter()
246            .find(|n| n.id.as_str() == "executable.worktree.status")
247            .expect("worktree status is declared");
248        assert_eq!(status.invocation, "majordomus worktree status");
249        assert!(status.runnable);
250        assert_eq!(status.group.as_deref(), Some("worktree"));
251    }
252
253    #[test]
254    fn semantics_reach_the_nodes() {
255        let c = contribute(None);
256        let remove = c
257            .nodes
258            .iter()
259            .find(|n| n.id.as_str() == "executable.worktree.remove")
260            .expect("worktree remove is declared");
261        assert_eq!(remove.effect, super::super::model::Effect::Destructive);
262        let serve = c
263            .nodes
264            .iter()
265            .find(|n| n.id.as_str() == "executable.serve")
266            .expect("serve is declared");
267        assert_eq!(
268            serve.interactivity,
269            super::super::model::Interactivity::LongRunning
270        );
271    }
272
273    #[test]
274    fn every_annotation_names_a_command_that_exists() {
275        let c = contribute(None);
276        let stale: Vec<_> = c
277            .diagnostics
278            .iter()
279            .filter(|d| d.code == "semantics-names-no-command")
280            .collect();
281        assert!(stale.is_empty(), "stale annotations: {stale:?}");
282    }
283
284    #[test]
285    fn value_sources_are_inferred_from_the_declaration() {
286        let c = contribute(None);
287        let path_cmd = c
288            .nodes
289            .iter()
290            .find(|n| n.id.as_str() == "executable.worktree.path")
291            .expect("worktree path is declared");
292        let branch = path_cmd
293            .arguments
294            .iter()
295            .find(|a| a.name == "branch")
296            .expect("it takes a branch");
297        assert_eq!(branch.source, ValueSource::Branch);
298
299        // A value-enum argument carries its own values and needs no annotation.
300        let enumerated = c
301            .nodes
302            .iter()
303            .flat_map(|n| n.arguments.iter())
304            .find(|a| !a.values.is_empty())
305            .expect("some argument is a value enum");
306        assert_eq!(enumerated.source, ValueSource::Enumerated);
307    }
308}