feat(notgraph): heatmap coloring, symbol counts, per-crate module graphs, cycle callouts

This commit is contained in:
Joseph O'Brien
2026-04-02 20:53:07 -04:00
parent 5c14230935
commit b5a27108d0
28 changed files with 490 additions and 167 deletions

View File

@@ -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<FanStats> {
@@ -10,11 +8,14 @@ pub fn fan_stats(nodes: &[String], edges: &[(String, String)]) -> Vec<FanStats>
*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<Vec<St
}
}
let mut queue: VecDeque<&str> = 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<Vec<St
return vec![];
}
let cycle_nodes: Vec<String> = in_degree.iter()
let cycle_nodes: Vec<String> = 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<Hotspot> {
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<Hotspot> {
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<FanStats> = mod_stats_list.iter()
let all_mod_fan: Vec<FanStats> = 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");
}

View File

@@ -17,7 +17,11 @@ pub fn build(manifest_path: &std::path::Path) -> Result<CrateGraph> {
let nodes: Vec<CrateName> = 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"));
}
}

View File

@@ -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!("{}[\"{}<br/>in:{} out:{}\"]", n, n, fi, fo)
let syms = sym_count.get(n).copied().unwrap_or(0);
format!(
"{}[\"{}<br/>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<Vec<String>>> = 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<String>> = 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!(
"<h3>{}</h3>\n<div class=\"graph-wrap\"><pre class=\"mermaid\">\n%%{{init: {{\"theme\":\"dark\"}}}}\n{}</pre></div>\n",
mg.krate, d
));
}
let cycle_html = if stats.cycles.is_empty() {
"<p>No cycles detected.</p>".to_string()
} else {
stats
.cycles
.iter()
.map(|c| format!("<p>CYCLE: {}</p>", c.join(" -&gt; ")))
.map(|c| format!("<p class=\"cycle-entry\">CYCLE: {}</p>", c.join(" &rarr; ")))
.collect::<Vec<_>>()
.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!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
h.name, h.kind, h.score
)
})
.map(|h| format!(
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
h.name, h.kind, h.score
))
.collect::<Vec<_>>()
.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,
);

View File

@@ -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());

View File

@@ -33,7 +33,6 @@ impl<'ast> Visit<'ast> for ModCollector {
}
}
pub fn build(krate: CrateName, src_dir: &Path) -> Result<ModuleGraph> {
let mut all_nodes: Vec<ModPath> = Vec::new();
let mut all_edges: Vec<(ModPath, ModPath)> = Vec::new();
@@ -63,7 +62,11 @@ pub fn build(krate: CrateName, src_dir: &Path) -> Result<ModuleGraph> {
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()))
);
}
}

View File

@@ -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<SymbolTable> {
let mut symbols: Vec<Symbol> = 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]

View File

@@ -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<FanStats>,
pub crate_graph: Vec<FanStats>,
pub module_graphs: Vec<ModStats>,
pub hotspots: Vec<Hotspot>,
pub cycles: Vec<Vec<ModPath>>,
pub hotspots: Vec<Hotspot>,
pub cycles: Vec<Vec<ModPath>>,
}
/// Convert a source file path (relative to src/) to a Rust module path like `crate::foo::bar`.