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}