From dcde5d1e82241d236b2186a0e09d9e3c0ac696a7 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Thu, 2 Apr 2026 03:04:27 -0400 Subject: [PATCH] fix(notgraph): render dep graph edges, add dark mode --- crates/notgraph/src/emit.rs | 114 +++++++++++++++--- crates/notgraph/src/main.rs | 20 +-- .../notgraph/templates/report.html.template | 39 ++++-- 3 files changed, 139 insertions(+), 34 deletions(-) diff --git a/crates/notgraph/src/emit.rs b/crates/notgraph/src/emit.rs index 951f2b1..bc13362 100644 --- a/crates/notgraph/src/emit.rs +++ b/crates/notgraph/src/emit.rs @@ -1,17 +1,25 @@ -use crate::types::{GraphStats, HotspotKind, SymbolKind, SymbolTable}; +use crate::types::{CrateGraph, GraphStats, HotspotKind, SymbolKind, SymbolTable}; use anyhow::Result; use std::path::Path; -pub fn write_all(output_dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) -> Result<()> { +pub fn write_all( + output_dir: &Path, + crate_graph: &CrateGraph, + stats: &GraphStats, + symbol_tables: &[SymbolTable], +) -> Result<()> { std::fs::create_dir_all(output_dir)?; write_json(output_dir, stats)?; write_markdown(output_dir, stats, symbol_tables)?; - write_html(output_dir, stats)?; + write_html(output_dir, crate_graph, stats)?; Ok(()) } fn write_json(dir: &Path, stats: &GraphStats) -> Result<()> { - std::fs::write(dir.join("report.json"), serde_json::to_string_pretty(stats)?)?; + std::fs::write( + dir.join("report.json"), + serde_json::to_string_pretty(stats)?, + )?; Ok(()) } @@ -21,29 +29,47 @@ fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) md.push_str("## Crate Dependency Graph\n\n"); for fs in &stats.crate_graph { - md.push_str(&format!("- **{}** (fan-in: {}, fan-out: {})\n", fs.name, fs.fan_in, fs.fan_out)); + md.push_str(&format!( + "- **{}** (fan-in: {}, fan-out: {})\n", + fs.name, fs.fan_in, fs.fan_out + )); } md.push('\n'); md.push_str("## Module Graphs\n\n"); for ms in &stats.module_graphs { - let status = if ms.cycles.is_empty() { "OK" } else { "CYCLES DETECTED" }; + let status = if ms.cycles.is_empty() { + "OK" + } else { + "CYCLES DETECTED" + }; md.push_str(&format!( "### {} [{}]\n\n- Modules: {}\n- Cycles: {}\n\n", - ms.krate, status, ms.nodes.len(), ms.cycles.len() + ms.krate, + status, + ms.nodes.len(), + ms.cycles.len() )); } md.push_str("## Hotspots\n\n### Fan-in (most depended-upon)\n\n"); md.push_str("| Name | Score |\n|------|-------|\n"); - for h in stats.hotspots.iter().filter(|h| h.kind == HotspotKind::FanIn) { + for h in stats + .hotspots + .iter() + .filter(|h| h.kind == HotspotKind::FanIn) + { md.push_str(&format!("| {} | {} |\n", h.name, h.score)); } md.push('\n'); md.push_str("### Fan-out (highest coupling)\n\n"); md.push_str("| Name | Score |\n|------|-------|\n"); - for h in stats.hotspots.iter().filter(|h| h.kind == HotspotKind::FanOut) { + for h in stats + .hotspots + .iter() + .filter(|h| h.kind == HotspotKind::FanOut) + { md.push_str(&format!("| {} | {} |\n", h.name, h.score)); } md.push('\n'); @@ -51,8 +77,17 @@ fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) md.push_str("## Public Symbol Inventory\n\n"); for table in symbol_tables { md.push_str(&format!("### {}\n\n", table.krate)); - for kind in [SymbolKind::Struct, SymbolKind::Enum, SymbolKind::Trait, SymbolKind::Fn, SymbolKind::Type, SymbolKind::Const] { - let syms: Vec<&str> = table.symbols.iter() + for kind in [ + SymbolKind::Struct, + SymbolKind::Enum, + SymbolKind::Trait, + SymbolKind::Fn, + SymbolKind::Type, + SymbolKind::Const, + ] { + let syms: Vec<&str> = table + .symbols + .iter() .filter(|s| s.kind == kind && s.is_pub) .map(|s| s.name.as_str()) .collect(); @@ -75,29 +110,74 @@ fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) Ok(()) } -fn write_html(dir: &Path, stats: &GraphStats) -> Result<()> { - let nodes_js: String = stats.crate_graph.iter().enumerate() - .map(|(i, n)| format!("{{id:{},label:{:?},title:'fan-in:{} fan-out:{}'}}", i, n.name, n.fan_in, n.fan_out)) +fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Result<()> { + // Build node id map from name -> index + let name_to_id: std::collections::HashMap<&str, usize> = crate_graph + .nodes + .iter() + .enumerate() + .map(|(i, n)| (n.as_str(), i)) + .collect(); + + let fan_map: std::collections::HashMap<&str, (usize, usize)> = stats + .crate_graph + .iter() + .map(|fs| (fs.name.as_str(), (fs.fan_in, fs.fan_out))) + .collect(); + + let nodes_js: String = crate_graph + .nodes + .iter() + .enumerate() + .map(|(i, n)| { + let (fi, fo) = fan_map.get(n.as_str()).copied().unwrap_or((0, 0)); + format!( + "{{id:{},label:{:?},title:'fan-in:{} fan-out:{}'}}", + i, n, fi, fo + ) + }) + .collect::>() + .join(","); + + let edges_js: String = crate_graph + .edges + .iter() + .enumerate() + .filter_map(|(i, (from, to))| { + let from_id = name_to_id.get(from.as_str())?; + let to_id = name_to_id.get(to.as_str())?; + Some(format!("{{id:{},from:{},to:{}}}", i, from_id, to_id)) + }) .collect::>() .join(","); let cycle_html = if stats.cycles.is_empty() { "

No cycles detected.

".to_string() } else { - stats.cycles.iter() + stats + .cycles + .iter() .map(|c| format!("

CYCLE: {}

", c.join(" -> "))) .collect::>() .join("\n") }; - let hotspot_rows: String = stats.hotspots.iter() - .map(|h| format!("{}{}{}", h.name, h.kind, h.score)) + let hotspot_rows: String = stats + .hotspots + .iter() + .map(|h| { + format!( + "{}{}{}", + h.name, h.kind, h.score + ) + }) .collect::>() .join("\n"); let html = format!( include_str!("../templates/report.html.template"), nodes_js = nodes_js, + edges_js = edges_js, hotspot_rows = hotspot_rows, cycle_html = cycle_html, ); diff --git a/crates/notgraph/src/main.rs b/crates/notgraph/src/main.rs index 4be8a6a..c630105 100644 --- a/crates/notgraph/src/main.rs +++ b/crates/notgraph/src/main.rs @@ -1,10 +1,13 @@ -use notgraph_lib::{analysis, crate_graph, emit, module_graph, symbols}; use anyhow::{Context, Result}; use clap::Parser; +use notgraph_lib::{analysis, crate_graph, emit, module_graph, symbols}; use std::path::PathBuf; #[derive(Parser)] -#[command(name = "notgraph", about = "Workspace module graph and symbol analysis")] +#[command( + name = "notgraph", + about = "Workspace module graph and symbol analysis" +)] struct Cli { #[arg(long, default_value = "docs/graph")] output: PathBuf, @@ -21,8 +24,7 @@ fn main() -> Result<()> { let manifest = find_workspace_manifest()?; let workspace_root = manifest.parent().unwrap().to_path_buf(); - let crate_g = crate_graph::build(&manifest) - .context("failed to build crate graph")?; + let crate_g = crate_graph::build(&manifest).context("failed to build crate graph")?; let meta = cargo_metadata::MetadataCommand::new() .manifest_path(&manifest) @@ -37,17 +39,19 @@ fn main() -> Result<()> { continue; } let src_dir = PathBuf::from(pkg.manifest_path.as_str()) - .parent().unwrap().join("src"); + .parent() + .unwrap() + .join("src"); if !src_dir.exists() { continue; } module_graphs.push( module_graph::build(pkg.name.clone(), &src_dir) - .with_context(|| format!("module_graph failed for {}", pkg.name))? + .with_context(|| format!("module_graph failed for {}", pkg.name))?, ); symbol_tables.push( symbols::build(pkg.name.clone(), &src_dir) - .with_context(|| format!("symbols failed for {}", pkg.name))? + .with_context(|| format!("symbols failed for {}", pkg.name))?, ); } @@ -59,7 +63,7 @@ fn main() -> Result<()> { workspace_root.join(&cli.output) }; - emit::write_all(&output_dir, &stats, &symbol_tables) + emit::write_all(&output_dir, &crate_g, &stats, &symbol_tables) .context("failed to write reports")?; println!("notgraph: reports written to {}", output_dir.display()); diff --git a/crates/notgraph/templates/report.html.template b/crates/notgraph/templates/report.html.template index 15050de..489426e 100644 --- a/crates/notgraph/templates/report.html.template +++ b/crates/notgraph/templates/report.html.template @@ -5,13 +5,33 @@ notgraph Report @@ -20,11 +40,12 @@

Hotspots