Skip to main content

majordomus_cli/command_graph/
workflow.rs

1//! The repository's own workflows as command-graph nodes.
2//!
3//! A workflow is a recipe the repository declares for a person to run and that no program
4//! here implements — building the site, running the suite, publishing. The runner already
5//! describes them: [`crate::environment::workflows`] reads `just --dump --dump-format
6//! json` and turns it into a typed catalogue, and this is a second reading of that
7//! catalogue, not a second parser.
8//!
9//! # The bridge is not a workflow
10//!
11//! Once the graph projects the two programs' commands into recipes, the runner reports
12//! those recipes too — and reading them back as workflows would produce a node whose
13//! execution is a recipe whose execution is that node. That is the cycle this design
14//! exists to forbid, and it is closed by construction: [`contribute`] is given the set of
15//! names the graph projects, and a recipe with such a name is the graph's own bridge and
16//! is not a declaration of anything.
17//!
18//! # What a workflow's effect is
19//!
20//! It is not inferred from the name, the group or the body. A recipe is arbitrary shell,
21//! and a guess about what arbitrary shell does is exactly the kind of thing that is right
22//! until the day it matters. A recipe the runner marks as needing confirmation is
23//! destructive, because that is what the marker means; every other workflow is read as a
24//! repository mutation, which is the strongest ordinary class and therefore the reading
25//! that keeps an unclassified workflow off every machine surface.
26
27use std::collections::BTreeSet;
28
29use crate::environment::{WorkflowCatalogue, WorkflowDescriptor};
30
31use super::model::{
32    ArgumentSpec, Availability, CommandId, CommandNode, Diagnostic, Effect, Execution,
33    Interactivity, Origin, Projections, Provenance, Requirement, Secrecy, ValueSource,
34};
35
36/// The file a projection shows as the declaration of a workflow.
37pub const DECLARATION: &str = "justfile";
38
39/// What the read produced.
40pub struct Contribution {
41    /// The nodes, in the runner's order.
42    pub nodes: Vec<CommandNode>,
43    /// What the read found.
44    pub diagnostics: Vec<Diagnostic>,
45}
46
47/// Read the declared workflows, excluding the recipes the graph itself projects.
48///
49/// `bridged` is the set of recipe names the graph's own workflow projection emits. It is
50/// computed from the graph, never read back from the runner, so the exclusion cannot drift
51/// from what was generated.
52pub fn contribute(catalogue: &WorkflowCatalogue, bridged: &BTreeSet<String>) -> Contribution {
53    let mut nodes = Vec::new();
54    let mut bridge_names = Vec::new();
55
56    for descriptor in &catalogue.workflows {
57        if bridged.contains(&descriptor.name) {
58            bridge_names.push(descriptor.name.clone());
59            continue;
60        }
61        nodes.push(node(descriptor, catalogue));
62    }
63
64    let mut diagnostics = Vec::new();
65    if !bridge_names.is_empty() {
66        diagnostics.push(Diagnostic {
67            severity: super::model::Severity::Info,
68            code: "workflow-bridge-recognised".into(),
69            message: format!(
70                "{} recipe(s) are this graph's own bridge and are not read as declarations: {}",
71                bridge_names.len(),
72                bridge_names.join(", ")
73            ),
74            commands: Vec::new(),
75            remedy: None,
76        });
77    }
78    Contribution { nodes, diagnostics }
79}
80
81/// One node from one recipe.
82fn node(descriptor: &WorkflowDescriptor, catalogue: &WorkflowCatalogue) -> CommandNode {
83    let path = vec![descriptor.name.clone()];
84    let effect = if descriptor.confirm {
85        Effect::Destructive
86    } else {
87        Effect::RepositoryMutation
88    };
89
90    let arguments = descriptor
91        .parameters
92        .iter()
93        .map(|p| ArgumentSpec {
94            name: p.name.clone(),
95            long: None,
96            short: None,
97            positional: true,
98            takes_value: true,
99            required: p.required,
100            variadic: p.variadic,
101            global: false,
102            help: String::new(),
103            value_name: Some(p.name.to_uppercase()),
104            values: Vec::new(),
105            defaults: Vec::new(),
106            // The runner declares a parameter's name and whether it is variadic, and
107            // nothing about what its values are. Nothing here invents one.
108            source: ValueSource::Free,
109            secrecy: Secrecy::Public,
110        })
111        .collect();
112
113    let entrypoint = catalogue
114        .entrypoints
115        .iter()
116        .any(|e| e.workflow == descriptor.name);
117
118    CommandNode {
119        id: CommandId::derive(Origin::Workflow, &path),
120        origin: Origin::Workflow,
121        invocation: format!("just {}", descriptor.name),
122        summary: descriptor.description.clone().unwrap_or_default(),
123        description: None,
124        runnable: true,
125        arguments,
126        execution: Execution {
127            origin: Origin::Workflow,
128            argv: path.clone(),
129        },
130        effect,
131        // A recipe runs in the caller's terminal and returns; the runner has no notion of
132        // a service, and a recipe that starts one is a recipe that does not return, which
133        // no surface here can discover from the dump.
134        interactivity: Interactivity::NonInteractive,
135        stability: crate::capability::Stability::Implemented,
136        availability: Availability {
137            available: true,
138            reason: None,
139            requires: vec![Requirement::Repository, Requirement::WorkflowRunner],
140        },
141        group: descriptor.group.clone(),
142        tags: Vec::new(),
143        aliases: Vec::new(),
144        deprecation: None,
145        provenance: Provenance {
146            declared_in: DECLARATION.into(),
147            read_by: Some(crate::environment::workflows::SOURCE.into()),
148            capability: None,
149        },
150        projections: Projections::default(),
151        entrypoint: Some(entrypoint),
152        path,
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::environment::{WorkflowEntrypoint, WorkflowParameter};
160
161    fn catalogue(names: &[&str]) -> WorkflowCatalogue {
162        WorkflowCatalogue {
163            state: crate::environment::TierState::Resolved,
164            source: Some(crate::environment::workflows::SOURCE.into()),
165            workflows: names
166                .iter()
167                .map(|n| WorkflowDescriptor {
168                    name: (*n).into(),
169                    namespace: None,
170                    description: Some(format!("does {n}")),
171                    group: Some("site".into()),
172                    parameters: vec![WorkflowParameter {
173                        name: "args".into(),
174                        variadic: true,
175                        required: false,
176                    }],
177                    dependencies: Vec::new(),
178                    confirm: *n == "clean",
179                })
180                .collect(),
181            entrypoints: vec![WorkflowEntrypoint {
182                group: "site".into(),
183                workflow: "site-build".into(),
184                command: "just site-build".into(),
185                description: None,
186            }],
187        }
188    }
189
190    #[test]
191    fn a_bridged_recipe_is_not_read_back_as_a_declaration() {
192        let cat = catalogue(&["site-build", "worktree-status"]);
193        let bridged = BTreeSet::from(["worktree-status".to_string()]);
194        let c = contribute(&cat, &bridged);
195        assert_eq!(c.nodes.len(), 1);
196        assert_eq!(c.nodes[0].id.as_str(), "workflow.site-build");
197        assert!(c
198            .diagnostics
199            .iter()
200            .any(|d| d.code == "workflow-bridge-recognised"));
201    }
202
203    #[test]
204    fn a_confirmed_recipe_is_destructive_and_every_other_is_a_repository_mutation() {
205        let cat = catalogue(&["clean", "site-build"]);
206        let c = contribute(&cat, &BTreeSet::new());
207        let clean = c.nodes.iter().find(|n| n.path[0] == "clean").unwrap();
208        let build = c.nodes.iter().find(|n| n.path[0] == "site-build").unwrap();
209        assert_eq!(clean.effect, Effect::Destructive);
210        assert_eq!(build.effect, Effect::RepositoryMutation);
211    }
212
213    #[test]
214    fn the_runners_entrypoints_reach_the_nodes() {
215        let cat = catalogue(&["site-build", "clean"]);
216        let c = contribute(&cat, &BTreeSet::new());
217        let build = c.nodes.iter().find(|n| n.path[0] == "site-build").unwrap();
218        assert_eq!(build.entrypoint, Some(true));
219        let clean = c.nodes.iter().find(|n| n.path[0] == "clean").unwrap();
220        assert_eq!(clean.entrypoint, Some(false));
221    }
222
223    #[test]
224    fn a_workflow_never_projects_into_a_machine_surface() {
225        let cat = catalogue(&["site-build"]);
226        let c = contribute(&cat, &BTreeSet::new());
227        let (why, ok) = super::super::policy::machine_verdict(&c.nodes[0]);
228        assert!(!ok, "an unclassified workflow must not reach a machine");
229        assert!(why.is_some());
230    }
231}