Skip to main content

majordomus_cli/command_graph/
bridge.rs

1//! The workflow bridge: the command graph projected into recipes.
2//!
3//! `just` is an ergonomic front door, not a place where commands are declared. Every recipe
4//! this writes is one line that runs the canonical program with the caller's own arguments
5//! — never a second implementation, never a second description, and never a call back into
6//! `just`, which is what would make the bridge a cycle.
7//!
8//! ```text
9//!   CommandGraph ──► render ──► bridge.just ──► imported by the root justfile
10//!                       └─────► bridge.json  (what was written, and from which graph)
11//! ```
12//!
13//! # Where it lives, and why not in the tree
14//!
15//! Under `.ai/local/cache/`, which no commit carries. A generated file in the tree would
16//! have to be regenerated by whoever changes a command, checked by a gate, and merged by
17//! hand on every branch that touches the command line — for a file that is a pure function
18//! of the tree it sits in. The materialisation is cheap and fingerprinted, so the cost of
19//! not tracking it is one hash comparison per entry into the repository.
20//!
21//! # Argument forwarding
22//!
23//! Interpolating a recipe's arguments into the body — `{{args}}` — splices text into a
24//! shell command, so a path with a space arrives as two arguments and a quote arrives as a
25//! syntax error. The runner's own answer is `set positional-arguments`, which passes a
26//! recipe's arguments to the shell as `$1`, `$2`, …; the body then forwards `"$@"` and the
27//! shell's own quoting carries spaces, quotes, `$(…)`, newlines and leading dashes
28//! unaltered. Nothing here escapes anything, because nothing here interpolates anything.
29
30use std::collections::BTreeSet;
31use std::path::{Path, PathBuf};
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36use super::model::{CommandGraph, CommandNode, Origin};
37
38/// The directory the bridge is materialised in, relative to the repository root.
39pub const DIRECTORY: &str = ".ai/local/cache/command-graph";
40
41/// The file the root justfile imports.
42pub const BRIDGE: &str = "bridge.just";
43
44/// The record of what was written, and of the graph it was written from.
45pub const MANIFEST: &str = "bridge.json";
46
47/// The schema of that record.
48pub const MANIFEST_SCHEMA: &str = "majordomus/command-bridge/v1";
49
50/// The setting the root justfile must carry for the forwarding in every recipe to be safe.
51///
52/// Named here so that the rule which checks it and the generator that depends on it read
53/// the same constant.
54pub const REQUIRED_SETTING: &str = "set positional-arguments";
55
56/// What a materialisation wrote, and from what.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Manifest {
59    /// The schema of this record.
60    pub schema: String,
61    /// The fingerprint of the graph the bridge was written from.
62    pub fingerprint: String,
63    /// Every recipe name the bridge defines, sorted. Read back on the next build so that a
64    /// recipe of this bridge is not mistaken for a declared workflow.
65    pub recipes: Vec<String>,
66    #[serde(default)]
67    /// A stamp over the files the bridge is derived from, and the executable that derived
68    /// it. Entering the repository compares this and does nothing when it matches, which
69    /// is what keeps `cd` at a few milliseconds: the graph is only built when one of the
70    /// declarations behind it has actually changed.
71    pub stamp: String,
72}
73
74/// What happened.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Outcome {
77    /// Where the bridge is.
78    pub path: PathBuf,
79    /// Whether anything was written; false when the fingerprint already matched.
80    pub written: bool,
81    /// How many recipes it defines.
82    pub recipes: usize,
83    /// The fingerprint of the graph it was written from.
84    pub fingerprint: String,
85}
86
87/// The recipe names this graph projects.
88///
89/// The materialiser writes them and the next build reads them back through the manifest;
90/// they are computed here so that the two cannot disagree.
91pub fn recipe_names(graph: &CommandGraph) -> BTreeSet<String> {
92    let mut out: BTreeSet<String> = graph
93        .commands
94        .iter()
95        .filter(|c| c.origin != Origin::Workflow)
96        .filter_map(|c| c.projections.workflow.clone())
97        .collect();
98    for alias in super::semantics::WORKFLOW_ALIASES {
99        if graph
100            .commands
101            .iter()
102            .any(|c| c.id.as_str() == alias.command)
103        {
104            out.insert(alias.alias.to_string());
105        }
106    }
107    out
108}
109
110/// The files the bridge is derived from.
111///
112/// The declarations, not their contents' meaning: the workflow runner's files, the shell
113/// tool's command registry, and the executable itself, whose clap tree is the third
114/// declaration and changes when it is rebuilt. A change anywhere here can change the
115/// bridge; a change nowhere here cannot.
116pub fn inputs(root: &Path) -> Vec<PathBuf> {
117    let mut out = vec![root.join("justfile"), root.join("share/commands.yaml")];
118    if let Ok(entries) = std::fs::read_dir(root.join(".just")) {
119        let mut modules: Vec<PathBuf> = entries
120            .flatten()
121            .map(|e| e.path())
122            .filter(|p| p.extension().is_some_and(|e| e == "just"))
123            .collect();
124        modules.sort();
125        out.extend(modules);
126    }
127    if let Ok(exe) = std::env::current_exe() {
128        out.push(exe);
129    }
130    out
131}
132
133/// A stamp over those files: size and modification time, in a fixed order.
134///
135/// Not a hash of the contents. Reading every byte of a build artefact on every entry into
136/// the repository would cost more than the work it avoids, and the question being asked is
137/// only "has anything changed since I last looked", for which the filesystem's own answer
138/// is enough. A file that is absent stamps as absent, so adding one is a change.
139pub fn stamp(root: &Path) -> String {
140    use std::time::UNIX_EPOCH;
141    let mut hasher = Sha256::new();
142    hasher.update(MANIFEST_SCHEMA.as_bytes());
143    for path in inputs(root) {
144        hasher.update(path.to_string_lossy().as_bytes());
145        match std::fs::metadata(&path) {
146            Ok(meta) => {
147                hasher.update(meta.len().to_le_bytes());
148                let secs = meta
149                    .modified()
150                    .ok()
151                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
152                    .map(|d| d.as_nanos())
153                    .unwrap_or_default();
154                hasher.update(secs.to_le_bytes());
155            }
156            Err(_) => hasher.update(b"absent"),
157        }
158    }
159    format!("{:x}", hasher.finalize())[..16].to_string()
160}
161
162/// Is the materialised bridge derived from the declarations as they stand?
163///
164/// Cheap: a few `stat` calls and one hash. This is what entering the repository asks.
165pub fn is_current(root: &Path) -> bool {
166    manifest(root).is_some_and(|m| !m.stamp.is_empty() && m.stamp == stamp(root))
167        && root.join(DIRECTORY).join(BRIDGE).exists()
168}
169
170/// Read back what the last materialisation wrote, if anything.
171pub fn manifest(root: &Path) -> Option<Manifest> {
172    let text = std::fs::read_to_string(root.join(DIRECTORY).join(MANIFEST)).ok()?;
173    let manifest: Manifest = serde_json::from_str(&text).ok()?;
174    (manifest.schema == MANIFEST_SCHEMA).then_some(manifest)
175}
176
177/// The recipe names the last materialisation wrote; empty when there was none.
178pub fn bridged(root: &Path) -> BTreeSet<String> {
179    manifest(root)
180        .map(|m| m.recipes.into_iter().collect())
181        .unwrap_or_default()
182}
183
184/// Render the bridge.
185///
186/// Deterministic: the same graph renders the same bytes.
187pub fn render(graph: &CommandGraph) -> String {
188    let mut out = String::new();
189    out.push_str(&format!(
190        "# GENERATED FILE — DO NOT EDIT.\n\
191         #\n\
192         # The command graph, projected into recipes. Every line below is derived from a\n\
193         # declaration somewhere else: the clap tree of the Rust executable, the shipped\n\
194         # command registry of the shell tool, and the semantics beside each. Editing this\n\
195         # file changes nothing durable — the next entry into the repository overwrites it.\n\
196         #\n\
197         # graph:  {}\n\
198         # source: majordomus commands bridge\n\
199         # schema: {}\n\n",
200        graph.fingerprint,
201        super::model::SCHEMA
202    ));
203
204    out.push_str(
205        "# Both programs are reached through the launchers, never through a path into a build\n\
206         # directory: the launcher is what decides where the executable of this checkout is.\n",
207    );
208    out.push_str("majordomus_executable := justfile_directory() / \"bin/majordomus-cli\"\n");
209    out.push_str("majordomus_tool := justfile_directory() / \"bin/majordomus\"\n\n");
210
211    for node in graph
212        .commands
213        .iter()
214        .filter(|c| c.origin != Origin::Workflow)
215    {
216        let Some(name) = &node.projections.workflow else {
217            continue;
218        };
219        out.push_str(&recipe(node, name));
220    }
221
222    // The names the runner answered to before the bridge was derived. Each one runs the
223    // canonical program directly — never another recipe, which would be a bridge over a
224    // bridge — and says in its own description what it is an older spelling of.
225    let mut aliases: Vec<String> = Vec::new();
226    for alias in super::semantics::WORKFLOW_ALIASES {
227        let Some(node) = graph
228            .commands
229            .iter()
230            .find(|c| c.id.as_str() == alias.command)
231        else {
232            continue;
233        };
234        aliases.push(alias_recipe(node, alias));
235    }
236    if !aliases.is_empty() {
237        out.push_str("# The older spellings, kept so that nothing a person typed disappears.\n");
238        for a in aliases {
239            out.push_str(&a);
240        }
241    }
242    out
243}
244
245/// One compatibility alias.
246fn alias_recipe(node: &CommandNode, alias: &super::semantics::WorkflowAlias) -> String {
247    let program = match node.origin {
248        Origin::Tool => "{{majordomus_tool}}",
249        _ => "{{majordomus_executable}}",
250    };
251    let mut argv = node.execution.argv.clone();
252    argv.extend(alias.arguments.iter().map(|a| (*a).to_string()));
253    format!(
254        "# {} — {}\n[group('compatibility')]\n{} *args:\n    @\"{program}\" {} \"$@\"\n\n",
255        one_line(&node.summary),
256        one_line(alias.reason),
257        alias.alias,
258        argv.join(" ")
259    )
260}
261
262/// One recipe.
263fn recipe(node: &CommandNode, name: &str) -> String {
264    let program = match node.origin {
265        Origin::Tool => "{{majordomus_tool}}",
266        _ => "{{majordomus_executable}}",
267    };
268    let mut out = String::new();
269    // The doc comment `just --list` shows. One line, from the one summary; a second line
270    // would silently become the description, which is a defect this repository has met.
271    out.push_str(&format!("# {}\n", one_line(&node.summary)));
272    if let Some(group) = &node.group {
273        out.push_str(&format!("[group('{}')]\n", escape_group(group)));
274    }
275    // A destructive command asks before it runs, from the runner's own mechanism, because
276    // the effect said so — not because someone remembered to add the attribute.
277    if node.effect == super::model::Effect::Destructive {
278        out.push_str(&format!(
279            "[confirm(\"{} — run it? [y/N]\")]\n",
280            escape_double(&node.invocation)
281        ));
282    }
283    out.push_str(&format!("{name} *args:\n"));
284    out.push_str(&format!(
285        "    @\"{program}\" {} \"$@\"\n\n",
286        node.execution.argv.join(" ")
287    ));
288    out
289}
290
291/// One line, with anything that would break out of a comment removed.
292///
293/// A summary comes from a declaration in this repository, but the graph is a general
294/// mechanism and a workflow's description comes from a file a person edits. Control
295/// characters, newlines and escape sequences never reach a generated shell file.
296fn one_line(text: &str) -> String {
297    let first = text.lines().next().unwrap_or_default();
298    first
299        .chars()
300        .filter(|c| !c.is_control())
301        .collect::<String>()
302        .trim()
303        .to_string()
304}
305
306/// A group name, safe inside single quotes.
307fn escape_group(text: &str) -> String {
308    one_line(text).replace('\'', "")
309}
310
311/// Text safe inside a double-quoted runner string.
312fn escape_double(text: &str) -> String {
313    one_line(text).replace('\\', "").replace('"', "'")
314}
315
316/// Write the bridge and its manifest, atomically, and only when the graph changed.
317///
318/// Two shells entering the repository at once may both call this. Each renders to its own
319/// temporary file and renames it over the destination, which is atomic on the platforms
320/// this runs on, so a reader sees one complete file or the other and never a half-written
321/// one. No lock is taken: the two contents are identical whenever the two graphs are, and
322/// the loser of the race has written the same bytes as the winner.
323pub fn materialise(root: &Path, graph: &CommandGraph) -> std::io::Result<Outcome> {
324    let dir = root.join(DIRECTORY);
325    let path = dir.join(BRIDGE);
326    let names = recipe_names(graph);
327
328    if let Some(previous) = manifest(root) {
329        if previous.fingerprint == graph.fingerprint && path.exists() {
330            return Ok(Outcome {
331                path,
332                written: false,
333                recipes: previous.recipes.len(),
334                fingerprint: graph.fingerprint.clone(),
335            });
336        }
337    }
338
339    std::fs::create_dir_all(&dir)?;
340    write_atomic(&path, render(graph).as_bytes())?;
341    let manifest = Manifest {
342        schema: MANIFEST_SCHEMA.into(),
343        fingerprint: graph.fingerprint.clone(),
344        recipes: names.iter().cloned().collect(),
345        stamp: stamp(root),
346    };
347    write_atomic(
348        &dir.join(MANIFEST),
349        serde_json::to_string_pretty(&manifest)
350            .unwrap_or_default()
351            .as_bytes(),
352    )?;
353
354    Ok(Outcome {
355        path,
356        written: true,
357        recipes: names.len(),
358        fingerprint: graph.fingerprint.clone(),
359    })
360}
361
362/// Render beside the destination, then rename over it.
363fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
364    let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
365    std::fs::write(&tmp, bytes)?;
366    std::fs::rename(&tmp, path)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::command_graph::{build, Inputs};
373
374    #[test]
375    fn every_recipe_runs_the_canonical_program_and_forwards_safely() {
376        let graph = build(&Inputs::default());
377        let text = render(&graph);
378        assert!(text.contains("GENERATED FILE"));
379        assert!(text.contains(&graph.fingerprint));
380        for line in text.lines().filter(|l| l.trim_start().starts_with("@\"")) {
381            assert!(
382                line.ends_with("\"$@\""),
383                "a recipe forwards its arguments unquoted: {line}"
384            );
385            assert!(
386                !line.contains("{{args}}"),
387                "a recipe interpolates its arguments: {line}"
388            );
389            assert!(
390                !line.contains("just "),
391                "a recipe calls the runner back: {line}"
392            );
393        }
394    }
395
396    #[test]
397    fn the_names_it_writes_are_the_names_it_reports() {
398        let graph = build(&Inputs::default());
399        let text = render(&graph);
400        for name in recipe_names(&graph) {
401            assert!(
402                text.contains(&format!("\n{name} *args:")),
403                "{name} is reported and not written"
404            );
405        }
406    }
407
408    #[test]
409    fn a_destructive_command_asks_first() {
410        let graph = build(&Inputs::default());
411        let text = render(&graph);
412        let remove = graph
413            .commands
414            .iter()
415            .find(|c| c.id.as_str() == "executable.worktree.remove")
416            .expect("worktree remove is declared");
417        let name = remove.projections.workflow.clone().unwrap();
418        let block = text
419            .split("\n\n")
420            .find(|b| b.contains(&format!("\n{name} *args:")))
421            .expect("the recipe is written");
422        assert!(block.contains("[confirm("), "{block}");
423    }
424
425    #[test]
426    fn hostile_metadata_cannot_break_out_of_the_generated_file() {
427        assert_eq!(one_line("a\nb"), "a");
428        assert_eq!(one_line("a\u{1b}[31mb"), "a[31mb");
429        assert_eq!(escape_double("say \"hi\""), "say 'hi'");
430        assert_eq!(escape_group("it's"), "its");
431    }
432
433    #[test]
434    fn materialising_twice_writes_once() {
435        let dir = tempfile::tempdir().expect("a temporary directory");
436        let graph = build(&Inputs::default());
437        let first = materialise(dir.path(), &graph).expect("the first write");
438        assert!(first.written);
439        let second = materialise(dir.path(), &graph).expect("the second write");
440        assert!(!second.written, "an unchanged graph rewrote the bridge");
441        assert_eq!(second.recipes, first.recipes);
442        assert_eq!(bridged(dir.path()).len(), first.recipes);
443    }
444}