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(Parser)]
#[command(
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"),
about
)]
struct Cli {
#[arg(short = 'f', default_value = "mkfile")]
file: PathBuf,
#[arg(long = "json")]
json: 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
};
if cli.json {
println!("{}", to_json(&graph, &recipes, scope, cli.target.as_deref()));
} else {
println!("{}", graph.to_dot(scope, cli.target.as_deref()));
}
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_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"));
}
}