Skip to main content

majordomus_cli/command_graph/
policy.rs

1//! The exposure policy: which surfaces carry a command, derived from what the command is.
2//!
3//! Nothing here reads a per-command configuration, because there is none. A surface is not
4//! a property a declaration sets; it is a consequence of the effect, the interactivity and
5//! the origin — and those are already on the node. The whole policy is the two functions
6//! below, so the question "why is this not an MCP tool?" has one answer in one place, and
7//! `commands explain` can quote it.
8//!
9//! The rules, in one table:
10//!
11//! | effect / interactivity | command line | workflow bridge | MCP · HTTP | Cockpit |
12//! |---|---|---|---|---|
13//! | read-only, non-interactive | yes | yes | yes, when a capability backs it | yes |
14//! | local mutation | yes | yes | yes, when a capability backs it | yes, confirmed |
15//! | repository / network mutation | yes | yes | no | no |
16//! | destructive | yes | yes | no | no |
17//! | interactive | yes | yes | no | no |
18//! | long-running | yes | yes | no | no |
19//!
20//! The command line and the workflow runner carry everything: a person at a terminal is
21//! the caller both were built for, and withholding a command from the terminal that
22//! implements it would be theatre. Every machine surface stops at local mutation, and the
23//! two that execute — MCP and HTTP — additionally require a capability behind the command,
24//! because a surface that executes must have a typed input schema to execute *from*.
25
26use super::model::{CommandNode, Effect, Interactivity, Origin, Projections, Provenance, Surface};
27
28/// Where the architecture of the command graph is documented; the page a command without
29/// one of its own points at.
30pub const DOCS_PREFIX: &str = "/docs/commands";
31
32/// The strongest effect a machine surface will carry.
33pub const MACHINE_CEILING: Effect = Effect::LocalMutation;
34
35/// The route of a command's page.
36///
37/// Not a new page tree. Each program's commands already have a reference on this site, and
38/// the rule here is only which one: the executable's command line under `/docs/cli/`, the
39/// shell tool's commands under `/commands/`, both generated from the same declarations this
40/// graph reads. A recipe has no page of its own and points at the architecture, rather than
41/// at a page that does not exist.
42pub fn docs_route(node: &CommandNode) -> String {
43    match node.origin {
44        Origin::Executable => format!("/docs/cli/{}/", node.path.join("/")),
45        Origin::Tool => format!("/commands/{}/", node.path.join("-")),
46        Origin::Workflow => format!("{DOCS_PREFIX}/"),
47    }
48}
49
50/// The workflow runner's spelling of a command path: the words joined by `-`.
51///
52/// `majordomus worktree status` becomes `worktree-status`. The rule is an algorithm and
53/// not a table, so a command added tomorrow has a recipe name today.
54pub fn workflow_name(path: &[String]) -> String {
55    path.join("-")
56}
57
58/// Words the workflow runner cannot accept as a recipe name, or that would read as one of
59/// its own options.
60///
61/// A projection that would produce one of these is qualified rather than emitted, and the
62/// graph says so. The list is a property of the runner's grammar, which is a contract, not
63/// a fact discoverable from this repository.
64pub const RESERVED_WORKFLOW_NAMES: &[&str] = &["default", "list", "help", "choose", "init"];
65
66/// Decide every projection of one node.
67///
68/// `capability` carries the MCP tool name and HTTP route the registry already declared for
69/// the capability behind this command, when there is one; the policy decides whether they
70/// may be *used*, never what they are called.
71pub fn project(
72    node: &CommandNode,
73    capability: Option<(&Option<String>, &Option<String>)>,
74) -> Projections {
75    let mut out = Projections {
76        docs: docs_route(node),
77        ..Projections::default()
78    };
79
80    if node.runnable {
81        out.cli = Some(node.invocation.clone());
82    } else {
83        // A node that only groups others still has a page and a completion, and it is
84        // still what a person types on the way to a leaf.
85        out.cli = Some(node.invocation.clone());
86    }
87
88    // The workflow runner carries the two programs' commands as a bridge. A workflow is
89    // already a recipe; projecting it back into one would be the cycle this design exists
90    // to forbid.
91    if node.runnable && node.origin != Origin::Workflow {
92        let name = workflow_name(&node.path);
93        if !RESERVED_WORKFLOW_NAMES.contains(&name.as_str()) {
94            out.workflow = Some(name);
95        }
96    }
97    if node.origin == Origin::Workflow {
98        out.workflow = Some(node.path.join("-"));
99    }
100
101    let (mut withheld, machine_ok) = machine_verdict(node);
102    if machine_ok {
103        if let Some((mcp, http)) = capability {
104            out.mcp.clone_from(mcp);
105            out.http.clone_from(http);
106        }
107        // The Cockpit renders any command a machine surface may execute; the capability
108        // page is where a generated form for it already lives.
109        if out.http.is_some() {
110            out.cockpit = Some("/cockpit/commands".into());
111        }
112        // A command the policy admits and no machine surface carries is withheld for the
113        // one remaining reason, and saying so is the point: a surface that executes needs
114        // a typed input schema to execute from, and only a capability supplies one. Left
115        // unsaid, this is the case that reads as an unexplained gap — the policy allowed
116        // it, and it is absent anyway.
117        if withheld.is_none() && out.mcp.is_none() && out.http.is_none() {
118            withheld = Some(
119                "no capability declares this command line, so no machine surface has a typed \
120                 input schema to execute from"
121                    .into(),
122            );
123        }
124    }
125    out.withheld = withheld;
126    out
127}
128
129/// Whether a machine surface may carry this command, and the reason when it may not.
130///
131/// The reason is the product, not the boolean: it is what `commands explain`, the Cockpit's
132/// disabled state and the completion's filtering all show, and it is written once here.
133pub fn machine_verdict(node: &CommandNode) -> (Option<String>, bool) {
134    match node.interactivity {
135        Interactivity::Interactive => (
136            Some("asks the person something; a request/response surface would hang".into()),
137            false,
138        ),
139        Interactivity::LongRunning => (
140            Some("serves until it is stopped; a request/response surface cannot carry it".into()),
141            false,
142        ),
143        Interactivity::NonInteractive => {
144            if node.effect > MACHINE_CEILING {
145                (
146                    Some(format!(
147                        "effect {:?} is above the machine ceiling {:?}",
148                        node.effect, MACHINE_CEILING
149                    )),
150                    false,
151                )
152            } else if !node.runnable {
153                (
154                    Some("groups other commands; nothing to execute".into()),
155                    false,
156                )
157            } else {
158                (None, true)
159            }
160        }
161    }
162}
163
164/// Whether a projection on this surface should offer the command at all.
165///
166/// A completion asks this before it offers a candidate, so an unavailable command is not
167/// suggested prominently — and the reason it carries is what a shell that can show one
168/// displays.
169pub fn offered_on(node: &CommandNode, surface: Surface) -> bool {
170    match surface {
171        Surface::Cli => true,
172        Surface::Workflow => node.projections.workflow.is_some(),
173        Surface::Mcp => node.projections.mcp.is_some(),
174        Surface::Http => node.projections.http.is_some(),
175    }
176}
177
178/// The provenance of a node whose declaration is one file.
179pub fn declared_in(path: &str) -> Provenance {
180    Provenance {
181        declared_in: path.into(),
182        read_by: None,
183        capability: None,
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::capability::Stability;
191    use crate::command_graph::model::{Availability, CommandId, Execution};
192
193    fn node(effect: Effect, interactivity: Interactivity, runnable: bool) -> CommandNode {
194        let path = vec!["worktree".to_string(), "status".to_string()];
195        CommandNode {
196            id: CommandId::derive(Origin::Executable, &path),
197            origin: Origin::Executable,
198            invocation: "majordomus worktree status".into(),
199            summary: "s".into(),
200            description: None,
201            runnable,
202            arguments: Vec::new(),
203            execution: Execution {
204                origin: Origin::Executable,
205                argv: path.clone(),
206            },
207            effect,
208            interactivity,
209            stability: Stability::Implemented,
210            availability: Availability::always(),
211            group: None,
212            tags: Vec::new(),
213            aliases: Vec::new(),
214            deprecation: None,
215            provenance: declared_in("apps/majordomus-cli/src/cli.rs"),
216            projections: Projections::default(),
217            entrypoint: None,
218            path,
219        }
220    }
221
222    #[test]
223    fn read_only_reaches_every_surface() {
224        let n = node(Effect::ReadOnly, Interactivity::NonInteractive, true);
225        let mcp = Some("majordomus_worktree_status".to_string());
226        let http = Some("/api/v1/worktree".to_string());
227        let p = project(&n, Some((&mcp, &http)));
228        assert_eq!(p.cli.as_deref(), Some("majordomus worktree status"));
229        assert_eq!(p.workflow.as_deref(), Some("worktree-status"));
230        assert_eq!(p.mcp, mcp);
231        assert_eq!(p.http, http);
232        assert!(p.withheld.is_none());
233        assert_eq!(p.docs, "/docs/cli/worktree/status/");
234    }
235
236    #[test]
237    fn a_repository_mutation_is_withheld_from_machines_with_a_reason() {
238        let n = node(
239            Effect::RepositoryMutation,
240            Interactivity::NonInteractive,
241            true,
242        );
243        let mcp = Some("t".to_string());
244        let http = Some("/api/v1/x".to_string());
245        let p = project(&n, Some((&mcp, &http)));
246        assert!(p.mcp.is_none() && p.http.is_none());
247        assert!(p.withheld.is_some());
248        // and the terminal still has it
249        assert!(p.cli.is_some() && p.workflow.is_some());
250    }
251
252    #[test]
253    fn a_server_is_never_a_machine_tool() {
254        let n = node(Effect::ReadOnly, Interactivity::LongRunning, true);
255        let (why, ok) = machine_verdict(&n);
256        assert!(!ok);
257        assert!(why.unwrap().contains("stopped"));
258    }
259
260    #[test]
261    fn a_workflow_is_never_bridged_back_into_a_workflow() {
262        let mut n = node(Effect::ReadOnly, Interactivity::NonInteractive, true);
263        n.origin = Origin::Workflow;
264        n.path = vec!["site-build".into()];
265        n.execution.origin = Origin::Workflow;
266        let p = project(&n, None);
267        assert_eq!(p.workflow.as_deref(), Some("site-build"));
268    }
269}