majordomus_cli/command_graph/
bridge.rs1use 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
38pub const DIRECTORY: &str = ".ai/local/cache/command-graph";
40
41pub const BRIDGE: &str = "bridge.just";
43
44pub const MANIFEST: &str = "bridge.json";
46
47pub const MANIFEST_SCHEMA: &str = "majordomus/command-bridge/v1";
49
50pub const REQUIRED_SETTING: &str = "set positional-arguments";
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Manifest {
59 pub schema: String,
61 pub fingerprint: String,
63 pub recipes: Vec<String>,
66 #[serde(default)]
67 pub stamp: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Outcome {
77 pub path: PathBuf,
79 pub written: bool,
81 pub recipes: usize,
83 pub fingerprint: String,
85}
86
87pub 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
110pub 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
133pub 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
162pub 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
170pub 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
177pub fn bridged(root: &Path) -> BTreeSet<String> {
179 manifest(root)
180 .map(|m| m.recipes.into_iter().collect())
181 .unwrap_or_default()
182}
183
184pub 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 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
245fn 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
262fn 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 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 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
291fn 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
306fn escape_group(text: &str) -> String {
308 one_line(text).replace('\'', "")
309}
310
311fn escape_double(text: &str) -> String {
313 one_line(text).replace('\\', "").replace('"', "'")
314}
315
316pub 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
362fn 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}