majordomus_cli/command_graph/
workflow.rs1use 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
36pub const DECLARATION: &str = "justfile";
38
39pub struct Contribution {
41 pub nodes: Vec<CommandNode>,
43 pub diagnostics: Vec<Diagnostic>,
45}
46
47pub 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
81fn 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 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 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}