majordomus_cli/command_graph/
mod.rs1pub mod bridge;
37pub mod complete;
38pub mod load;
39pub mod model;
40pub mod native;
41pub mod policy;
42pub mod semantics;
43pub mod shell;
44pub mod tool;
45pub mod workflow;
46
47pub use model::{
48 ArgumentSpec, Availability, CommandGraph, CommandId, CommandNode, Diagnostic, Effect,
49 Execution, Interactivity, Origin, Projections, Provenance, Requirement, Secrecy, Severity,
50 Surface, ValueChoice, ValueSource, SCHEMA,
51};
52
53use std::collections::{BTreeMap, BTreeSet};
54use std::path::Path;
55
56use sha2::{Digest, Sha256};
57
58use crate::capability::registry::CapabilityRegistry;
59use crate::environment::WorkflowCatalogue;
60
61#[derive(Default)]
67pub struct Inputs<'a> {
68 pub registry: Option<&'a CapabilityRegistry>,
71 pub share: Option<&'a Path>,
73 pub workflows: Option<&'a WorkflowCatalogue>,
75 pub bridged: BTreeSet<String>,
78}
79
80pub fn build(inputs: &Inputs<'_>) -> CommandGraph {
86 let mut nodes = Vec::new();
87 let mut diagnostics = Vec::new();
88
89 let native = native::contribute(inputs.registry);
90 nodes.extend(native.nodes);
91 diagnostics.extend(native.diagnostics);
92
93 if let Some(share) = inputs.share {
94 let tool = tool::contribute(share);
95 nodes.extend(tool.nodes);
96 diagnostics.extend(tool.diagnostics);
97 }
98
99 for node in &mut nodes {
103 let capability = node
104 .provenance
105 .capability
106 .as_ref()
107 .and_then(|id| inputs.registry.and_then(|r| r.get(id.as_str())));
108 let exposures = capability.map(|c| {
109 (
110 c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
111 c.exposure.http.as_ref().map(|h| h.path.clone()),
112 )
113 });
114 node.projections = match &exposures {
115 Some((mcp, http)) => policy::project(node, Some((mcp, http))),
116 None => policy::project(node, None),
117 };
118 }
119
120 qualify_collisions(&mut nodes, &mut diagnostics);
121
122 if let Some(catalogue) = inputs.workflows {
123 let contribution = workflow::contribute(catalogue, &inputs.bridged);
124 diagnostics.extend(contribution.diagnostics);
125 let mut declared = contribution.nodes;
126 for node in &mut declared {
127 node.projections = policy::project(node, None);
128 }
129 let taken: BTreeSet<String> = declared
133 .iter()
134 .filter_map(|n| n.projections.workflow.clone())
135 .collect();
136 for node in &mut nodes {
137 let Some(name) = node.projections.workflow.clone() else {
138 continue;
139 };
140 if taken.contains(&name) {
141 node.projections.workflow = None;
142 diagnostics.push(
143 Diagnostic::error(
144 "workflow-name-collision",
145 format!(
146 "`just {name}` is a declared workflow, so the bridge for `{}` cannot take that name",
147 node.invocation
148 ),
149 vec![node.id.clone()],
150 )
151 .with_remedy(format!(
152 "rename the declared recipe, or accept that `{}` has no workflow bridge",
153 node.invocation
154 )),
155 );
156 }
157 }
158 nodes.extend(declared);
159 }
160
161 nodes.sort_by(|a, b| (a.origin, &a.path).cmp(&(b.origin, &b.path)));
162 diagnostics.extend(validate(&nodes));
163 diagnostics
164 .sort_by(|a, b| (a.severity, &a.code, &a.message).cmp(&(b.severity, &b.code, &b.message)));
165
166 let fingerprint = fingerprint(&nodes);
167 CommandGraph {
168 schema: SCHEMA.into(),
169 fingerprint,
170 commands: nodes,
171 diagnostics,
172 }
173}
174
175fn qualify_collisions(nodes: &mut [CommandNode], diagnostics: &mut Vec<Diagnostic>) {
184 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
185 for node in nodes.iter() {
186 if let Some(name) = &node.projections.workflow {
187 *counts.entry(name.clone()).or_default() += 1;
188 }
189 }
190 for node in nodes.iter_mut() {
191 let Some(name) = node.projections.workflow.clone() else {
192 continue;
193 };
194 if counts.get(&name).copied().unwrap_or(0) < 2 {
195 continue;
196 }
197 let qualified = format!("{}-{name}", node.origin.prefix());
198 diagnostics.push(Diagnostic::warning(
199 "workflow-name-qualified",
200 format!(
201 "`just {name}` would name two commands, so `{}` is projected as `just {qualified}`",
202 node.invocation
203 ),
204 vec![node.id.clone()],
205 ));
206 node.projections.workflow = Some(qualified);
207 }
208}
209
210fn validate(nodes: &[CommandNode]) -> Vec<Diagnostic> {
215 let mut out = Vec::new();
216
217 let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
218 for node in nodes {
219 *seen.entry(node.id.as_str()).or_default() += 1;
220 }
221 for (id, count) in seen.iter().filter(|(_, c)| **c > 1) {
222 out.push(Diagnostic::error(
223 "duplicate-command-id",
224 format!("{count} commands claim the identity `{id}`"),
225 Vec::new(),
226 ));
227 }
228
229 let mut recipes: BTreeMap<String, Vec<CommandId>> = BTreeMap::new();
230 let mut tools: BTreeMap<String, Vec<CommandId>> = BTreeMap::new();
231 for node in nodes {
232 if let Some(name) = &node.projections.workflow {
233 recipes
234 .entry(name.clone())
235 .or_default()
236 .push(node.id.clone());
237 }
238 if let Some(name) = &node.projections.mcp {
239 tools.entry(name.clone()).or_default().push(node.id.clone());
240 }
241 }
242 for (name, ids) in recipes.iter().filter(|(_, v)| v.len() > 1) {
243 out.push(Diagnostic::error(
244 "duplicate-workflow-projection",
245 format!("`just {name}` would be written by {} commands", ids.len()),
246 ids.clone(),
247 ));
248 }
249 for (name, ids) in tools.iter().filter(|(_, v)| v.len() > 1) {
250 out.push(Diagnostic::error(
251 "duplicate-mcp-projection",
252 format!("the MCP tool `{name}` is claimed by {} commands", ids.len()),
253 ids.clone(),
254 ));
255 }
256
257 for node in nodes {
260 if node.projections.workflow.is_some()
261 && node.execution.origin == Origin::Workflow
262 && node.origin != Origin::Workflow
263 {
264 out.push(Diagnostic::error(
265 "execution-cycle",
266 format!(
267 "`{}` is projected as a recipe and executed through the recipe runner",
268 node.invocation
269 ),
270 vec![node.id.clone()],
271 ));
272 }
273 }
274
275 let ids: Vec<String> = nodes.iter().map(|n| n.id.to_string()).collect();
279 let origins: BTreeSet<Origin> = nodes.iter().map(|n| n.origin).collect();
280 for alias in semantics::unused_aliases(&ids) {
281 let target_origin = CommandId::derive(Origin::Executable, &[])
282 .origin()
283 .filter(|_| alias.command.starts_with("executable."))
284 .or_else(|| alias.command.starts_with("tool.").then_some(Origin::Tool))
285 .or_else(|| {
286 alias
287 .command
288 .starts_with("workflow.")
289 .then_some(Origin::Workflow)
290 });
291 if target_origin.is_some_and(|o| !origins.contains(&o)) {
292 continue;
293 }
294 out.push(
295 Diagnostic::error(
296 "alias-names-no-command",
297 format!(
298 "the workflow alias `just {}` names `{}`, which the graph does not carry",
299 alias.alias, alias.command
300 ),
301 Vec::new(),
302 )
303 .with_remedy("point the alias at the command that replaced it, or remove it"),
304 );
305 }
306
307 for node in nodes {
309 for arg in &node.arguments {
310 if arg.secrecy == Secrecy::Secret && arg.source.suggestible() {
311 out.push(Diagnostic::error(
312 "secret-is-suggestible",
313 format!(
314 "`{}` takes `{}`, which is a secret with a value source that would enumerate it",
315 node.invocation, arg.name
316 ),
317 vec![node.id.clone()],
318 ));
319 }
320 }
321 }
322
323 out
324}
325
326fn fingerprint(nodes: &[CommandNode]) -> String {
333 let mut hasher = Sha256::new();
334 hasher.update(SCHEMA.as_bytes());
335 for node in nodes {
336 let bytes = serde_json::to_vec(node).unwrap_or_default();
339 hasher.update(&bytes);
340 }
341 format!("{:x}", hasher.finalize())[..16].to_string()
342}
343
344pub fn entrypoints(graph: &CommandGraph, limit: usize) -> Vec<&CommandNode> {
351 let mut out: Vec<&CommandNode> = graph
352 .commands
353 .iter()
354 .filter(|c| c.runnable && c.entrypoint == Some(true) && c.availability.available)
355 .collect();
356 out.sort_by_key(|c| {
357 (
358 match c.origin {
360 Origin::Workflow => 0,
361 Origin::Tool => 1,
362 Origin::Executable => 2,
363 },
364 c.effect,
365 c.path.join("-"),
366 )
367 });
368 out.truncate(limit);
369 out
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn a_graph_builds_from_the_command_line_alone() {
378 let graph = build(&Inputs::default());
379 assert_eq!(graph.schema, SCHEMA);
380 assert!(graph.commands.len() > 40);
381 assert_eq!(graph.fingerprint.len(), 16);
382 assert!(graph.errors().is_empty(), "errors: {:?}", graph.errors());
383 }
384
385 #[test]
386 fn the_fingerprint_is_deterministic() {
387 let a = build(&Inputs::default());
388 let b = build(&Inputs::default());
389 assert_eq!(a.fingerprint, b.fingerprint);
390 }
391
392 #[test]
393 fn every_command_has_a_page_and_a_command_line() {
394 let graph = build(&Inputs::default());
395 for node in &graph.commands {
396 assert!(
397 node.projections.cli.is_some(),
398 "{} has no command line",
399 node.id
400 );
401 assert!(
402 node.projections.docs.starts_with('/') && node.projections.docs.ends_with('/'),
403 "{} has no page",
404 node.id
405 );
406 }
407 }
408
409 #[test]
410 fn a_surface_spelling_resolves_back_to_one_node() {
411 let graph = build(&Inputs::default());
412 let node = graph
413 .resolve(Surface::Workflow, "worktree-status")
414 .expect("the bridge spells worktree status this way");
415 assert_eq!(node.id.as_str(), "executable.worktree.status");
416 let same = graph
417 .resolve(Surface::Cli, "majordomus worktree status")
418 .expect("the command line spells it this way");
419 assert_eq!(same.id, node.id);
420 }
421
422 #[test]
423 fn no_mutation_reaches_a_machine_surface() {
424 let graph = build(&Inputs::default());
425 for node in &graph.commands {
426 if node.effect > policy::MACHINE_CEILING {
427 assert!(
428 node.projections.mcp.is_none() && node.projections.http.is_none(),
429 "{} is above the ceiling and reaches a machine surface",
430 node.id
431 );
432 assert!(node.projections.withheld.is_some());
433 }
434 }
435 }
436
437 #[test]
438 fn entrypoints_come_from_the_declarations() {
439 let graph = build(&Inputs::default());
440 let offered = entrypoints(&graph, 5);
441 assert!(!offered.is_empty());
442 assert!(offered.iter().all(|c| c.runnable));
443 }
444}