use super::Builder;
use crate::cli::{GraphAction, GraphFormat, GraphViewer};
use crate::color;
use crate::json_output;
use crate::processors::log_command;
use anyhow::{Context, Result};
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
impl Builder {
pub fn graph(
&self,
ctx: &crate::build_context::BuildContext,
action: GraphAction,
) -> Result<()> {
match action {
GraphAction::Show { format } => self.print_graph(ctx, format),
GraphAction::View { viewer } => self.view_graph(ctx, viewer),
GraphAction::Stats => self.graph_stats(ctx),
GraphAction::Unreferenced { extensions, rm } => {
self.graph_unreferenced(ctx, extensions, rm)
}
GraphAction::LookupFwd { files } => self.graph_lookup_fwd(ctx, files),
GraphAction::LookupRev { files } => self.graph_lookup_rev(ctx, files),
}
}
fn print_graph(
&self,
ctx: &crate::build_context::BuildContext,
format: GraphFormat,
) -> Result<()> {
let graph = self.build_graph(ctx)?;
let output = match format {
GraphFormat::Dot => graph.to_dot(),
GraphFormat::Mermaid => graph.to_mermaid(),
GraphFormat::Json => graph.to_json(),
GraphFormat::Text => graph.to_text(),
GraphFormat::Svg => graph.to_svg(ctx)?,
};
println!("{output}");
Ok(())
}
fn view_graph(
&self,
ctx: &crate::build_context::BuildContext,
viewer: GraphViewer,
) -> Result<()> {
use std::process::Command;
let graph = self.build_graph(ctx)?;
let temp_dir = std::env::temp_dir();
match viewer {
GraphViewer::Mermaid => {
let html_path = temp_dir.join("rsconstruct_graph.html");
let html_content = graph.to_html();
fs::write(&html_path, html_content).with_context(|| {
format!("Failed to write HTML file: {}", html_path.display())
})?;
self.open_file(&html_path)?;
println!("Opened graph in browser: {}", html_path.display());
}
GraphViewer::Svg => {
let mut dot_check_cmd = Command::new("dot");
dot_check_cmd.arg("-V");
let dot_check = crate::processors::run_command_capture(ctx, &dot_check_cmd);
if dot_check.map_or(true, |o| !o.status.success()) {
anyhow::bail!(
"Graphviz 'dot' command not found. Install Graphviz or use --view=mermaid"
);
}
let dot_path = temp_dir.join("rsconstruct_graph.dot");
let svg_path = temp_dir.join("rsconstruct_graph.svg");
let dot_content = graph.to_dot();
fs::write(&dot_path, dot_content)
.with_context(|| format!("Failed to write DOT file: {}", dot_path.display()))?;
let mut dot_cmd = Command::new("dot");
dot_cmd.arg("-Tsvg").arg(&dot_path).arg("-o").arg(&svg_path);
let output = crate::processors::run_command_capture(ctx, &dot_cmd)
.context("Failed to run dot command")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("dot command failed: {stderr}");
}
self.open_file(&svg_path)?;
println!("Opened graph: {}", svg_path.display());
}
}
Ok(())
}
fn graph_stats(&self, ctx: &crate::build_context::BuildContext) -> Result<()> {
let graph = self.build_graph(ctx)?;
let products = graph.products();
let mut per_processor: BTreeMap<&str, (usize, usize, usize)> = BTreeMap::new();
let mut total_edges = 0usize;
for product in products {
let entry = per_processor
.entry(product.processor.as_str())
.or_insert((0, 0, 0));
entry.0 += 1; entry.1 += product.inputs.len();
entry.2 += product.outputs.len();
total_edges += graph.get_dependencies(product.id).len();
}
if json_output::is_json_mode() {
let stats: Vec<serde_json::Value> = per_processor
.iter()
.map(|(proc, (count, inputs, outputs))| {
serde_json::json!({
"processor": proc,
"products": count,
"inputs": inputs,
"outputs": outputs,
})
})
.collect();
let json = serde_json::json!({
"processors": stats,
"total_products": products.len(),
"total_edges": total_edges,
});
println!("{}", serde_json::to_string_pretty(&json)?);
} else {
for (proc, (count, inputs, outputs)) in &per_processor {
println!(
"{}: {} products, {} inputs, {} outputs",
color::bold(proc),
count,
inputs,
outputs
);
}
println!();
println!(
"{}: {} products, {} dependency edges",
color::bold("Total"),
products.len(),
total_edges
);
}
Ok(())
}
fn graph_unreferenced(
&self,
ctx: &crate::build_context::BuildContext,
extensions: Vec<String>,
rm: bool,
) -> Result<()> {
let graph = self.build_graph(ctx)?;
let referenced: HashSet<PathBuf> = graph
.products()
.iter()
.flat_map(|p| p.inputs.iter().cloned())
.collect();
let exts: Vec<String> = extensions
.iter()
.map(|e| {
if e.starts_with('.') {
e.clone()
} else {
format!(".{e}")
}
})
.collect();
let mut unreferenced: Vec<PathBuf> = Vec::new();
collect_unreferenced(
std::path::Path::new("."),
&exts,
&referenced,
&mut unreferenced,
)?;
unreferenced.sort();
for path in &unreferenced {
println!("{}", path.display());
}
if rm {
for path in &unreferenced {
fs::remove_file(path)
.with_context(|| format!("Failed to delete {}", path.display()))?;
}
}
Ok(())
}
pub(super) fn open_file(&self, path: &std::path::Path) -> Result<()> {
use std::process::Command;
let cmd = if let Some(ref viewer) = self.config.graph.viewer {
viewer.as_str()
} else {
"xdg-open"
};
let mut open_cmd = Command::new(cmd);
open_cmd.arg(path);
log_command(&open_cmd);
open_cmd
.spawn()
.with_context(|| format!("Failed to open file with {cmd}"))?;
Ok(())
}
fn graph_lookup_fwd(
&self,
ctx: &crate::build_context::BuildContext,
files: Vec<String>,
) -> Result<()> {
let graph = self.build_graph(ctx)?;
let queries = normalize_query_paths(&files);
if json_output::is_json_mode() {
#[derive(serde::Serialize)]
struct Consumer<'a> {
processor: &'a str,
outputs: Vec<String>,
}
#[derive(serde::Serialize)]
struct Entry<'a> {
file: String,
consumers: Vec<Consumer<'a>>,
}
let entries: Vec<Entry> = queries
.iter()
.map(|q| {
let consumers: Vec<Consumer> = graph
.products_consuming(q)
.iter()
.map(|&id| &graph.products()[id])
.map(|p| Consumer {
processor: &p.processor,
outputs: p.outputs.iter().map(|o| o.display().to_string()).collect(),
})
.collect();
Entry {
file: q.display().to_string(),
consumers,
}
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
for query in &queries {
println!("{}", query.display());
let consumers = graph.products_consuming(query);
if consumers.is_empty() {
println!(" (not consumed by any product)");
} else {
for &id in consumers {
let product = &graph.products()[id];
if product.outputs.is_empty() {
println!(" [{}] (no outputs — checker)", product.processor);
} else {
let outs: Vec<String> = product
.outputs
.iter()
.map(|o| o.display().to_string())
.collect();
println!(" [{}] -> {}", product.processor, outs.join(", "));
}
}
}
}
Ok(())
}
fn graph_lookup_rev(
&self,
ctx: &crate::build_context::BuildContext,
files: Vec<String>,
) -> Result<()> {
let graph = self.build_graph(ctx)?;
let queries = normalize_query_paths(&files);
if json_output::is_json_mode() {
#[derive(serde::Serialize)]
struct Producer<'a> {
processor: &'a str,
inputs: Vec<String>,
}
#[derive(serde::Serialize)]
struct Entry<'a> {
file: String,
producer: Option<Producer<'a>>,
}
let entries: Vec<Entry> = queries
.iter()
.map(|q| {
let producer = graph.path_owner(q).map(|id| {
let p = &graph.products()[id];
Producer {
processor: &p.processor,
inputs: p.inputs.iter().map(|i| i.display().to_string()).collect(),
}
});
Entry {
file: q.display().to_string(),
producer,
}
})
.collect();
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
for query in &queries {
println!("{}", query.display());
match graph.path_owner(query) {
Some(id) => {
let p = &graph.products()[id];
if p.inputs.is_empty() {
println!(" [{}] (no inputs declared)", p.processor);
} else {
let ins: Vec<String> =
p.inputs.iter().map(|i| i.display().to_string()).collect();
println!(" [{}] <- {}", p.processor, ins.join(", "));
}
}
None => println!(" (not produced by any product)"),
}
}
Ok(())
}
}
fn normalize_query_paths(files: &[String]) -> Vec<PathBuf> {
files
.iter()
.map(|s| {
let p = Path::new(s);
p.strip_prefix("./").unwrap_or(p).to_path_buf()
})
.collect()
}
fn collect_unreferenced(
dir: &Path,
exts: &[String],
referenced: &HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
) -> Result<()> {
for entry in
fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))?
{
let entry = entry.with_context(|| format!("Failed to read entry in {}", dir.display()))?;
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') || name == "target" {
continue;
}
collect_unreferenced(&path, exts, referenced, out)?;
} else if path.is_file()
&& let Some(ext) = path.extension().and_then(|e| e.to_str())
&& exts.contains(&format!(".{ext}"))
{
let clean = path.strip_prefix("./").unwrap_or(&path).to_path_buf();
if !referenced.contains(&clean) {
out.push(clean);
}
}
}
Ok(())
}