From b5a27108d0db577df3b4b91abe0374d9881c034e Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:53:07 -0400 Subject: [PATCH] feat(notgraph): heatmap coloring, symbol counts, per-crate module graphs, cycle callouts --- crates/notgraph/src/analysis.rs | 73 +++++--- crates/notgraph/src/crate_graph.rs | 18 +- crates/notgraph/src/emit.rs | 157 +++++++++++++++--- crates/notgraph/src/main.rs | 2 +- crates/notgraph/src/module_graph.rs | 27 ++- crates/notgraph/src/symbols.rs | 89 ++++++++-- crates/notgraph/src/types.rs | 18 +- .../notgraph/templates/report.html.template | 23 ++- crates/notgraph/tests/integration.rs | 13 +- crates/notsecrets/examples/age_smoke.rs | 59 +++++++ crates/notsecrets/src/decrypt.rs | 14 +- crates/notsecrets/src/encrypt.rs | 9 +- crates/notsecrets/src/format.rs | 28 ++-- crates/notsecrets/src/identities/encrypted.rs | 2 +- crates/notsecrets/src/identities/mod.rs | 6 +- crates/notsecrets/src/identities/scrypt.rs | 6 +- .../notsecrets/src/identities/ssh_ed25519.rs | 19 ++- crates/notsecrets/src/identities/ssh_rsa.rs | 12 +- crates/notsecrets/src/identities/x25519.rs | 7 +- crates/notsecrets/src/lib.rs | 6 +- crates/notsecrets/src/recipients/scrypt.rs | 12 +- crates/notsecrets/src/recipients/ssh_rsa.rs | 2 +- crates/notsecrets/src/recipients/x25519.rs | 2 +- crates/notsecrets/src/sources/bitwarden.rs | 23 ++- crates/notsecrets/src/sources/file.rs | 24 +-- crates/notsecrets/src/sources/prompt.rs | 2 +- crates/notstrap/src/lib.rs | 2 +- tests/integration/tests/cross_crate.rs | 2 +- 28 files changed, 490 insertions(+), 167 deletions(-) create mode 100644 crates/notsecrets/examples/age_smoke.rs diff --git a/crates/notgraph/src/analysis.rs b/crates/notgraph/src/analysis.rs index 17a8451..1a5e583 100644 --- a/crates/notgraph/src/analysis.rs +++ b/crates/notgraph/src/analysis.rs @@ -1,6 +1,4 @@ -use crate::types::{ - CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph, -}; +use crate::types::{CrateGraph, FanStats, GraphStats, Hotspot, HotspotKind, ModStats, ModuleGraph}; use std::collections::{HashMap, VecDeque}; pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec { @@ -10,11 +8,14 @@ pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec *fan_out.entry(from.as_str()).or_insert(0) += 1; *fan_in.entry(to.as_str()).or_insert(0) += 1; } - nodes.iter().map(|n| FanStats { - name: n.clone(), - fan_in: *fan_in.get(n.as_str()).unwrap_or(&0), - fan_out: *fan_out.get(n.as_str()).unwrap_or(&0), - }).collect() + nodes + .iter() + .map(|n| FanStats { + name: n.clone(), + fan_in: *fan_in.get(n.as_str()).unwrap_or(&0), + fan_out: *fan_out.get(n.as_str()).unwrap_or(&0), + }) + .collect() } /// Kahn's algorithm. Returns cycle participants if graph is not a DAG. @@ -30,7 +31,8 @@ pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec = in_degree.iter() + let mut queue: VecDeque<&str> = in_degree + .iter() .filter(|&(_, &d)| d == 0) .map(|(n, _)| *n) .collect(); @@ -51,7 +53,8 @@ pub fn detect_cycles(nodes: &[String], edges: &[(String, String)]) -> Vec = in_degree.iter() + let cycle_nodes: Vec = in_degree + .iter() .filter(|&(_, &d)| d > 0) .map(|(n, _)| n.to_string()) .collect(); @@ -66,7 +69,11 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec { by_fan_in.sort_by(|a, b| b.fan_in.cmp(&a.fan_in)); for s in by_fan_in.iter().take(top_n) { if s.fan_in > 0 { - result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanIn, score: s.fan_in }); + result.push(Hotspot { + name: s.name.clone(), + kind: HotspotKind::FanIn, + score: s.fan_in, + }); } } @@ -74,14 +81,22 @@ pub fn hotspots(stats: &[FanStats], top_n: usize) -> Vec { by_fan_out.sort_by(|a, b| b.fan_out.cmp(&a.fan_out)); for s in by_fan_out.iter().take(top_n) { if s.fan_out > 0 { - result.push(Hotspot { name: s.name.clone(), kind: HotspotKind::FanOut, score: s.fan_out }); + result.push(Hotspot { + name: s.name.clone(), + kind: HotspotKind::FanOut, + score: s.fan_out, + }); } } result } -pub fn analyse(crate_graph: &CrateGraph, module_graphs: &[ModuleGraph], top_n: usize) -> GraphStats { +pub fn analyse( + crate_graph: &CrateGraph, + module_graphs: &[ModuleGraph], + top_n: usize, +) -> GraphStats { let crate_stats = fan_stats(&crate_graph.nodes, &crate_graph.edges); let crate_hotspots = hotspots(&crate_stats, top_n); @@ -92,10 +107,15 @@ pub fn analyse(crate_graph: &CrateGraph, module_graphs: &[ModuleGraph], top_n: u let node_stats = fan_stats(&mg.nodes, &mg.edges); let cycles = detect_cycles(&mg.nodes, &mg.edges); all_cycles.extend(cycles.clone()); - mod_stats_list.push(ModStats { krate: mg.krate.clone(), nodes: node_stats, cycles }); + mod_stats_list.push(ModStats { + krate: mg.krate.clone(), + nodes: node_stats, + cycles, + }); } - let all_mod_fan: Vec = mod_stats_list.iter() + let all_mod_fan: Vec = mod_stats_list + .iter() .flat_map(|ms| ms.nodes.iter().cloned()) .collect(); let mod_hotspots = hotspots(&all_mod_fan, top_n); @@ -156,13 +176,28 @@ mod tests { #[test] fn hotspots_picks_top_n_by_fan_in_and_fan_out() { let stats = vec![ - FanStats { name: "a".to_string(), fan_in: 5, fan_out: 1 }, - FanStats { name: "b".to_string(), fan_in: 3, fan_out: 2 }, - FanStats { name: "c".to_string(), fan_in: 1, fan_out: 4 }, + FanStats { + name: "a".to_string(), + fan_in: 5, + fan_out: 1, + }, + FanStats { + name: "b".to_string(), + fan_in: 3, + fan_out: 2, + }, + FanStats { + name: "c".to_string(), + fan_in: 1, + fan_out: 4, + }, ]; let spots = hotspots(&stats, 1); let fi = spots.iter().find(|h| h.kind == HotspotKind::FanIn).unwrap(); - let fo = spots.iter().find(|h| h.kind == HotspotKind::FanOut).unwrap(); + let fo = spots + .iter() + .find(|h| h.kind == HotspotKind::FanOut) + .unwrap(); assert_eq!(fi.name, "a"); assert_eq!(fo.name, "c"); } diff --git a/crates/notgraph/src/crate_graph.rs b/crates/notgraph/src/crate_graph.rs index bc17514..45aaba2 100644 --- a/crates/notgraph/src/crate_graph.rs +++ b/crates/notgraph/src/crate_graph.rs @@ -17,7 +17,11 @@ pub fn build(manifest_path: &std::path::Path) -> Result { let nodes: Vec = workspace_members.iter().cloned().collect(); let mut edges: Vec<(CrateName, CrateName)> = Vec::new(); - for pkg in meta.packages.iter().filter(|p| workspace_members.contains(&p.name)) { + for pkg in meta + .packages + .iter() + .filter(|p| workspace_members.contains(&p.name)) + { for dep in &pkg.dependencies { if workspace_members.contains(&dep.name) { edges.push((pkg.name.clone(), dep.name.clone())); @@ -35,15 +39,21 @@ mod tests { #[test] fn builds_crate_graph_for_workspace() { let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent().unwrap() - .parent().unwrap() + .parent() + .unwrap() + .parent() + .unwrap() .join("Cargo.toml"); let graph = build(&manifest).unwrap(); assert!(graph.nodes.contains(&"notgraph".to_string())); assert!(graph.nodes.contains(&"notcore".to_string())); - assert!(graph.edges.contains(&("notstrap".to_string(), "notfiles".to_string()))); + assert!( + graph + .edges + .contains(&("notstrap".to_string(), "notfiles".to_string())) + ); assert!(!graph.edges.iter().any(|(from, _)| from == "notcore")); } } diff --git a/crates/notgraph/src/emit.rs b/crates/notgraph/src/emit.rs index ea325fa..e5b30fc 100644 --- a/crates/notgraph/src/emit.rs +++ b/crates/notgraph/src/emit.rs @@ -1,17 +1,18 @@ -use crate::types::{CrateGraph, GraphStats, HotspotKind, SymbolKind, SymbolTable}; +use crate::types::{CrateGraph, GraphStats, HotspotKind, ModuleGraph, SymbolKind, SymbolTable}; use anyhow::Result; use std::path::Path; pub fn write_all( output_dir: &Path, crate_graph: &CrateGraph, + module_graphs: &[ModuleGraph], 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, crate_graph, stats)?; + write_html(output_dir, crate_graph, module_graphs, stats, symbol_tables)?; Ok(()) } @@ -110,17 +111,54 @@ fn write_markdown(dir: &Path, stats: &GraphStats, symbol_tables: &[SymbolTable]) Ok(()) } -fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Result<()> { +fn write_html( + dir: &Path, + crate_graph: &CrateGraph, + module_graphs: &[ModuleGraph], + stats: &GraphStats, + symbol_tables: &[SymbolTable], +) -> Result<()> { 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(); - // Classify nodes into subgraphs by topology: - // leaf = no outgoing edges to workspace crates (fan-out == 0) - // root = no incoming edges from workspace crates (fan-in == 0) - // feature = everything else + // Symbol counts per crate + let sym_count: std::collections::HashMap<&str, usize> = symbol_tables + .iter() + .map(|t| { + let count = t.symbols.iter().filter(|s| s.is_pub).count(); + (t.krate.as_str(), count) + }) + .collect(); + + // Nodes involved in any cycle (for red highlighting) + let cycle_nodes: std::collections::HashSet<&str> = stats + .cycles + .iter() + .flat_map(|c| c.iter().map(|n| n.as_str())) + .collect(); + + // Max fan-in for heatmap normalisation + let max_fan_in = stats + .crate_graph + .iter() + .map(|fs| fs.fan_in) + .max() + .unwrap_or(1) + .max(1); + + // Heatmap: interpolate between dim blue and bright blue based on fan-in + let heatmap_color = |fan_in: usize| -> String { + let t = fan_in as f32 / max_fan_in as f32; + let r = (30.0 + t * 20.0) as u8; + let g = (58.0 + t * 80.0) as u8; + let b = (95.0 + t * 160.0) as u8; + format!("#{:02x}{:02x}{:02x}", r, g, b) + }; + + // Classify nodes into subgraphs by topology let leaf_nodes: std::collections::HashSet<&str> = stats .crate_graph .iter() @@ -146,7 +184,6 @@ fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Resul .map(|n| n.as_str()) .filter(|n| !leaf_nodes.contains(n) && !root_nodes.contains(n)) .collect(); - // Nodes that are both leaf and root (isolated, e.g. notgraph) go into roots let mut isolated: Vec<&str> = crate_graph.nodes.iter() .map(|n| n.as_str()) .filter(|n| leaf_nodes.contains(n) && root_nodes.contains(n)) @@ -159,47 +196,114 @@ fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Resul let node_label = |n: &str| -> String { let (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0)); - format!("{}[\"{}
in:{} out:{}\"]", n, n, fi, fo) + let syms = sym_count.get(n).copied().unwrap_or(0); + format!( + "{}[\"{}
in:{} out:{} | {} pub\"]", + n, n, fi, fo, syms + ) }; - // Mermaid flowchart: dependency -> dependent (LR) + // ── Crate dependency graph ────────────────────────────────────────────── let mut mermaid = String::from("flowchart LR\n"); if !leaves.is_empty() { mermaid.push_str(" subgraph Core\n direction TB\n"); - for n in &leaves { - mermaid.push_str(&format!(" {}\n", node_label(n))); - } + for n in &leaves { mermaid.push_str(&format!(" {}\n", node_label(n))); } mermaid.push_str(" end\n"); } if !features.is_empty() { mermaid.push_str(" subgraph Features\n direction TB\n"); - for n in &features { - mermaid.push_str(&format!(" {}\n", node_label(n))); - } + for n in &features { mermaid.push_str(&format!(" {}\n", node_label(n))); } mermaid.push_str(" end\n"); } if !roots.is_empty() { mermaid.push_str(" subgraph Tools\n direction TB\n"); - for n in &roots { - mermaid.push_str(&format!(" {}\n", node_label(n))); - } + for n in &roots { mermaid.push_str(&format!(" {}\n", node_label(n))); } mermaid.push_str(" end\n"); } for (from, to) in &crate_graph.edges { - // dependency -> dependent mermaid.push_str(&format!(" {} --> {}\n", to, from)); } + + // Heatmap styles + for fs in &stats.crate_graph { + let color = heatmap_color(fs.fan_in); + mermaid.push_str(&format!( + " style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n", + fs.name, color + )); + } + + // Cycle highlights (override heatmap) + for n in &cycle_nodes { + mermaid.push_str(&format!( + " style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n", + n + )); + } + let graph_diagram = mermaid; + // Build cycle lookup from stats (ModuleGraph itself has no cycle data) + let mod_cycles: std::collections::HashMap<&str, &Vec>> = stats + .module_graphs + .iter() + .map(|ms| (ms.krate.as_str(), &ms.cycles)) + .collect(); + + // ── Per-crate module graphs ───────────────────────────────────────────── + let mut module_diagrams = String::new(); + for mg in module_graphs { + // Collect cycle nodes for this crate + let empty: Vec> = Vec::new(); + let cycles = mod_cycles.get(mg.krate.as_str()).copied().unwrap_or(&empty); + let crate_cycle_nodes: std::collections::HashSet<&str> = cycles + .iter() + .flat_map(|c| c.iter().map(|n| n.as_str())) + .collect(); + + let mut d = format!("flowchart TD\n"); + + // Nodes: strip crate prefix for readable labels, skip ::tests modules + for node in &mg.nodes { + let short = node + .strip_prefix(&format!("{}::", mg.krate)) + .unwrap_or(node.as_str()); + // Sanitize id for Mermaid (replace :: with __) + let id = node.replace("::", "__"); + d.push_str(&format!(" {}[\"{}\"]\n", id, short)); + } + + // Edges + for (from, to) in &mg.edges { + let from_id = from.replace("::", "__"); + let to_id = to.replace("::", "__"); + d.push_str(&format!(" {} --> {}\n", from_id, to_id)); + } + + // Cycle highlights + for n in &crate_cycle_nodes { + let id = n.replace("::", "__"); + d.push_str(&format!( + " style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n", + id + )); + } + + module_diagrams.push_str(&format!( + "

{}

\n
\n%%{{init: {{\"theme\":\"dark\"}}}}\n{}
\n", + mg.krate, d + )); + } + let cycle_html = if stats.cycles.is_empty() { "

No cycles detected.

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

CYCLE: {}

", c.join(" -> "))) + .map(|c| format!("

CYCLE: {}

", c.join(" → "))) .collect::>() .join("\n") }; @@ -207,18 +311,17 @@ fn write_html(dir: &Path, crate_graph: &CrateGraph, stats: &GraphStats) -> Resul let hotspot_rows: String = stats .hotspots .iter() - .map(|h| { - format!( - "{}{}{}", - h.name, h.kind, h.score - ) - }) + .map(|h| format!( + "{}{}{}", + h.name, h.kind, h.score + )) .collect::>() .join("\n"); let html = format!( include_str!("../templates/report.html.template"), graph_diagram = graph_diagram, + module_diagrams = module_diagrams, hotspot_rows = hotspot_rows, cycle_html = cycle_html, ); diff --git a/crates/notgraph/src/main.rs b/crates/notgraph/src/main.rs index c630105..a47472a 100644 --- a/crates/notgraph/src/main.rs +++ b/crates/notgraph/src/main.rs @@ -63,7 +63,7 @@ fn main() -> Result<()> { workspace_root.join(&cli.output) }; - emit::write_all(&output_dir, &crate_g, &stats, &symbol_tables) + emit::write_all(&output_dir, &crate_g, &module_graphs, &stats, &symbol_tables) .context("failed to write reports")?; println!("notgraph: reports written to {}", output_dir.display()); diff --git a/crates/notgraph/src/module_graph.rs b/crates/notgraph/src/module_graph.rs index 6001b8d..de2f5d4 100644 --- a/crates/notgraph/src/module_graph.rs +++ b/crates/notgraph/src/module_graph.rs @@ -33,7 +33,6 @@ impl<'ast> Visit<'ast> for ModCollector { } } - pub fn build(krate: CrateName, src_dir: &Path) -> Result { let mut all_nodes: Vec = Vec::new(); let mut all_edges: Vec<(ModPath, ModPath)> = Vec::new(); @@ -63,7 +62,11 @@ pub fn build(krate: CrateName, src_dir: &Path) -> Result { all_edges.sort(); all_edges.dedup(); - Ok(ModuleGraph { krate, nodes: all_nodes, edges: all_edges }) + Ok(ModuleGraph { + krate, + nodes: all_nodes, + edges: all_edges, + }) } #[cfg(test)] @@ -72,8 +75,8 @@ mod tests { #[test] fn clean_fixture_has_expected_nodes() { - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/clean/src"); + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/clean/src"); let graph = build("clean".to_string(), &fixture).unwrap(); assert!(graph.nodes.contains(&"clean".to_string())); assert!(graph.nodes.contains(&"clean::alpha".to_string())); @@ -82,10 +85,18 @@ mod tests { #[test] fn clean_fixture_has_edges_from_lib() { - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/clean/src"); + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/clean/src"); let graph = build("clean".to_string(), &fixture).unwrap(); - assert!(graph.edges.contains(&("clean".to_string(), "clean::alpha".to_string()))); - assert!(graph.edges.contains(&("clean".to_string(), "clean::beta".to_string()))); + assert!( + graph + .edges + .contains(&("clean".to_string(), "clean::alpha".to_string())) + ); + assert!( + graph + .edges + .contains(&("clean".to_string(), "clean::beta".to_string())) + ); } } diff --git a/crates/notgraph/src/symbols.rs b/crates/notgraph/src/symbols.rs index f1b6d80..6249053 100644 --- a/crates/notgraph/src/symbols.rs +++ b/crates/notgraph/src/symbols.rs @@ -11,7 +11,10 @@ struct SymbolCollector { impl SymbolCollector { fn new(mod_path: ModPath) -> Self { - Self { mod_path, symbols: Vec::new() } + Self { + mod_path, + symbols: Vec::new(), + } } fn is_pub(vis: &Visibility) -> bool { @@ -19,28 +22,57 @@ impl SymbolCollector { } fn push(&mut self, kind: SymbolKind, name: String, is_pub: bool) { - self.symbols.push(Symbol { mod_path: self.mod_path.clone(), kind, name, is_pub }); + self.symbols.push(Symbol { + mod_path: self.mod_path.clone(), + kind, + name, + is_pub, + }); } } impl<'ast> Visit<'ast> for SymbolCollector { fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) { - self.push(SymbolKind::Struct, node.ident.to_string(), Self::is_pub(&node.vis)); + self.push( + SymbolKind::Struct, + node.ident.to_string(), + Self::is_pub(&node.vis), + ); } fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) { - self.push(SymbolKind::Enum, node.ident.to_string(), Self::is_pub(&node.vis)); + self.push( + SymbolKind::Enum, + node.ident.to_string(), + Self::is_pub(&node.vis), + ); } fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) { - self.push(SymbolKind::Trait, node.ident.to_string(), Self::is_pub(&node.vis)); + self.push( + SymbolKind::Trait, + node.ident.to_string(), + Self::is_pub(&node.vis), + ); } fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) { - self.push(SymbolKind::Fn, node.sig.ident.to_string(), Self::is_pub(&node.vis)); + self.push( + SymbolKind::Fn, + node.sig.ident.to_string(), + Self::is_pub(&node.vis), + ); } fn visit_item_type(&mut self, node: &'ast syn::ItemType) { - self.push(SymbolKind::Type, node.ident.to_string(), Self::is_pub(&node.vis)); + self.push( + SymbolKind::Type, + node.ident.to_string(), + Self::is_pub(&node.vis), + ); } fn visit_item_const(&mut self, node: &'ast syn::ItemConst) { - self.push(SymbolKind::Const, node.ident.to_string(), Self::is_pub(&node.vis)); + self.push( + SymbolKind::Const, + node.ident.to_string(), + Self::is_pub(&node.vis), + ); } fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { let child_path = format!("{}::{}", self.mod_path, node.ident); @@ -50,7 +82,6 @@ impl<'ast> Visit<'ast> for SymbolCollector { } } - pub fn build(krate: CrateName, src_dir: &Path) -> Result { let mut symbols: Vec = Vec::new(); for entry in WalkDir::new(src_dir) @@ -75,45 +106,69 @@ mod tests { use super::*; fn fixture_table() -> SymbolTable { - let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/symbols/src"); + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/symbols/src"); build("symbols".to_string(), &fixture).unwrap() } #[test] fn detects_pub_struct() { let t = fixture_table(); - assert!(t.symbols.iter().any(|s| s.name == "Foo" && s.kind == SymbolKind::Struct && s.is_pub)); + assert!( + t.symbols + .iter() + .any(|s| s.name == "Foo" && s.kind == SymbolKind::Struct && s.is_pub) + ); } #[test] fn detects_pub_enum() { let t = fixture_table(); - assert!(t.symbols.iter().any(|s| s.name == "Bar" && s.kind == SymbolKind::Enum && s.is_pub)); + assert!( + t.symbols + .iter() + .any(|s| s.name == "Bar" && s.kind == SymbolKind::Enum && s.is_pub) + ); } #[test] fn detects_pub_trait() { let t = fixture_table(); - assert!(t.symbols.iter().any(|s| s.name == "Baz" && s.kind == SymbolKind::Trait && s.is_pub)); + assert!( + t.symbols + .iter() + .any(|s| s.name == "Baz" && s.kind == SymbolKind::Trait && s.is_pub) + ); } #[test] fn detects_pub_fn() { let t = fixture_table(); - assert!(t.symbols.iter().any(|s| s.name == "qux" && s.kind == SymbolKind::Fn && s.is_pub)); + assert!( + t.symbols + .iter() + .any(|s| s.name == "qux" && s.kind == SymbolKind::Fn && s.is_pub) + ); } #[test] fn detects_pub_type_alias() { let t = fixture_table(); - assert!(t.symbols.iter().any(|s| s.name == "Alias" && s.kind == SymbolKind::Type && s.is_pub)); + assert!( + t.symbols + .iter() + .any(|s| s.name == "Alias" && s.kind == SymbolKind::Type && s.is_pub) + ); } #[test] fn detects_pub_const() { let t = fixture_table(); - assert!(t.symbols.iter().any(|s| s.name == "VALUE" && s.kind == SymbolKind::Const && s.is_pub)); + assert!( + t.symbols + .iter() + .any(|s| s.name == "VALUE" && s.kind == SymbolKind::Const && s.is_pub) + ); } #[test] diff --git a/crates/notgraph/src/types.rs b/crates/notgraph/src/types.rs index e8e4cda..f09b03c 100644 --- a/crates/notgraph/src/types.rs +++ b/crates/notgraph/src/types.rs @@ -38,11 +38,11 @@ impl std::fmt::Display for SymbolKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { SymbolKind::Struct => write!(f, "struct"), - SymbolKind::Enum => write!(f, "enum"), - SymbolKind::Trait => write!(f, "trait"), - SymbolKind::Fn => write!(f, "fn"), - SymbolKind::Type => write!(f, "type"), - SymbolKind::Const => write!(f, "const"), + SymbolKind::Enum => write!(f, "enum"), + SymbolKind::Trait => write!(f, "trait"), + SymbolKind::Fn => write!(f, "fn"), + SymbolKind::Type => write!(f, "type"), + SymbolKind::Const => write!(f, "const"), } } } @@ -76,7 +76,7 @@ pub enum HotspotKind { impl std::fmt::Display for HotspotKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - HotspotKind::FanIn => write!(f, "fan-in"), + HotspotKind::FanIn => write!(f, "fan-in"), HotspotKind::FanOut => write!(f, "fan-out"), } } @@ -91,10 +91,10 @@ pub struct Hotspot { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GraphStats { - pub crate_graph: Vec, + pub crate_graph: Vec, pub module_graphs: Vec, - pub hotspots: Vec, - pub cycles: Vec>, + pub hotspots: Vec, + pub cycles: Vec>, } /// Convert a source file path (relative to src/) to a Rust module path like `crate::foo::bar`. diff --git a/crates/notgraph/templates/report.html.template b/crates/notgraph/templates/report.html.template index 2b60246..304f4cc 100644 --- a/crates/notgraph/templates/report.html.template +++ b/crates/notgraph/templates/report.html.template @@ -13,12 +13,15 @@ --text: #e0e0e0; --accent: #4a9eff; --heading-border: #4a9eff; + --cycle-bg: #3a1a1a; + --cycle-text: #f44336; }} * {{ box-sizing: border-box; }} body {{ font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 2rem; max-width: 1200px; }} h1 {{ color: var(--accent); margin-bottom: 0.5rem; }} - h2 {{ border-bottom: 2px solid var(--heading-border); padding-bottom: 0.3rem; color: var(--text); margin-top: 2rem; }} - .graph-wrap {{ background: var(--bg2); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 2rem; overflow-x: auto; }} + h2 {{ border-bottom: 2px solid var(--heading-border); padding-bottom: 0.3rem; color: var(--text); margin-top: 2.5rem; }} + h3 {{ color: var(--accent); margin-top: 1.5rem; margin-bottom: 0.5rem; }} + .graph-wrap {{ background: var(--bg2); border: 1px solid var(--border); border-radius: 4px; padding: 1.5rem; margin-bottom: 1.5rem; overflow-x: auto; }} table {{ border-collapse: collapse; width: 100%; margin-bottom: 2rem; }} th, td {{ border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; }} th {{ background: var(--bg3); cursor: pointer; user-select: none; color: var(--accent); }} @@ -26,16 +29,30 @@ td {{ background: var(--bg2); }} tr:hover td {{ background: var(--bg3); }} p {{ color: var(--text); }} + .cycle-entry {{ background: var(--cycle-bg); color: var(--cycle-text); padding: 0.4rem 0.8rem; border-left: 3px solid var(--cycle-text); border-radius: 2px; font-family: monospace; }} + .legend {{ display: flex; gap: 1.5rem; margin-bottom: 1rem; font-size: 0.85rem; color: #888; }} + .legend-item {{ display: flex; align-items: center; gap: 0.4rem; }} + .legend-swatch {{ width: 14px; height: 14px; border-radius: 2px; border: 1px solid #4a9eff; }}

notgraph Report

+

Crate Dependency Graph

+
+
low fan-in
+
high fan-in
+
cycle
+
 %%{{init: {{'theme':'dark','themeVariables':{{'darkMode':true,'background':'#242424','primaryColor':'#1e3a5f','primaryBorderColor':'#4a9eff','primaryTextColor':'#e0e0e0','lineColor':'#4a9eff','edgeLabelBackground':'#242424'}}}}}}%%
 {graph_diagram}
+ +

Module Graphs

+{module_diagrams} +

Hotspots

@@ -45,8 +62,10 @@ {hotspot_rows}
+

Cycle Report

{cycle_html} +