#![forbid(unsafe_code)]
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use clap::Parser;
use mk_rs_core::graph::{self, GraphScope};
use mk_rs_core::lex::{tokenize, ShellMode};
use mk_rs_core::parse::{self, Stmt};
#[derive(Clone, Debug, clap::ValueEnum)]
enum Format {
Ascii,
Mermaid,
Dot,
Json,
}
#[derive(Parser)]
#[command(
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"),
about
)]
struct Cli {
#[arg(short = 'f', default_value = "mkfile")]
file: PathBuf,
#[arg(long = "format", short = 'F', value_enum, default_value_t = Format::Ascii)]
format: Format,
#[arg(long = "json", conflicts_with = "format")]
json: bool,
#[arg(long = "dot", conflicts_with = "format")]
dot: bool,
#[arg(long = "target", short = 't')]
target: Option<String>,
#[arg(long = "dead-ends")]
dead_ends: bool,
#[arg(long = "orphans")]
orphans: bool,
#[arg(long = "check")]
check: bool,
}
fn main() {
if let Err(e) = run() {
eprintln!("mk-graph: {}", e);
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let text =
std::fs::read_to_string(&cli.file).map_err(|e| format!("{}: {}", cli.file.display(), e))?;
let tokens = tokenize(&text, ShellMode::Sh)?;
let stmts = parse::parse(&tokens)?;
let recipes: HashMap<String, String> = stmts
.iter()
.filter_map(|s| if let Stmt::Rule(r) = s { Some(r) } else { None })
.filter_map(|r| r.recipe.clone().map(|rec| (r.targets[0].clone(), rec)))
.collect();
let target_names: Vec<String> = stmts
.iter()
.filter_map(|s| if let Stmt::Rule(r) = s { Some(r) } else { None })
.flat_map(|r| r.targets.iter().cloned())
.filter(|t| !t.contains('%') && !t.contains('&'))
.collect();
let graph = graph::build_graph(&stmts, &target_names)?;
let do_dead = cli.dead_ends || cli.check;
let do_orphans = cli.orphans || cli.check;
let check_mode = do_dead || do_orphans;
let mut errors = 0;
if do_dead {
let dead = find_dead_ends(&graph);
if dead.is_empty() {
eprintln!("mk-graph: no dead-end targets found");
} else {
eprintln!("mk-graph: {} dead-end target(s):", dead.len());
for name in &dead {
eprintln!(" {}", name);
}
errors += dead.len();
}
}
if do_orphans {
let orph = find_orphans(&graph);
if orph.is_empty() {
eprintln!("mk-graph: no orphan prerequisites found");
} else {
eprintln!("mk-graph: {} orphan prerequisite(s):", orph.len());
for name in &orph {
eprintln!(" {}", name);
}
errors += orph.len();
}
}
if check_mode {
if errors > 0 {
std::process::exit(1);
}
return Ok(());
}
let scope = if cli.target.is_some() {
GraphScope::Subgraph
} else {
GraphScope::All
};
let format = if cli.json {
Format::Json
} else if cli.dot {
Format::Dot
} else {
cli.format.clone()
};
let out = match format {
Format::Ascii => to_ascii(&graph, scope, cli.target.as_deref()),
Format::Mermaid => to_mermaid(&graph, scope, cli.target.as_deref()),
Format::Dot => graph.to_dot(scope, cli.target.as_deref()),
Format::Json => to_json(&graph, &recipes, scope, cli.target.as_deref()),
};
if !out.is_empty() {
let trimmed = match format {
Format::Ascii => out.trim_end().to_string(),
_ => out,
};
println!("{}", trimmed);
}
Ok(())
}
fn find_dead_ends(graph: &graph::Graph) -> Vec<String> {
let consumed: HashSet<graph::NodeIndex> = graph.arcs.iter().map(|a| a.from).collect();
let produced: HashSet<graph::NodeIndex> = graph.arcs.iter().map(|a| a.to).collect();
produced
.difference(&consumed)
.map(|idx| graph.nodes[idx.0].name.clone())
.collect()
}
fn find_orphans(graph: &graph::Graph) -> Vec<String> {
let produced: HashSet<graph::NodeIndex> = graph.arcs.iter().map(|a| a.to).collect();
let needed: HashSet<graph::NodeIndex> = graph.arcs.iter().map(|a| a.from).collect();
needed
.difference(&produced)
.filter(|idx| graph.nodes[idx.0].mtime.is_none())
.map(|idx| graph.nodes[idx.0].name.clone())
.collect()
}
fn to_ascii(graph: &graph::Graph, scope: GraphScope, root: Option<&str>) -> String {
let included: HashSet<graph::NodeIndex> = match scope {
GraphScope::All => (0..graph.nodes.len()).map(graph::NodeIndex).collect(),
GraphScope::Subgraph => {
let root_idx = root
.and_then(|n| graph.nodes.iter().position(|node| node.name == n))
.map(graph::NodeIndex);
match root_idx {
Some(idx) => reachable_from_nodes(graph, idx),
None => {
eprintln!("mk-graph: target '{}' not found", root.unwrap_or(""));
return String::new();
}
}
}
};
let mut dag = ascii_dag::Graph::new();
let node_labels: Vec<Option<String>> = graph
.nodes
.iter()
.enumerate()
.map(|(i, n)| {
if included.contains(&graph::NodeIndex(i)) {
Some(if n.flags.is_virtual() {
format!("{}:V", n.name)
} else {
n.name.clone()
})
} else {
None
}
})
.collect();
let edge_labels: Vec<String> = graph
.arcs
.iter()
.map(|a| edge_label(a).unwrap_or_default())
.collect();
for (i, lbl) in node_labels.iter().enumerate() {
if let Some(l) = lbl {
dag.add_node(i, l.as_str());
}
}
for (i, arc) in graph.arcs.iter().enumerate() {
if node_labels[arc.from.0].is_some() && node_labels[arc.to.0].is_some() {
let lbl = if edge_labels[i].is_empty() {
None
} else {
Some(edge_labels[i].as_str())
};
dag.add_edge(arc.from.0, arc.to.0, lbl);
}
}
dag.render()
}
fn to_mermaid(graph: &graph::Graph, scope: GraphScope, root: Option<&str>) -> String {
let included: HashSet<graph::NodeIndex> = match scope {
GraphScope::All => (0..graph.nodes.len()).map(graph::NodeIndex).collect(),
GraphScope::Subgraph => {
let root_idx = root
.and_then(|n| graph.nodes.iter().position(|node| node.name == n))
.map(graph::NodeIndex);
match root_idx {
Some(idx) => reachable_from_nodes(graph, idx),
None => {
eprintln!("mk-graph: target '{}' not found", root.unwrap_or(""));
return String::new();
}
}
}
};
let mut out = String::from("```mermaid\ngraph LR\n");
for (i, node) in graph.nodes.iter().enumerate() {
if !included.contains(&graph::NodeIndex(i)) {
continue;
}
let id = format!("n{}", i);
let label = mermaid_escape(&node.name);
if node.flags.is_virtual() {
out.push_str(&format!(" {}({{{}}})\n", id, label));
} else {
out.push_str(&format!(" {}[{}]\n", id, label));
}
}
for arc in &graph.arcs {
if !included.contains(&arc.from) || !included.contains(&arc.to) {
continue;
}
match edge_label(arc) {
Some(lbl) => out.push_str(&format!(
" n{} -- \"{}\" n{}\n",
arc.from.0,
mermaid_escape(&lbl),
arc.to.0
)),
None => out.push_str(&format!(" n{} --> n{}\n", arc.from.0, arc.to.0)),
}
}
out.push_str("```");
out
}
fn edge_label(arc: &graph::Arc) -> Option<String> {
if arc.is_meta {
Some("meta".to_string())
} else if arc.line > 0 {
Some(format!("L{}", arc.line))
} else {
None
}
}
fn mermaid_escape(s: &str) -> String {
let needs_quote = s.contains(['[', ']', '(', ')', '{', '}', '"', ' ']);
if needs_quote {
format!("\"{}\"", s.replace('"', "#quot;"))
} else {
s.to_string()
}
}
fn to_json(
graph: &graph::Graph,
recipes: &HashMap<String, String>,
scope: GraphScope,
root: Option<&str>,
) -> String {
let included: HashSet<graph::NodeIndex> = match scope {
GraphScope::All => (0..graph.nodes.len()).map(graph::NodeIndex).collect(),
GraphScope::Subgraph => {
let root_idx = root
.and_then(|n| graph.nodes.iter().position(|node| node.name == n))
.map(graph::NodeIndex);
match root_idx {
Some(idx) => reachable_from_nodes(graph, idx),
None => {
eprintln!("mk-graph: target '{}' not found", root.unwrap_or(""));
return String::new();
}
}
}
};
let nodes: Vec<serde_json::Value> = graph
.nodes
.iter()
.enumerate()
.filter(|(i, _)| included.contains(&graph::NodeIndex(*i)))
.map(|(_, node)| {
let kind = if node.flags.is_virtual() {
"virtual"
} else {
"file"
};
let stage = stage_heuristic(&node.name, node.flags.is_virtual());
let recipe = recipes.get(&node.name);
let mut obj = serde_json::json!({
"id": node.name,
"kind": kind,
"stage": stage
});
if let Some(rec) = recipe {
obj["recipe"] = serde_json::Value::String(rec.clone());
}
obj
})
.collect();
let edges: Vec<serde_json::Value> = graph
.arcs
.iter()
.filter(|arc| included.contains(&arc.from) && included.contains(&arc.to))
.map(|arc| {
serde_json::json!({
"from": graph.nodes[arc.from.0].name,
"to": graph.nodes[arc.to.0].name,
"line": arc.line
})
})
.collect();
let output = serde_json::json!({ "nodes": nodes, "edges": edges });
serde_json::to_string(&output).unwrap_or_default()
}
fn reachable_from_nodes(
graph: &graph::Graph,
start: graph::NodeIndex,
) -> HashSet<graph::NodeIndex> {
let mut visited = HashSet::new();
let mut stack = vec![start];
while let Some(idx) = stack.pop() {
if visited.insert(idx) {
for &arc_idx in &graph.nodes[idx.0].arcs_in {
let prereq = graph.arcs[arc_idx.0].from;
stack.push(prereq);
}
}
}
visited
}
fn stage_heuristic(name: &str, is_virtual: bool) -> &'static str {
if is_virtual {
return "virtual";
}
if name.starts_with("data/raw/") {
"raw"
} else if name.starts_with("data/processed/") || name.starts_with("data/bars/") {
"processed"
} else if name.starts_with("reports/") || name.starts_with("templates/") {
"report"
} else {
"file"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_export_has_nodes_and_edges() {
let input = "all: hello.o world.o\nhello.o: hello.c\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let recipes: HashMap<String, String> = stmts
.iter()
.filter_map(|s| if let Stmt::Rule(r) = s { Some(r) } else { None })
.filter_map(|r| r.recipe.clone().map(|rec| (r.targets[0].clone(), rec)))
.collect();
let targets = vec!["all".into()];
let g = graph::build_graph(&stmts, &targets).unwrap();
let json = to_json(&g, &recipes, GraphScope::All, None);
assert!(json.contains("\"nodes\""));
assert!(json.contains("\"edges\""));
assert!(json.contains("hello.c"));
}
#[test]
fn json_includes_recipe_text() {
let input = "all:V: hello.c\n\techo build\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let recipes: HashMap<String, String> = stmts
.iter()
.filter_map(|s| if let Stmt::Rule(r) = s { Some(r) } else { None })
.filter_map(|r| r.recipe.clone().map(|rec| (r.targets[0].clone(), rec)))
.collect();
let targets = vec!["all".into()];
let g = graph::build_graph(&stmts, &targets).unwrap();
let json = to_json(&g, &recipes, GraphScope::All, None);
assert!(json.contains("echo build"));
}
#[test]
fn dead_ends_detects_output_only() {
let input = "a: b\nb:\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = graph::build_graph(&stmts, &["a".into()]).unwrap();
let dead = find_dead_ends(&g);
assert!(dead.contains(&"a".to_string()));
assert!(!dead.contains(&"b".to_string()));
}
#[test]
fn orphans_detects_missing_prereqs() {
let input = "a: b c\nd:\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = graph::build_graph(&stmts, &["a".into(), "d".into()]).unwrap();
let orph = find_orphans(&g);
assert!(orph.iter().any(|s| s == "b"));
assert!(orph.iter().any(|s| s == "c"));
}
#[test]
fn ascii_render_contains_node_names_and_edge_labels() {
let input = "prog: main.o\nmain.o: main.c\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = graph::build_graph(&stmts, &["prog".into()]).unwrap();
let ascii = to_ascii(&g, GraphScope::All, None);
assert!(
ascii.contains("main.c"),
"ascii should list node names: {ascii}"
);
assert!(ascii.contains("prog"));
assert!(
ascii.contains('[') && ascii.contains(']'),
"ascii should bracket node labels: {ascii}"
);
}
#[test]
fn ascii_marks_virtual_targets() {
let input = "all:V: prog\nprog:\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = graph::build_graph(&stmts, &["all".into()]).unwrap();
let ascii = to_ascii(&g, GraphScope::All, None);
assert!(
ascii.contains("all:V"),
"virtual target should be suffixed with :V: {ascii}"
);
}
#[test]
fn mermaid_is_valid_block_with_edges() {
let input = "prog: main.o util.o\nmain.o: main.c\nutil.o: util.c\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = graph::build_graph(&stmts, &["prog".into()]).unwrap();
let m = to_mermaid(&g, GraphScope::All, None);
assert!(
m.starts_with("```mermaid\n"),
"should open fenced block: {m}"
);
assert!(m.ends_with("```"), "should close fenced block: {m}");
assert!(m.contains("graph LR"));
assert!(
m.contains("-- ") || m.contains("-->"),
"should have at least one edge: {m}"
);
assert!(m.contains("main.o"));
}
#[test]
fn mermaid_uses_hexagon_shape_for_virtual() {
let input = "all:V: prog\nprog:\n";
let tokens = tokenize(input, ShellMode::Sh).unwrap();
let stmts = parse::parse(&tokens).unwrap();
let g = graph::build_graph(&stmts, &["all".into()]).unwrap();
let m = to_mermaid(&g, GraphScope::All, None);
assert!(
m.contains("({"),
"virtual target should use {{...}} shape: {m}"
);
}
}