Skip to main content

majordomus_cli/environment/
workflows.rs

1//! Workflows: the recipes a person can run here, read from the workflow runner's own
2//! structured description of them.
3//!
4//! `just --dump --dump-format json` already answers every question worth asking about a
5//! recipe — its name, its doc comment, its `[group(...)]`, its parameters, its
6//! dependencies, whether it is private, whether it asks before running. So nothing here
7//! parses a `justfile`. A second parser for a file its own tool can describe is a second
8//! source of truth that goes stale the first time the syntax grows.
9//!
10//! # The recommended commands
11//!
12//! The banner shows a handful of commands, and the one thing it must not do is carry a
13//! list of them: a hard-coded `just test` outlives the recipe it names. The rule instead
14//! is one sentence, and the justfile is what decides its outcome:
15//!
16//! > **The entry point of a group is the public recipe whose name is the group's name.**
17//!
18//! A group without such a recipe has no entry point and contributes nothing. Groups are
19//! offered in the order the justfile declares them, so moving a group up the file moves
20//! its command up the banner, and naming a recipe after its group is what publishes it.
21//! There is no metadata to invent, no annotation to remember, and no configuration file
22//! that can disagree with the justfile.
23
24use 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
34/// The command whose output is read. Named here so the snapshot's provenance can quote it.
35pub const SOURCE: &str = "just --dump --dump-format json";
36
37/// The command that orders the groups. `--dump` sorts recipes by name and reports no group
38/// order, so declaration order — the only order a person recognises — comes from here.
39pub const GROUP_SOURCE: &str = "just --groups --unsorted";
40
41/// How long the runner may take. It reads one file and prints a document; a second is
42/// already pathological, and a snapshot is better without it than late.
43pub const TIMEOUT: Duration = Duration::from_secs(2);
44
45/// Read the catalogue by asking `just` about the repository at `root`.
46///
47/// Returns [`WorkflowCatalogue::unavailable`] when `just` is not installed, when the
48/// repository has no justfile, or when the runner refuses it — none of which is an error
49/// here: a repository is not obliged to have a workflow runner.
50pub 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
70/// Every public workflow of a `just --dump --dump-format json` document, sorted by name.
71///
72/// Recipes of imported modules are included under their module's name, which is how a
73/// modular justfile stays one catalogue rather than becoming several.
74///
75/// ```
76/// use majordomus_cli::environment::workflows::workflows_of;
77/// let document = serde_json::json!({
78///     "recipes": {
79///         "build": { "name": "build", "doc": "Build it.", "private": false,
80///                    "attributes": [{"group": "build"}], "parameters": [], "dependencies": [] },
81///         "_hidden": { "name": "_hidden", "doc": null, "private": true,
82///                      "attributes": [], "parameters": [], "dependencies": [] }
83///     },
84///     "modules": {}
85/// });
86/// let found = workflows_of(&document);
87/// assert_eq!(found.len(), 1, "a private recipe is not a workflow");
88/// assert_eq!(found[0].name, "build");
89/// assert_eq!(found[0].group.as_deref(), Some("build"));
90/// ```
91pub 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        // `just` marks a recipe private two ways: the `[private]` attribute, which it
106        // reports as `private`, and a leading underscore, which it does not. Both mean
107        // "not for a person to run", and a catalogue that showed the second would offer
108        // commands the runner hides from `--list`.
109        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
154/// The value of a named attribute. `just` reports an attribute either as a bare string
155/// (`"private"`) or as a single-member object (`{"group": "test"}`).
156fn 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    // `kind` is `singular`, `plus` (one or more) or `star` (any number).
169    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
187/// The groups in the order `just --groups --unsorted` printed them.
188///
189/// ```
190/// use majordomus_cli::environment::workflows::group_order;
191/// let text = "Recipe groups:\n    build\n    serve\n    test\n";
192/// assert_eq!(group_order(text), ["build", "serve", "test"]);
193/// ```
194pub 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
204/// The entry point of each group: the public recipe whose name is the group's name.
205///
206/// Groups are offered in `order`; a group with no such recipe contributes nothing, and a
207/// group not in `order` follows the ones that are, by name, so a runner that could not be
208/// asked for the declaration order still produces a deterministic answer.
209///
210/// ```
211/// use majordomus_cli::environment::workflows::{entrypoints, workflows_of};
212/// let document = serde_json::json!({ "modules": {}, "recipes": {
213///     "check":  { "name": "check",  "doc": "Every gate.", "private": false,
214///                 "attributes": [{"group": "check"}], "parameters": [], "dependencies": [] },
215///     "check-fast": { "name": "check-fast", "doc": null, "private": false,
216///                 "attributes": [{"group": "check"}], "parameters": [], "dependencies": [] },
217///     "site-build": { "name": "site-build", "doc": null, "private": false,
218///                 "attributes": [{"group": "site"}], "parameters": [], "dependencies": [] }
219/// }});
220/// let found = entrypoints(&workflows_of(&document), &["check".into(), "site".into()]);
221/// assert_eq!(found.len(), 1, "site has no recipe named `site`, so it offers none");
222/// assert_eq!(found[0].command, "just check");
223/// ```
224pub 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
251/// The groups of the justfile at `root`, in the order it declares them, or `None` when the
252/// runner could not be asked.
253pub(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    /// The rule the banner's commands come from, stated as an assertion: a group offers
343    /// the recipe named after it, in the order the justfile declares its groups, and a
344    /// group with no such recipe offers nothing rather than something arbitrary.
345    #[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}