Skip to main content

majordomus_cli/command_graph/
semantics.rs

1//! The semantics of the native command line that its declaration cannot carry.
2//!
3//! clap declares everything about *shape*: the commands, their arguments, defaults, value
4//! sets, help. It cannot declare what running one does to the repository, whether it holds
5//! a terminal, or where the values of `<BRANCH>` come from. Those are decisions, and this
6//! is where they are made — once, beside the declaration, in the same crate, never again
7//! per surface.
8//!
9//! # Why this is not a second registry
10//!
11//! Two properties keep it from becoming one:
12//!
13//! - **The default is the answer for most commands.** This executable is read-only and
14//!   non-interactive; [`Semantics::DEFAULT`] says so, and a command that is nothing more
15//!   than that appears nowhere below. Only a command that *departs* from the default is
16//!   named — which is exactly the information nothing else holds.
17//! - **Completeness is enforced in both directions.** `validate` fails when an entry
18//!   names a command that does not exist (a rename that left this file behind) and when a
19//!   command's effect could not be decided. The table cannot silently go stale, which is
20//!   the property that distinguishes a declaration from a mirror.
21//!
22//! This is the same arrangement the examples beside the command line already use, and it
23//! is checked by the same kind of test.
24
25use super::model::{Effect, Interactivity, Requirement, Secrecy, ValueSource};
26
27/// What a command does, beyond what its declaration says.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Semantics {
30    /// The command path this describes, the executable's own name excluded:
31    /// `&["worktree", "remove"]`.
32    pub path: &'static [&'static str],
33    /// What running it changes.
34    pub effect: Effect,
35    /// How it behaves towards a terminal.
36    pub interactivity: Interactivity,
37    /// What must be true for it to mean anything.
38    pub requires: &'static [Requirement],
39}
40
41impl Semantics {
42    /// What a command is when it says nothing: this executable reads and answers.
43    pub const DEFAULT: Semantics = Semantics {
44        path: &[],
45        effect: Effect::ReadOnly,
46        interactivity: Interactivity::NonInteractive,
47        requires: &[Requirement::Repository, Requirement::Layer],
48    };
49}
50
51/// Every command that departs from [`Semantics::DEFAULT`].
52///
53/// Ordered by command path. A command absent from this table is read-only and
54/// non-interactive, and needs a repository with a layer in it.
55pub const SEMANTICS: &[Semantics] = &[
56    // The two servers. Both read; neither returns.
57    Semantics {
58        path: &["mcp"],
59        effect: Effect::ReadOnly,
60        interactivity: Interactivity::LongRunning,
61        requires: &[Requirement::Repository, Requirement::Layer],
62    },
63    Semantics {
64        path: &["serve"],
65        effect: Effect::ReadOnly,
66        interactivity: Interactivity::LongRunning,
67        requires: &[Requirement::Repository, Requirement::Layer],
68    },
69    // Generation writes the committed projections.
70    Semantics {
71        path: &["generate"],
72        effect: Effect::RepositoryMutation,
73        interactivity: Interactivity::NonInteractive,
74        requires: &[Requirement::Repository, Requirement::Layer],
75    },
76    // The benchmark writes its results and its baseline under the layer's repository half.
77    Semantics {
78        path: &["bench"],
79        effect: Effect::RepositoryMutation,
80        interactivity: Interactivity::NonInteractive,
81        requires: &[Requirement::Repository, Requirement::Layer],
82    },
83    Semantics {
84        path: &["bench", "coverage"],
85        effect: Effect::ReadOnly,
86        interactivity: Interactivity::NonInteractive,
87        requires: &[Requirement::Repository, Requirement::Layer],
88    },
89    Semantics {
90        path: &["bench", "baseline", "update"],
91        effect: Effect::RepositoryMutation,
92        interactivity: Interactivity::NonInteractive,
93        requires: &[Requirement::Repository, Requirement::Layer],
94    },
95    // The one command here that writes outside the repository: a person's shell startup
96    // file. It needs no repository and no layer — a shell is installed once and serves every
97    // checkout — and nothing declares a capability for it, so no machine surface carries it.
98    Semantics {
99        path: &["completion", "install"],
100        effect: Effect::LocalMutation,
101        interactivity: Interactivity::NonInteractive,
102        requires: &[],
103    },
104    // Raising the version writes tracked files — the manifest and the lock's record of it —
105    // which is a repository mutation and is why no capability declares it: the policy keeps
106    // repository mutations off every machine surface, so the bump is a command a person runs
107    // and nothing reachable over MCP or HTTP.
108    Semantics {
109        path: &["release", "bump"],
110        effect: Effect::RepositoryMutation,
111        interactivity: Interactivity::NonInteractive,
112        requires: &[Requirement::Repository],
113    },
114    // Packaging writes into the build directory.
115    Semantics {
116        path: &["distribution", "build"],
117        effect: Effect::LocalMutation,
118        interactivity: Interactivity::NonInteractive,
119        requires: &[Requirement::Repository, Requirement::RustToolchain],
120    },
121    // The environment is a property of the checkout; it needs no layer.
122    Semantics {
123        path: &["env"],
124        effect: Effect::ReadOnly,
125        interactivity: Interactivity::NonInteractive,
126        requires: &[Requirement::Repository],
127    },
128    // The topology reads git and writes worktrees. It never needs the layer.
129    Semantics {
130        path: &["worktree"],
131        effect: Effect::ReadOnly,
132        interactivity: Interactivity::NonInteractive,
133        requires: &[Requirement::Repository],
134    },
135    Semantics {
136        path: &["worktree", "create"],
137        effect: Effect::RepositoryMutation,
138        interactivity: Interactivity::NonInteractive,
139        requires: &[Requirement::Repository],
140    },
141    Semantics {
142        path: &["worktree", "ensure"],
143        effect: Effect::RepositoryMutation,
144        interactivity: Interactivity::NonInteractive,
145        requires: &[Requirement::Repository],
146    },
147    Semantics {
148        path: &["worktree", "migrate"],
149        effect: Effect::RepositoryMutation,
150        interactivity: Interactivity::NonInteractive,
151        requires: &[Requirement::Repository],
152    },
153    Semantics {
154        path: &["worktree", "repair"],
155        effect: Effect::RepositoryMutation,
156        interactivity: Interactivity::NonInteractive,
157        requires: &[Requirement::Repository],
158    },
159    Semantics {
160        path: &["worktree", "remove"],
161        effect: Effect::Destructive,
162        interactivity: Interactivity::NonInteractive,
163        requires: &[Requirement::Repository],
164    },
165];
166
167/// Where the values of an argument come from, when the type the declaration erased is one
168/// this repository has a registry for.
169///
170/// Keyed by the command path and the argument's id. Inference comes first — a value-enum
171/// argument carries its own values and a `PATH` placeholder is a path — so only an
172/// identifier whose set lives in a registry appears here.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct ValueBinding {
175    /// The command path the argument belongs to.
176    pub path: &'static [&'static str],
177    /// The argument's id, as clap knows it.
178    pub argument: &'static str,
179    /// Where its values come from.
180    pub source: ValueSource,
181}
182
183/// Every argument whose values a registry here knows.
184pub const VALUE_BINDINGS: &[ValueBinding] = &[
185    ValueBinding {
186        path: &["capabilities", "describe"],
187        argument: "id",
188        source: ValueSource::Capability,
189    },
190    ValueBinding {
191        path: &["capabilities", "schema"],
192        argument: "id",
193        source: ValueSource::Capability,
194    },
195    ValueBinding {
196        path: &["why", "show"],
197        argument: "id",
198        source: ValueSource::Moment,
199    },
200    ValueBinding {
201        path: &["worktree", "path"],
202        argument: "branch",
203        source: ValueSource::Branch,
204    },
205    ValueBinding {
206        path: &["worktree", "inspect"],
207        argument: "branch",
208        source: ValueSource::Branch,
209    },
210    ValueBinding {
211        path: &["worktree", "create"],
212        argument: "branch",
213        source: ValueSource::Branch,
214    },
215    ValueBinding {
216        path: &["worktree", "ensure"],
217        argument: "branch",
218        source: ValueSource::Branch,
219    },
220    ValueBinding {
221        path: &["worktree", "remove"],
222        argument: "branch",
223        source: ValueSource::Branch,
224    },
225    ValueBinding {
226        path: &["commands", "show"],
227        argument: "id",
228        source: ValueSource::Command,
229    },
230    ValueBinding {
231        path: &["commands", "explain"],
232        argument: "id",
233        source: ValueSource::Command,
234    },
235    ValueBinding {
236        path: &["web", "explain"],
237        argument: "id",
238        source: ValueSource::Graph,
239    },
240];
241
242/// A name the workflow runner answered to before the bridge was derived, kept so that no
243/// recipe a person or a script used disappears.
244///
245/// An alias carries no description, no group and no semantics of its own: it names a
246/// command of the graph and, where the older spelling meant a command *with* an argument,
247/// the arguments it always passed. Everything a projection shows about it comes from the
248/// command it names. [`unused_aliases`] fails the build when an alias names a command that
249/// no longer exists, so a rename cannot leave a recipe pointing at nothing.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub struct WorkflowAlias {
252    /// The recipe name, as it was.
253    pub alias: &'static str,
254    /// The command it resolves to, by canonical identity.
255    pub command: &'static str,
256    /// The arguments the older spelling always passed, before the caller's own.
257    pub arguments: &'static [&'static str],
258    /// Why it exists, in one line, for the generated file and the reference to print.
259    pub reason: &'static str,
260}
261
262/// Every workflow name that predates the generated bridge.
263///
264/// This is the compatibility inventory of the migration, and it is the only place it
265/// exists: the bridge renders these, the reference prints them, and nothing else keeps a
266/// table of old names.
267pub const WORKFLOW_ALIASES: &[WorkflowAlias] = &[
268    WorkflowAlias {
269        alias: "capabilities",
270        command: "executable.capabilities.list",
271        arguments: &[],
272        reason: "the recipe before the bridge derived `capabilities-list` from the command",
273    },
274    WorkflowAlias {
275        alias: "describe",
276        command: "executable.capabilities.describe",
277        arguments: &[],
278        reason: "the recipe before the bridge derived `capabilities-describe`",
279    },
280    WorkflowAlias {
281        alias: "validate",
282        command: "executable.capabilities.validate",
283        arguments: &[],
284        reason: "the recipe before the bridge derived `capabilities-validate`",
285    },
286    WorkflowAlias {
287        alias: "generate-check",
288        command: "executable.generate",
289        arguments: &["--check"],
290        reason: "the recipe that spelled `generate --check`",
291    },
292    WorkflowAlias {
293        alias: "inspect",
294        command: "executable.mcp",
295        arguments: &["--inspect"],
296        reason: "the recipe that spelled `mcp --inspect`",
297    },
298    WorkflowAlias {
299        alias: "bench-run",
300        command: "executable.bench",
301        arguments: &[],
302        reason: "the recipe before `bench` was qualified by the program that owns it",
303    },
304    WorkflowAlias {
305        alias: "bench-check",
306        command: "executable.bench",
307        arguments: &["--profile", "ci", "--check", "--no-write"],
308        reason: "the recipe that spelled the continuous-integration benchmark run",
309    },
310    WorkflowAlias {
311        alias: "bench-baseline",
312        command: "executable.bench.baseline.update",
313        arguments: &[],
314        reason: "the recipe before the bridge derived `bench-baseline-update`",
315    },
316    WorkflowAlias {
317        alias: "wt",
318        command: "executable.worktree",
319        arguments: &[],
320        reason: "the command line's own alias for `worktree`, kept for the runner too",
321    },
322    WorkflowAlias {
323        alias: "wt-create",
324        command: "executable.worktree.ensure",
325        arguments: &[],
326        reason: "the recipe that created a worktree if it was absent and answered it if not",
327    },
328    WorkflowAlias {
329        alias: "wt-migrate",
330        command: "executable.worktree.migrate",
331        arguments: &[],
332        reason: "the recipe before the bridge derived `worktree-migrate`",
333    },
334    WorkflowAlias {
335        alias: "wt-doctor",
336        command: "executable.worktree.doctor",
337        arguments: &[],
338        reason: "the recipe before the bridge derived `worktree-doctor`",
339    },
340    WorkflowAlias {
341        alias: "usecase-list",
342        command: "tool.usecase",
343        arguments: &["list"],
344        reason: "the recipe that spelled one subcommand of the shell tool's `usecase`",
345    },
346    WorkflowAlias {
347        alias: "usecase-run",
348        command: "tool.usecase",
349        arguments: &["run"],
350        reason: "the recipe that spelled one subcommand of the shell tool's `usecase`",
351    },
352    WorkflowAlias {
353        alias: "usecase-coverage",
354        command: "tool.usecase",
355        arguments: &["coverage", "--check"],
356        reason: "the recipe that spelled the use-case coverage gate",
357    },
358    WorkflowAlias {
359        alias: "usecase-impact",
360        command: "tool.usecase",
361        arguments: &["impact"],
362        reason: "the recipe that spelled one subcommand of the shell tool's `usecase`",
363    },
364    WorkflowAlias {
365        alias: "mcp-status",
366        command: "executable.serve.status",
367        arguments: &[],
368        reason:
369            "the recipe that read this checkout's lease before the bridge derived `serve-status`",
370    },
371];
372
373/// Every alias that names a command the graph does not carry.
374pub fn unused_aliases(existing: &[String]) -> Vec<&'static WorkflowAlias> {
375    WORKFLOW_ALIASES
376        .iter()
377        .filter(|a| !existing.iter().any(|id| id == a.command))
378        .collect()
379}
380
381/// The semantics of one command: the most specific entry that names it, or the most
382/// specific entry that names a prefix of it, or the default.
383///
384/// Inheritance down the tree is what keeps the table short: `worktree` says the whole
385/// topology needs no layer once, and every command under it is answered by that.
386pub fn of(path: &[String]) -> Semantics {
387    let mut best: Option<&Semantics> = None;
388    for entry in SEMANTICS {
389        if entry.path.len() <= path.len()
390            && entry
391                .path
392                .iter()
393                .zip(path.iter())
394                .all(|(a, b)| *a == b.as_str())
395            && best.is_none_or(|b| b.path.len() < entry.path.len())
396        {
397            best = Some(entry);
398        }
399    }
400    best.copied().unwrap_or(Semantics::DEFAULT)
401}
402
403/// Where an argument's values come from, when a binding names it.
404pub fn binding(path: &[String], argument: &str) -> Option<ValueSource> {
405    VALUE_BINDINGS
406        .iter()
407        .find(|b| {
408            b.argument == argument
409                && b.path.len() == path.len()
410                && b.path
411                    .iter()
412                    .zip(path.iter())
413                    .all(|(a, c)| *a == c.as_str())
414        })
415        .map(|b| b.source)
416}
417
418/// How openly an argument's value may be handled, inferred from what it is called.
419///
420/// Nothing in this executable takes a credential today. The classification exists so that
421/// the day one does, no completion, log or cache has to be taught to leave it alone.
422pub fn secrecy(argument: &str, value_name: Option<&str>) -> Secrecy {
423    let hay = format!(
424        "{argument} {}",
425        value_name.unwrap_or_default().to_lowercase()
426    );
427    const SECRET: &[&str] = &["token", "secret", "password", "passwd", "api-key", "apikey"];
428    const SENSITIVE: &[&str] = &["credential", "auth", "key"];
429    if SECRET.iter().any(|w| hay.contains(w)) {
430        Secrecy::Secret
431    } else if SENSITIVE.iter().any(|w| hay.contains(w)) {
432        Secrecy::Sensitive
433    } else {
434        Secrecy::Public
435    }
436}
437
438/// Every entry names a command that exists.
439///
440/// The failure this catches is the one that makes a table like this dangerous: a command
441/// is renamed, the entry beside it is not, and the command silently falls back to the
442/// default — which for `worktree remove` would mean a destructive command advertised to
443/// every machine surface. Returns the offending paths.
444pub fn unused(existing: &[Vec<String>]) -> Vec<String> {
445    let mut out = Vec::new();
446    for entry in SEMANTICS {
447        if !existing
448            .iter()
449            .any(|p| p.len() == entry.path.len() && p.iter().zip(entry.path).all(|(a, b)| a == b))
450        {
451            out.push(entry.path.join(" "));
452        }
453    }
454    for binding in VALUE_BINDINGS {
455        if !existing.iter().any(|p| {
456            p.len() == binding.path.len() && p.iter().zip(binding.path).all(|(a, b)| a == b)
457        }) {
458            out.push(format!("{} <{}>", binding.path.join(" "), binding.argument));
459        }
460    }
461    out
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    fn p(words: &[&str]) -> Vec<String> {
469        words.iter().map(|w| w.to_string()).collect()
470    }
471
472    #[test]
473    fn the_default_answers_an_unnamed_command() {
474        let s = of(&p(&["scope"]));
475        assert_eq!(s.effect, Effect::ReadOnly);
476        assert_eq!(s.interactivity, Interactivity::NonInteractive);
477    }
478
479    #[test]
480    fn the_most_specific_entry_wins() {
481        assert_eq!(of(&p(&["bench"])).effect, Effect::RepositoryMutation);
482        assert_eq!(of(&p(&["bench", "coverage"])).effect, Effect::ReadOnly);
483        assert_eq!(
484            of(&p(&["bench", "baseline", "update"])).effect,
485            Effect::RepositoryMutation
486        );
487    }
488
489    #[test]
490    fn semantics_are_inherited_down_the_tree() {
491        // `worktree` declares that the topology needs no layer; `worktree list` inherits it.
492        let s = of(&p(&["worktree", "list"]));
493        assert_eq!(s.requires, &[Requirement::Repository]);
494        assert_eq!(s.effect, Effect::ReadOnly);
495        assert_eq!(of(&p(&["worktree", "remove"])).effect, Effect::Destructive);
496    }
497
498    #[test]
499    fn a_binding_is_exact_not_inherited() {
500        assert_eq!(
501            binding(&p(&["worktree", "path"]), "branch"),
502            Some(ValueSource::Branch)
503        );
504        assert_eq!(binding(&p(&["worktree"]), "branch"), None);
505    }
506
507    #[test]
508    fn secrets_are_recognised_by_name() {
509        assert_eq!(secrecy("token", None), Secrecy::Secret);
510        assert_eq!(secrecy("api_key", Some("API-KEY")), Secrecy::Secret);
511        assert_eq!(secrecy("branch", Some("BRANCH")), Secrecy::Public);
512    }
513
514    #[test]
515    fn an_entry_naming_nothing_is_reported() {
516        let existing = vec![p(&["mcp"])];
517        let stale = unused(&existing);
518        assert!(stale.contains(&"serve".to_string()));
519    }
520}