Skip to main content

majordomus_cli/command_graph/
model.rs

1//! The typed model of the command graph: what a command is, and what every projection of
2//! it may say.
3//!
4//! Nothing here is a list of commands. The types describe the *shape* of a command node;
5//! the nodes themselves come from the contributors in [`super::native`], [`super::tool`]
6//! and [`super::workflow`], each of which reads a declaration that already exists — the
7//! clap tree, the shipped command registry, the workflow runner's own dump — rather than
8//! restating one.
9//!
10//! # Identity
11//!
12//! A [`CommandId`] is `<origin>.<path joined by dots>`: `executable.worktree.status`,
13//! `tool.check`, `workflow.site-build`. The origin is in the identity because the three
14//! programs are genuinely different programs — `majordomus check` is the shell tool and
15//! `majordomus worktree status` is the Rust executable, and a graph that pretended they
16//! shared a namespace would have to invent a rule for the day they collide. The id is
17//! never a display string: it survives a renamed group, a moved recipe and a reworded
18//! summary, which is what the projections key on.
19
20use std::collections::BTreeMap;
21use std::fmt;
22
23use schemars::JsonSchema;
24use serde::{Deserialize, Serialize};
25
26use crate::capability::CapabilityId;
27
28/// The schema every serialisation of the graph carries.
29pub const SCHEMA: &str = "majordomus/command-graph/v1";
30
31/// Which program runs a command. Part of the identity, because the three are different
32/// programs that share one name on the path.
33#[derive(
34    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
35)]
36#[serde(rename_all = "snake_case")]
37#[schemars(rename = "CommandOrigin")]
38pub enum Origin {
39    /// The Rust executable, `apps/majordomus-cli`: the read-only interfaces, generation,
40    /// introspection, the servers. Declared by clap.
41    Executable,
42    /// The shell tool, `bin/majordomus`: the task lifecycle. Declared by the shipped
43    /// command registry and dispatched by the tool itself.
44    Tool,
45    /// A workflow the repository declares for a person to run — a `just` recipe that is
46    /// not a generated bridge. Declared by the justfile and read from the runner's dump.
47    Workflow,
48}
49
50impl Origin {
51    /// The prefix this origin contributes to every id under it.
52    pub fn prefix(self) -> &'static str {
53        match self {
54            Origin::Executable => "executable",
55            Origin::Tool => "tool",
56            Origin::Workflow => "workflow",
57        }
58    }
59
60    /// The program a person types, for the rendering of an invocation.
61    pub fn program(self) -> &'static str {
62        match self {
63            Origin::Executable => "majordomus",
64            Origin::Tool => "majordomus",
65            Origin::Workflow => "just",
66        }
67    }
68}
69
70/// The canonical identity of one command.
71///
72/// Constructed from the origin and the command path, never written by hand, so that a
73/// projection cannot invent one and a rename of a display string cannot change one.
74#[derive(
75    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
76)]
77#[serde(transparent)]
78pub struct CommandId(String);
79
80impl CommandId {
81    /// Derive the id of a command with this path under this origin.
82    ///
83    /// The path is the words a person types after the program's own name. Each word is
84    /// lowered and every character outside `[a-z0-9-]` becomes `-`, so that a recipe named
85    /// with a character the id grammar refuses still has one stable identity rather than
86    /// none.
87    ///
88    /// ```
89    /// use majordomus_cli::command_graph::{CommandId, Origin};
90    /// let id = CommandId::derive(Origin::Executable, &["worktree".into(), "status".into()]);
91    /// assert_eq!(id.as_str(), "executable.worktree.status");
92    /// ```
93    pub fn derive(origin: Origin, path: &[String]) -> Self {
94        let mut out = String::from(origin.prefix());
95        for word in path {
96            out.push('.');
97            out.extend(word.chars().map(|c| match c {
98                'a'..='z' | '0'..='9' | '-' => c,
99                'A'..='Z' => c.to_ascii_lowercase(),
100                _ => '-',
101            }));
102        }
103        CommandId(out)
104    }
105
106    /// The id as text.
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    /// The origin this id belongs to, read back from its prefix.
112    pub fn origin(&self) -> Option<Origin> {
113        match self.0.split('.').next()? {
114            "executable" => Some(Origin::Executable),
115            "tool" => Some(Origin::Tool),
116            "workflow" => Some(Origin::Workflow),
117            _ => None,
118        }
119    }
120}
121
122impl fmt::Display for CommandId {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(&self.0)
125    }
126}
127
128/// What running a command changes. The one thing a surface policy is allowed to ask.
129///
130/// The order is the order of increasing consequence, and it is the order the derived
131/// exposure policy reads: a surface declares the strongest effect it will carry, and
132/// every node at or below it is projected there. Nothing configures a surface per
133/// command.
134#[derive(
135    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
136)]
137#[serde(rename_all = "snake_case")]
138#[schemars(rename = "CommandEffect")]
139pub enum Effect {
140    /// Reads and answers. Changes nothing anywhere.
141    ReadOnly,
142    /// Writes only where the repository keeps a checkout's own state — the process's
143    /// memory, `.ai/local/`, a build directory. Nothing a commit would carry.
144    LocalMutation,
145    /// Writes tracked files: generated artifacts, the worktree, git itself.
146    RepositoryMutation,
147    /// Reaches the network with an effect on the far side: a push, a deploy, a release.
148    NetworkMutation,
149    /// Removes something a person would have to reconstruct.
150    Destructive,
151}
152
153impl Effect {
154    /// The effect of a capability, from the execution policy the registry classified.
155    ///
156    /// Read from the policy rather than from the kind alone, because the kind cannot tell
157    /// the two commands apart: announcing a peer changes this process's memory, and moving
158    /// an issue writes a tracked file a commit will carry. This function is what the
159    /// exposure policy consults — a surface carries every node at or below the strongest
160    /// effect it declares — so a capability whose effect was understated here would be
161    /// projected onto a surface whose ceiling exists to exclude it.
162    pub fn of_capability(policy: crate::capability::ExecutionPolicy) -> Self {
163        match policy.effect {
164            crate::capability::Effect::Read => Effect::ReadOnly,
165            crate::capability::Effect::ProcessState => Effect::LocalMutation,
166            crate::capability::Effect::RepositoryMutation => Effect::RepositoryMutation,
167        }
168    }
169
170    /// Does this effect leave the repository as it found it?
171    pub fn is_read_only(self) -> bool {
172        matches!(self, Effect::ReadOnly)
173    }
174}
175
176/// How a command behaves towards the caller's terminal and the caller's patience.
177#[derive(
178    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
179)]
180#[serde(rename_all = "snake_case")]
181#[schemars(rename = "CommandInteractivity")]
182pub enum Interactivity {
183    /// Runs, answers, exits. Safe to call from a machine surface.
184    NonInteractive,
185    /// Asks the person something, or reads the body of a record from a terminal. A
186    /// machine surface that offered it would hang.
187    Interactive,
188    /// Serves until it is stopped. A request/response surface cannot carry it.
189    LongRunning,
190}
191
192/// Where a command means anything, and why not when it does not.
193///
194/// Derived from the repository environment rather than declared per command, so that a
195/// projection reads a field instead of re-deciding. The reason is carried because the
196/// answer a person needs is never `false`.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
198#[schemars(rename = "CommandAvailability")]
199pub struct Availability {
200    /// Can it be run here, now?
201    pub available: bool,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    /// Why not, in one line, when it cannot.
204    pub reason: Option<String>,
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    /// What it needs, whether or not that is satisfied here.
207    pub requires: Vec<Requirement>,
208}
209
210impl Availability {
211    /// Available, with nothing standing in the way.
212    pub fn always() -> Self {
213        Availability {
214            available: true,
215            reason: None,
216            requires: Vec::new(),
217        }
218    }
219}
220
221/// One thing a command needs before it can run.
222#[derive(
223    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
224)]
225#[serde(rename_all = "snake_case")]
226#[schemars(rename = "CommandRequirement")]
227pub enum Requirement {
228    /// A git repository.
229    Repository,
230    /// The `.ai/` layer, initialised.
231    Layer,
232    /// An active task record.
233    Task,
234    /// The Rust executable, built.
235    Executable,
236    /// The workflow runner, installed.
237    WorkflowRunner,
238    /// A cargo workspace and a toolchain to build it.
239    RustToolchain,
240    /// The site sources and its generator.
241    Site,
242}
243
244/// Where a value for an argument comes from, when something in this repository knows the
245/// set.
246///
247/// This is the completion contract, and it is a property of the *argument*, not of a shell
248/// script: the same source answers a shell's TAB, a generated form's select and a machine
249/// surface's enumeration of what it will accept. Inference from the declaration comes
250/// first — a value-enum argument carries its own values, a `PATH` placeholder is a path —
251/// and only what cannot be inferred is annotated beside the command.
252#[derive(
253    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
254)]
255#[serde(tag = "kind", rename_all = "snake_case")]
256#[schemars(rename = "CommandValueSource")]
257pub enum ValueSource {
258    /// Nothing here knows the set; the caller types a value.
259    Free,
260    /// The declaration carries the values; they are on the argument.
261    Enumerated,
262    /// A path in the filesystem.
263    Path,
264    /// A path inside the repository.
265    RepositoryPath,
266    /// A capability id, from the registry.
267    Capability,
268    /// A command id, from this graph.
269    Command,
270    /// A rule id, from the effective rule set.
271    Rule,
272    /// An object kind, from the index.
273    ObjectKind,
274    /// A git branch in this repository.
275    Branch,
276    /// A graph id, from the graph registry.
277    Graph,
278    /// A moment id, from the why catalogue.
279    Moment,
280    /// A shell name, from the shells the activation supports.
281    Shell,
282    /// A secret. Never enumerated, never cached, never suggested.
283    Secret,
284}
285
286impl ValueSource {
287    /// May a completion offer values for this source at all?
288    pub fn suggestible(self) -> bool {
289        !matches!(self, ValueSource::Free | ValueSource::Secret)
290    }
291}
292
293/// How openly a value may be handled.
294#[derive(
295    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
296)]
297#[serde(rename_all = "snake_case")]
298#[schemars(rename = "CommandSecrecy")]
299pub enum Secrecy {
300    /// Ordinary: may be logged, completed, shown.
301    Public,
302    /// A path or an identifier that names something private. Shown, never logged.
303    Sensitive,
304    /// A credential. Never completed, never logged, never cached.
305    Secret,
306}
307
308/// One argument of a command, as the declaration gives it plus what can be inferred.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
310#[schemars(rename = "CommandArgument")]
311pub struct ArgumentSpec {
312    /// The argument's id.
313    pub name: String,
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    /// `--long`, without the dashes.
316    pub long: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    /// `-s`, without the dash.
319    pub short: Option<char>,
320    /// Given by position rather than by flag.
321    pub positional: bool,
322    /// Takes a value at all; a flag does not.
323    pub takes_value: bool,
324    /// Must be given.
325    pub required: bool,
326    /// Takes any number of values.
327    pub variadic: bool,
328    /// Accepted by every command under the one that declares it.
329    pub global: bool,
330    /// The help text, one line.
331    pub help: String,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    /// The placeholder, `PATH`.
334    pub value_name: Option<String>,
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    /// The values the declaration carries, each with its help.
337    pub values: Vec<ValueChoice>,
338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
339    /// The defaults, as the declaration renders them.
340    pub defaults: Vec<String>,
341    /// Where further values come from.
342    pub source: ValueSource,
343    /// How openly the value may be handled.
344    pub secrecy: Secrecy,
345}
346
347/// One value an argument accepts, from the declaration.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
349#[schemars(rename = "CommandValueChoice")]
350pub struct ValueChoice {
351    /// The value as typed.
352    pub value: String,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    /// Its help, when the declaration carries one.
355    pub description: Option<String>,
356}
357
358/// How a command is actually run: the program and the words before the caller's own.
359///
360/// This is what forbids a cycle. A projection renders an invocation from the execution
361/// descriptor, so a generated bridge always spells the *canonical* program — never the
362/// surface it is a bridge for.
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364#[schemars(rename = "CommandExecution")]
365pub struct Execution {
366    /// Which program.
367    pub origin: Origin,
368    /// The words that precede the caller's arguments, the program's own name excluded.
369    pub argv: Vec<String>,
370}
371
372impl Execution {
373    /// The command line a person types, without arguments.
374    pub fn invocation(&self) -> String {
375        let mut out = String::from(self.origin.program());
376        for word in &self.argv {
377            out.push(' ');
378            out.push_str(word);
379        }
380        out
381    }
382}
383
384/// Where a node came from, in enough detail to open the file.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
386#[schemars(rename = "CommandProvenance")]
387pub struct Provenance {
388    /// The repository-relative file that declares it.
389    pub declared_in: String,
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    /// The command that reads that declaration, when a reader wants to reproduce it.
392    pub read_by: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    /// The capability this command runs, when it runs one.
395    pub capability: Option<CapabilityId>,
396}
397
398/// A command that is no longer the name to use.
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
400#[schemars(rename = "CommandDeprecation")]
401pub struct Deprecation {
402    /// Why, in one line.
403    pub reason: String,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    /// What to use instead.
406    pub replaced_by: Option<CommandId>,
407}
408
409/// Where one command appears, derived from its effect, its interactivity and its origin.
410///
411/// Every field is computed by [`super::policy`]. Nothing declares a projection, and no
412/// surface keeps a list of what it carries: a surface asks the graph.
413#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
414#[schemars(rename = "CommandProjections")]
415pub struct Projections {
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    /// The command line, as typed.
418    pub cli: Option<String>,
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    /// The generated workflow bridge's recipe name.
421    pub workflow: Option<String>,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    /// The MCP tool name, when the capability behind it declares one.
424    pub mcp: Option<String>,
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    /// The HTTP route, when the capability behind it declares one.
427    pub http: Option<String>,
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    /// The Cockpit address, when the surface carries it.
430    pub cockpit: Option<String>,
431    /// The page on the site. Every command has one.
432    pub docs: String,
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    /// Why a machine surface does not carry it, when one does not.
435    pub withheld: Option<String>,
436}
437
438/// One command, from whichever program offers it.
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
440pub struct CommandNode {
441    /// The canonical identity.
442    pub id: CommandId,
443    /// Which program runs it.
444    pub origin: Origin,
445    /// The words after the program's own name.
446    pub path: Vec<String>,
447    /// The command line a person types, rendered once here.
448    pub invocation: String,
449    /// One line.
450    pub summary: String,
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    /// The longer description, when the declaration carries one.
453    pub description: Option<String>,
454    /// Can it be run on its own, or does it only group the commands under it?
455    pub runnable: bool,
456    /// The arguments, in declaration order.
457    pub arguments: Vec<ArgumentSpec>,
458    /// How it is run.
459    pub execution: Execution,
460    /// What it changes.
461    pub effect: Effect,
462    /// How it behaves towards a terminal.
463    pub interactivity: Interactivity,
464    /// Where it stands.
465    pub stability: crate::capability::Stability,
466    /// Where it means anything.
467    pub availability: Availability,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    /// The group a person finds it under, when the declaration has groups.
470    pub group: Option<String>,
471    #[serde(default, skip_serializing_if = "Vec::is_empty")]
472    /// Free tags.
473    pub tags: Vec<String>,
474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
475    /// Other names that resolve to this node, declared once here and honoured by every
476    /// projection that has a use for one.
477    pub aliases: Vec<String>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    /// Whether this is still the name to use.
480    pub deprecation: Option<Deprecation>,
481    /// Where it came from.
482    pub provenance: Provenance,
483    /// Where it appears.
484    pub projections: Projections,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    /// Is this command an entry point of its group — the one a newcomer is offered?
487    pub entrypoint: Option<bool>,
488}
489
490impl CommandNode {
491    /// Everything a search matches on, lowered, joined.
492    pub fn haystack(&self) -> String {
493        let mut s = self.invocation.to_lowercase();
494        s.push(' ');
495        s.push_str(&self.summary.to_lowercase());
496        for tag in &self.tags {
497            s.push(' ');
498            s.push_str(&tag.to_lowercase());
499        }
500        s.push(' ');
501        s.push_str(self.id.as_str());
502        s
503    }
504}
505
506/// How bad a finding is.
507#[derive(
508    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
509)]
510#[serde(rename_all = "snake_case")]
511#[schemars(rename = "CommandDiagnosticSeverity")]
512pub enum Severity {
513    /// The graph is wrong and a projection built from it would be wrong.
514    Error,
515    /// Worth saying; the graph stands.
516    Warning,
517    /// A fact a reader may want.
518    Info,
519}
520
521/// One thing the build found.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
523#[schemars(rename = "CommandDiagnostic")]
524pub struct Diagnostic {
525    /// How bad.
526    pub severity: Severity,
527    /// A stable code, for a gate to match on.
528    pub code: String,
529    /// What is wrong, in one line.
530    pub message: String,
531    #[serde(default, skip_serializing_if = "Vec::is_empty")]
532    /// The nodes it is about.
533    pub commands: Vec<CommandId>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    /// What to do about it.
536    pub remedy: Option<String>,
537}
538
539impl Diagnostic {
540    /// An error about one or more commands.
541    pub fn error(code: &str, message: impl Into<String>, commands: Vec<CommandId>) -> Self {
542        Diagnostic {
543            severity: Severity::Error,
544            code: code.into(),
545            message: message.into(),
546            commands,
547            remedy: None,
548        }
549    }
550
551    /// A warning about one or more commands.
552    pub fn warning(code: &str, message: impl Into<String>, commands: Vec<CommandId>) -> Self {
553        Diagnostic {
554            severity: Severity::Warning,
555            code: code.into(),
556            message: message.into(),
557            commands,
558            remedy: None,
559        }
560    }
561
562    /// The same, with a remedy.
563    pub fn with_remedy(mut self, remedy: impl Into<String>) -> Self {
564        self.remedy = Some(remedy.into());
565        self
566    }
567}
568
569/// The whole graph.
570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
571pub struct CommandGraph {
572    /// The schema of this document.
573    pub schema: String,
574    /// A hash of the semantic content: the identity a cache and a generated projection
575    /// key on. Nothing that varies between two runs over the same tree is in it.
576    pub fingerprint: String,
577    /// Every command, in a deterministic order: origin, then path.
578    pub commands: Vec<CommandNode>,
579    /// What the build found.
580    pub diagnostics: Vec<Diagnostic>,
581}
582
583impl CommandGraph {
584    /// Look one up.
585    pub fn get(&self, id: &CommandId) -> Option<&CommandNode> {
586        self.commands.iter().find(|c| &c.id == id)
587    }
588
589    /// Resolve a surface's spelling of a command back to the canonical node.
590    ///
591    /// This is the one place a surface alias is interpreted. A completion for `just`
592    /// resolves the recipe name here and then asks the same engine the command line asks,
593    /// which is why there is no second completion implementation.
594    pub fn resolve(&self, surface: Surface, spelling: &str) -> Option<&CommandNode> {
595        self.commands.iter().find(|c| match surface {
596            Surface::Cli => c.projections.cli.as_deref() == Some(spelling),
597            Surface::Workflow => {
598                c.projections.workflow.as_deref() == Some(spelling)
599                    || c.aliases.iter().any(|a| a == spelling)
600            }
601            Surface::Mcp => c.projections.mcp.as_deref() == Some(spelling),
602            Surface::Http => c.projections.http.as_deref() == Some(spelling),
603        })
604    }
605
606    /// The errors, if any. A projection refuses to write when this is not empty.
607    pub fn errors(&self) -> Vec<&Diagnostic> {
608        self.diagnostics
609            .iter()
610            .filter(|d| d.severity == Severity::Error)
611            .collect()
612    }
613
614    /// The commands of one origin.
615    pub fn of_origin(&self, origin: Origin) -> impl Iterator<Item = &CommandNode> {
616        self.commands.iter().filter(move |c| c.origin == origin)
617    }
618
619    /// The runnable commands, grouped by their group, in graph order.
620    pub fn by_group(&self) -> BTreeMap<String, Vec<&CommandNode>> {
621        let mut out: BTreeMap<String, Vec<&CommandNode>> = BTreeMap::new();
622        for node in self.commands.iter().filter(|c| c.runnable) {
623            let key = node.group.clone().unwrap_or_else(|| "other".into());
624            out.entry(key).or_default().push(node);
625        }
626        out
627    }
628}
629
630/// A surface that spells commands its own way.
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
632#[serde(rename_all = "snake_case")]
633#[schemars(rename = "CommandSurfaceName")]
634pub enum Surface {
635    /// The command line of either program.
636    Cli,
637    /// The workflow runner.
638    Workflow,
639    /// MCP.
640    Mcp,
641    /// HTTP.
642    Http,
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    #[test]
650    fn ids_are_derived_and_stable() {
651        let a = CommandId::derive(Origin::Executable, &["worktree".into(), "status".into()]);
652        assert_eq!(a.as_str(), "executable.worktree.status");
653        assert_eq!(a.origin(), Some(Origin::Executable));
654        let b = CommandId::derive(Origin::Workflow, &["site-build".into()]);
655        assert_eq!(b.as_str(), "workflow.site-build");
656        // A spelling the grammar refuses still yields one identity rather than none.
657        let c = CommandId::derive(Origin::Workflow, &["Site Build".into()]);
658        assert_eq!(c.as_str(), "workflow.site-build");
659    }
660
661    #[test]
662    fn effects_order_by_consequence() {
663        assert!(Effect::ReadOnly < Effect::LocalMutation);
664        assert!(Effect::RepositoryMutation < Effect::NetworkMutation);
665        assert!(Effect::NetworkMutation < Effect::Destructive);
666        use crate::capability::{CapabilityKind, ExecutionPolicy};
667        let policy = ExecutionPolicy::classify;
668        assert!(Effect::of_capability(policy(CapabilityKind::Query)).is_read_only());
669        assert!(!Effect::of_capability(policy(CapabilityKind::Command)).is_read_only());
670        // The distinction the kind cannot make, and the reason this reads the policy: a
671        // command that writes a tracked record is a stronger effect than one that does not,
672        // and the exposure ceiling is derived from exactly this value.
673        assert_eq!(
674            Effect::of_capability(policy(CapabilityKind::Command)),
675            Effect::LocalMutation
676        );
677        assert_eq!(
678            Effect::of_capability(policy(CapabilityKind::Command).writes_repository()),
679            Effect::RepositoryMutation
680        );
681    }
682
683    #[test]
684    fn a_secret_is_never_suggestible() {
685        assert!(!ValueSource::Secret.suggestible());
686        assert!(!ValueSource::Free.suggestible());
687        assert!(ValueSource::Branch.suggestible());
688    }
689
690    #[test]
691    fn an_invocation_is_rendered_from_the_execution() {
692        let e = Execution {
693            origin: Origin::Executable,
694            argv: vec!["worktree".into(), "status".into()],
695        };
696        assert_eq!(e.invocation(), "majordomus worktree status");
697    }
698}