majordomus_cli/environment/
workflows.rs1use std::path::Path;
25use std::process::Command;
26use std::time::Duration;
27
28use serde_json::Value;
29
30use super::{
31 TierState, WorkflowCatalogue, WorkflowDescriptor, WorkflowEntrypoint, WorkflowParameter,
32};
33
34pub const SOURCE: &str = "just --dump --dump-format json";
36
37pub const GROUP_SOURCE: &str = "just --groups --unsorted";
40
41pub const TIMEOUT: Duration = Duration::from_secs(2);
44
45pub fn resolve(root: &Path) -> WorkflowCatalogue {
51 let Some(dump) = run(root, &["--dump", "--dump-format", "json"]) else {
52 return WorkflowCatalogue::unavailable();
53 };
54 let Ok(document): Result<Value, _> = serde_json::from_str(&dump) else {
55 return WorkflowCatalogue::unavailable();
56 };
57 let workflows = workflows_of(&document);
58 let groups = run(root, &["--groups", "--unsorted"])
59 .map(|text| group_order(&text))
60 .unwrap_or_default();
61 let entrypoints = entrypoints(&workflows, &groups);
62 WorkflowCatalogue {
63 state: TierState::Resolved,
64 source: Some(SOURCE.into()),
65 workflows,
66 entrypoints,
67 }
68}
69
70pub fn workflows_of(document: &Value) -> Vec<WorkflowDescriptor> {
92 let mut out = Vec::new();
93 collect(document, None, &mut out);
94 crate::order::canonical(&mut out);
95 out
96}
97
98fn collect(document: &Value, namespace: Option<&str>, out: &mut Vec<WorkflowDescriptor>) {
99 for (name, recipe) in document
100 .get("recipes")
101 .and_then(Value::as_object)
102 .into_iter()
103 .flatten()
104 {
105 let private =
110 recipe.get("private").and_then(Value::as_bool) == Some(true) || name.starts_with('_');
111 if private {
112 continue;
113 }
114 let attributes = recipe.get("attributes").and_then(Value::as_array);
115 out.push(WorkflowDescriptor {
116 name: match namespace {
117 Some(module) => format!("{module} {name}"),
118 None => name.clone(),
119 },
120 namespace: namespace.map(str::to_string),
121 description: recipe
122 .get("doc")
123 .and_then(Value::as_str)
124 .map(str::to_string),
125 group: attributes.and_then(|a| attribute(a, "group")),
126 parameters: recipe
127 .get("parameters")
128 .and_then(Value::as_array)
129 .map(|params| params.iter().map(parameter).collect())
130 .unwrap_or_default(),
131 dependencies: recipe
132 .get("dependencies")
133 .and_then(Value::as_array)
134 .map(|deps| {
135 deps.iter()
136 .filter_map(|d| d.get("recipe").and_then(Value::as_str))
137 .map(str::to_string)
138 .collect()
139 })
140 .unwrap_or_default(),
141 confirm: attributes.is_some_and(|a| attribute(a, "confirm").is_some()),
142 });
143 }
144 for (module, sub) in document
145 .get("modules")
146 .and_then(Value::as_object)
147 .into_iter()
148 .flatten()
149 {
150 collect(sub, Some(module), out);
151 }
152}
153
154fn attribute(attributes: &[Value], name: &str) -> Option<String> {
157 attributes.iter().find_map(|a| match a {
158 Value::String(s) if s == name => Some(String::new()),
159 Value::Object(m) => m.get(name).map(|v| match v {
160 Value::String(s) => s.clone(),
161 other => other.to_string(),
162 }),
163 _ => None,
164 })
165}
166
167fn parameter(value: &Value) -> WorkflowParameter {
168 let kind = value
170 .get("kind")
171 .and_then(Value::as_str)
172 .unwrap_or("singular");
173 let has_default = value
174 .get("default")
175 .is_some_and(|d| !matches!(d, Value::Null));
176 WorkflowParameter {
177 name: value
178 .get("name")
179 .and_then(Value::as_str)
180 .unwrap_or_default()
181 .to_string(),
182 variadic: matches!(kind, "star" | "plus"),
183 required: !has_default && !matches!(kind, "star"),
184 }
185}
186
187pub fn group_order(text: &str) -> Vec<String> {
195 text.lines()
196 .skip_while(|l| !l.trim_end().ends_with(':'))
197 .skip(1)
198 .map(str::trim)
199 .filter(|l| !l.is_empty())
200 .map(str::to_string)
201 .collect()
202}
203
204pub fn entrypoints(workflows: &[WorkflowDescriptor], order: &[String]) -> Vec<WorkflowEntrypoint> {
225 let mut groups: Vec<String> = order.to_vec();
226 let mut rest: Vec<String> = workflows
227 .iter()
228 .filter_map(|w| w.group.clone())
229 .filter(|g| !groups.contains(g))
230 .collect();
231 crate::order::canonical(&mut rest);
232 rest.dedup();
233 groups.extend(rest);
234
235 groups
236 .into_iter()
237 .filter_map(|group| {
238 let workflow = workflows
239 .iter()
240 .find(|w| w.group.as_deref() == Some(group.as_str()) && w.name == group)?;
241 Some(WorkflowEntrypoint {
242 command: format!("just {}", workflow.name),
243 workflow: workflow.name.clone(),
244 description: workflow.description.clone(),
245 group,
246 })
247 })
248 .collect()
249}
250
251pub(crate) fn groups(root: &Path) -> Option<Vec<String>> {
254 run(root, &["--groups", "--unsorted"]).map(|text| group_order(&text))
255}
256
257fn run(root: &Path, args: &[&str]) -> Option<String> {
258 let out = super::probe::bounded_output(
259 Command::new("just")
260 .arg("--justfile")
261 .arg(root.join("justfile"))
262 .args(args),
263 TIMEOUT,
264 )?;
265 out.status
266 .success()
267 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn document() -> Value {
275 serde_json::json!({
276 "modules": {
277 "site": { "modules": {}, "recipes": {
278 "build": { "name": "build", "doc": "Build the site.", "private": false,
279 "attributes": [], "parameters": [], "dependencies": [] }
280 }}
281 },
282 "recipes": {
283 "test": { "name": "test", "doc": "Every gate.", "private": false,
284 "attributes": [{"group": "test"}], "parameters": [],
285 "dependencies": [{"recipe": "build"}] },
286 "clean": { "name": "clean", "doc": "Remove it.", "private": false,
287 "attributes": [{"confirm": "Sure? [y/N]"}, {"group": "build"}],
288 "parameters": [], "dependencies": [] },
289 "build": { "name": "build", "doc": null, "private": false,
290 "attributes": [{"group": "build"}],
291 "parameters": [{"name": "args", "kind": "star", "default": null}],
292 "dependencies": [] },
293 "default": { "name": "default", "doc": null, "private": true,
294 "attributes": [], "parameters": [], "dependencies": [] }
295 }
296 })
297 }
298
299 #[test]
300 fn a_private_recipe_is_not_a_workflow() {
301 let found = workflows_of(&document());
302 assert!(!found.iter().any(|w| w.name == "default"));
303 }
304
305 #[test]
306 fn recipes_come_back_in_a_stable_order_whatever_the_runner_emitted() {
307 let found = workflows_of(&document());
308 let names: Vec<&str> = found.iter().map(|w| w.name.as_str()).collect();
309 assert_eq!(names, ["build", "clean", "site build", "test"]);
310 }
311
312 #[test]
313 fn a_modules_recipe_carries_its_module_in_its_name() {
314 let found = workflows_of(&document());
315 let module_recipe = found
316 .iter()
317 .find(|w| w.namespace.as_deref() == Some("site"))
318 .expect("the module's recipe");
319 assert_eq!(module_recipe.name, "site build");
320 assert_eq!(
321 module_recipe.description.as_deref(),
322 Some("Build the site.")
323 );
324 }
325
326 #[test]
327 fn attributes_become_the_facts_a_reader_needs() {
328 let found = workflows_of(&document());
329 let clean = found.iter().find(|w| w.name == "clean").expect("clean");
330 assert!(clean.confirm, "a recipe that asks first says so");
331 assert_eq!(clean.group.as_deref(), Some("build"));
332 let build = found.iter().find(|w| w.name == "build").expect("build");
333 assert!(!build.confirm);
334 assert_eq!(build.parameters[0].name, "args");
335 assert!(build.parameters[0].variadic);
336 assert!(
337 !build.parameters[0].required,
338 "a star parameter is optional"
339 );
340 }
341
342 #[test]
346 fn the_entrypoint_of_a_group_is_the_recipe_named_after_it() {
347 let workflows = workflows_of(&document());
348 let found = entrypoints(&workflows, &["test".into(), "build".into()]);
349 let commands: Vec<&str> = found.iter().map(|e| e.command.as_str()).collect();
350 assert_eq!(
351 commands,
352 ["just test", "just build"],
353 "declaration order wins"
354 );
355 }
356
357 #[test]
358 fn a_group_order_that_could_not_be_read_still_answers_deterministically() {
359 let workflows = workflows_of(&document());
360 let found = entrypoints(&workflows, &[]);
361 let commands: Vec<&str> = found.iter().map(|e| e.command.as_str()).collect();
362 assert_eq!(commands, ["just build", "just test"], "then by group name");
363 }
364
365 #[test]
366 fn a_repository_without_a_workflow_runner_is_not_an_error() {
367 let dir = tempfile::tempdir().expect("a temporary directory");
368 let catalogue = resolve(dir.path());
369 assert_eq!(catalogue.state, TierState::Unavailable);
370 assert!(catalogue.workflows.is_empty());
371 assert!(catalogue.entrypoints.is_empty());
372 }
373
374 #[test]
375 fn the_group_listing_header_is_not_read_as_a_group() {
376 assert_eq!(group_order("Recipe groups:\n a\n b\n"), ["a", "b"]);
377 assert_eq!(group_order(""), Vec::<String>::new());
378 }
379}