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`.

View File

@@ -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; }}
</style>
</head>
<body>
<h1>notgraph Report</h1>
<h2>Crate Dependency Graph</h2>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:#1e3a5f"></div> low fan-in</div>
<div class="legend-item"><div class="legend-swatch" style="background:#1e72ff"></div> high fan-in</div>
<div class="legend-item"><div class="legend-swatch" style="background:#7a1a1a;border-color:#f44336"></div> cycle</div>
</div>
<div class="graph-wrap">
<pre class="mermaid">
%%{{init: {{'theme':'dark','themeVariables':{{'darkMode':true,'background':'#242424','primaryColor':'#1e3a5f','primaryBorderColor':'#4a9eff','primaryTextColor':'#e0e0e0','lineColor':'#4a9eff','edgeLabelBackground':'#242424'}}}}}}%%
{graph_diagram}</pre>
</div>
<h2>Module Graphs</h2>
{module_diagrams}
<h2>Hotspots</h2>
<table id="hotspots">
<thead><tr>
@@ -45,8 +62,10 @@
</tr></thead>
<tbody>{hotspot_rows}</tbody>
</table>
<h2>Cycle Report</h2>
{cycle_html}
<script>
mermaid.initialize({{ startOnLoad: true }});
function sortTable(id, col, th) {{

View File

@@ -14,7 +14,8 @@ fn clean_fixture_has_no_cycles() {
assert!(
analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(),
"expected no cycles; nodes={:?} edges={:?}",
mg.nodes, mg.edges
mg.nodes,
mg.edges
);
}
@@ -38,8 +39,14 @@ fn symbols_fixture_has_six_public_symbols() {
let table = symbols::build("symbols".to_string(), &fixtures("symbols")).unwrap();
let pub_count = table.symbols.iter().filter(|s| s.is_pub).count();
assert_eq!(
pub_count, 6,
pub_count,
6,
"expected 6 public symbols (Foo, Bar, Baz, qux, Alias, VALUE), got: {:?}",
table.symbols.iter().filter(|s| s.is_pub).map(|s| &s.name).collect::<Vec<_>>()
table
.symbols
.iter()
.filter(|s| s.is_pub)
.map(|s| &s.name)
.collect::<Vec<_>>()
);
}

View File

@@ -0,0 +1,59 @@
use notsecrets::{Decryptor, Encryptor, X25519Identity, X25519Recipient};
use rand::rngs::OsRng;
use x25519_dalek::{PublicKey, StaticSecret};
fn main() {
// 1. Generate a fresh keypair
let secret = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);
let identity = X25519Identity::from_static_secret(secret);
let recipient = X25519Recipient::from_public_key(public);
println!("Recipient: {}", recipient.to_bech32());
println!("Identity: {}", identity.to_bech32());
// 2. Build plaintext from env
let plaintext = [
(
"ANTHROPIC_API_KEY",
std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(),
),
(
"OPENAI_API_KEY",
std::env::var("OPENAI_API_KEY").unwrap_or_default(),
),
(
"GEMINI_API_KEY",
std::env::var("GEMINI_API_KEY").unwrap_or_default(),
),
]
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("\n");
let plaintext = plaintext.as_bytes();
// 3. Encrypt
let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
let ciphertext = encryptor.encrypt(plaintext).unwrap();
println!("\nCiphertext: {} bytes", ciphertext.len());
println!(
"Header: {}",
std::str::from_utf8(&ciphertext[..22]).unwrap()
);
// 4. Write to file
let out_path = "/tmp/secrets.age";
std::fs::write(out_path, &ciphertext).unwrap();
println!("Written to: {out_path}");
// 5. Decrypt back
let decryptor = Decryptor::with_identities(vec![Box::new(identity)]);
let decrypted = decryptor.decrypt(&ciphertext).unwrap();
assert_eq!(decrypted, plaintext);
println!("\nDecrypted:\n{}", std::str::from_utf8(&decrypted).unwrap());
println!("\n✓ Round-trip success");
}

View File

@@ -1,10 +1,7 @@
use crate::error::AgeError;
use crate::format::{header_bytes_up_to_footer, parse_header};
use crate::identities::{FileKey, Identity};
use chacha20poly1305::{
aead::Aead,
ChaCha20Poly1305, Key, KeyInit, Nonce,
};
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use sha2::Sha256;
@@ -86,9 +83,9 @@ fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32],
#[cfg(test)]
mod tests {
use super::*;
use crate::Encryptor;
use crate::identities::x25519::X25519Identity;
use crate::recipients::x25519::X25519Recipient;
use crate::Encryptor;
use rand::rngs::OsRng;
use x25519_dalek::{PublicKey, StaticSecret};
@@ -116,11 +113,8 @@ mod tests {
fn decryptor_roundtrip_multiple_recipients() {
let (identity1, recipient1) = make_x25519_pair();
let (identity2, recipient2) = make_x25519_pair();
let encryptor = Encryptor::with_recipients(vec![
Box::new(recipient1),
Box::new(recipient2),
])
.unwrap();
let encryptor =
Encryptor::with_recipients(vec![Box::new(recipient1), Box::new(recipient2)]).unwrap();
let plaintext = b"multi-recipient test";
let ciphertext = encryptor.encrypt(plaintext).unwrap();
let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]);

View File

@@ -2,10 +2,7 @@ use crate::error::AgeError;
use crate::format::serialize_header;
use crate::identities::{FileKey, Header};
use crate::recipients::Recipient;
use chacha20poly1305::{
aead::Aead,
ChaCha20Poly1305, Key, KeyInit, Nonce,
};
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use rand::RngCore;
@@ -129,7 +126,9 @@ mod tests {
Box::new(X25519Recipient::from_public_key(public));
let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap();
let plaintext = b"hello, age!";
let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed");
let ciphertext = encryptor
.encrypt(plaintext)
.expect("encrypt should succeed");
assert!(
ciphertext.starts_with(b"age-encryption.org/v1\n"),
"output must start with age version line"

View File

@@ -11,9 +11,7 @@ const FOOTER_PREFIX: &[u8] = b"--- ";
/// index into `input` where the payload (nonce + ciphertext) begins.
pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> {
if !input.starts_with(VERSION_LINE) {
return Err(AgeError::ParseError(
"missing age version line".to_string(),
));
return Err(AgeError::ParseError("missing age version line".to_string()));
}
let mut pos = VERSION_LINE.len();
@@ -176,11 +174,15 @@ mod tests {
assert_eq!(header.recipients[0].args.len(), 1);
assert!(header.recipients[0].body.is_empty());
// payload offset should point to the 32 bytes at the end
assert_eq!(&input[payload_offset..], {
let mut v = vec![0x01u8; 16];
v.extend_from_slice(&[0x02u8; 16]);
v
}.as_slice());
assert_eq!(
&input[payload_offset..],
{
let mut v = vec![0x01u8; 16];
v.extend_from_slice(&[0x02u8; 16]);
v
}
.as_slice()
);
}
#[test]
@@ -208,7 +210,10 @@ mod tests {
fn parse_missing_version_line_returns_error() {
let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n";
let result = parse_header(input);
assert!(result.is_err(), "expected ParseError for missing version line");
assert!(
result.is_err(),
"expected ParseError for missing version line"
);
}
#[test]
@@ -233,7 +238,10 @@ mod tests {
args: vec!["salt".to_string(), "18".to_string()],
body: body.clone(),
};
let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] };
let header = Header {
recipients: vec![stanza],
mac: vec![0u8; 32],
};
let serialized = serialize_header(&header);
let mut with_payload = serialized;
with_payload.extend_from_slice(&[0u8; 32]);

View File

@@ -36,7 +36,7 @@ impl Identity for EncryptedIdentity {
Err(_) => {
return Some(Err(AgeError::ParseError(
"decrypted identity file is not valid UTF-8".to_string(),
)))
)));
}
};
// Parse inner identity — only X25519 supported for now

View File

@@ -39,9 +39,9 @@ impl FileKey {
impl TryFrom<&[u8]> for FileKey {
type Error = AgeError;
fn try_from(b: &[u8]) -> Result<Self, AgeError> {
b.try_into()
.map(Self)
.map_err(|_| AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len())))
b.try_into().map(Self).map_err(|_| {
AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len()))
})
}
}

View File

@@ -63,8 +63,8 @@ pub(crate) fn derive_scrypt_key(
mod tests {
use super::*;
use crate::identities::FileKey;
use crate::recipients::scrypt::ScryptRecipient;
use crate::recipients::Recipient;
use crate::recipients::scrypt::ScryptRecipient;
#[test]
fn scrypt_wrap_unwrap_roundtrip() {
@@ -73,7 +73,9 @@ mod tests {
let identity = ScryptIdentity::new(passphrase);
let file_key = FileKey::new([0x11u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "scrypt");
assert_eq!(stanza.args.len(), 2);

View File

@@ -46,7 +46,11 @@ impl Identity for SshEd25519Identity {
}
let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
Err(e) => {
return Some(Err(AgeError::ParseError(format!(
"fingerprint base64: {e}"
))));
}
};
if fp_bytes.as_slice() != self.fingerprint() {
return None;
@@ -68,8 +72,11 @@ fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result<FileKey, Age
let shared = x25519_secret.diffie_hellman(&ephemeral_pub);
let x25519_pub = X25519PublicKey::from(&x25519_secret);
let wrap_key =
derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), x25519_pub.as_bytes())?;
let wrap_key = derive_wrap_key(
shared.as_bytes(),
ephemeral_pub.as_bytes(),
x25519_pub.as_bytes(),
)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
let nonce = Nonce::default();
@@ -104,8 +111,8 @@ pub(crate) fn derive_wrap_key(
mod tests {
use super::*;
use crate::identities::FileKey;
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
use crate::recipients::Recipient;
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
fn test_signing_key() -> SigningKey {
use rand::rngs::OsRng;
@@ -123,7 +130,9 @@ mod tests {
let identity = SshEd25519Identity::from_signing_key(signing_key);
let file_key = FileKey::new([0x33u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "ssh-ed25519");
assert_eq!(stanza.args.len(), 2);

View File

@@ -32,7 +32,11 @@ impl Identity for SshRsaIdentity {
}
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
Ok(b) => b,
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
Err(e) => {
return Some(Err(AgeError::ParseError(format!(
"fingerprint base64: {e}"
))));
}
};
if fp.as_slice() != self.fingerprint() {
return None;
@@ -62,8 +66,8 @@ pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
#[cfg(test)]
mod tests {
use super::*;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use crate::recipients::Recipient;
use crate::recipients::ssh_rsa::SshRsaRecipient;
use rand::rngs::OsRng;
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
@@ -79,7 +83,9 @@ mod tests {
let identity = SshRsaIdentity::from_private_key(private_key);
let file_key = FileKey::new([0x55u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "ssh-rsa");
assert_eq!(stanza.args.len(), 1);

View File

@@ -119,7 +119,9 @@ mod tests {
let recipient = X25519Recipient::from_public_key(public);
let identity = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes));
let file_key = FileKey::new([0x42u8; 16]);
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
let stanza = recipient
.wrap_file_key(&file_key)
.expect("wrap should succeed");
assert_eq!(stanza.tag, "X25519");
assert_eq!(stanza.args.len(), 1);
let unwrapped = identity
@@ -148,7 +150,8 @@ mod tests {
let public = PublicKey::from(&secret);
let recipient_str = X25519Recipient::from_public_key(public).to_bech32();
assert!(recipient_str.starts_with("age1"));
let identity_str = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32();
let identity_str =
X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32();
assert!(identity_str.starts_with("AGE-SECRET-KEY-1"));
}

View File

@@ -90,10 +90,8 @@ mod tests {
#[test]
fn resolve_identities_partial_success_returns_loaded() {
let sources: Vec<Box<dyn IdentitySource>> = vec![
Box::new(AlwaysFailSource),
Box::new(StaticX25519Source),
];
let sources: Vec<Box<dyn IdentitySource>> =
vec![Box::new(AlwaysFailSource), Box::new(StaticX25519Source)];
let ids = resolve_identities(sources).expect("at least one source succeeded");
assert_eq!(ids.len(), 1);
}

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Stanza};
use crate::identities::scrypt::derive_scrypt_key;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
@@ -17,7 +17,10 @@ pub struct ScryptRecipient {
impl ScryptRecipient {
/// `work_factor` is log2(N). Use 18 in production, 14 in tests.
pub fn new(passphrase: String, work_factor: u8) -> Self {
Self { passphrase, work_factor }
Self {
passphrase,
work_factor,
}
}
}
@@ -35,10 +38,7 @@ impl Recipient for ScryptRecipient {
Ok(Stanza {
tag: TAG.to_string(),
args: vec![
STANDARD_NO_PAD.encode(salt),
self.work_factor.to_string(),
],
args: vec![STANDARD_NO_PAD.encode(salt), self.work_factor.to_string()],
body: wrapped,
})
}

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Stanza};
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use rsa::{Oaep, RsaPublicKey};

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::{FileKey, Stanza};
use crate::identities::x25519::derive_wrap_key;
use crate::identities::{FileKey, Stanza};
use crate::recipients::Recipient;
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
use bech32::{Bech32, Hrp};

View File

@@ -10,7 +10,9 @@ pub struct BitwardenSource {
impl BitwardenSource {
pub fn new(item_name: impl Into<String>) -> Self {
Self { item_name: item_name.into() }
Self {
item_name: item_name.into(),
}
}
fn retrieve_key(&self) -> Result<String, AgeError> {
@@ -28,10 +30,12 @@ impl BitwardenSource {
let session = std::env::var("BW_SESSION").unwrap_or_default();
let session = if session.is_empty() {
let password = rpassword::prompt_password("Bitwarden master password: ")
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read password: {e}"),
let password =
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read password: {e}"),
}
})?;
let output = Command::new("bw")
.args(["unlock", "--raw", &password])
@@ -101,10 +105,11 @@ impl IdentitySource for BitwardenSource {
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
let key = self.retrieve_key()?;
let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
})?;
let identity =
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
})?;
Ok(Box::new(identity))
}
}

View File

@@ -1,7 +1,7 @@
use crate::error::AgeError;
use crate::identities::Identity;
use crate::identities::encrypted::EncryptedIdentity;
use crate::identities::x25519::X25519Identity;
use crate::identities::Identity;
use crate::sources::IdentitySource;
use std::path::PathBuf;
@@ -33,19 +33,19 @@ impl IdentitySource for FileSource {
source: anyhow::anyhow!("key file UTF-8: {e}"),
})?
.trim();
let identity = X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
})?;
let identity =
X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
})?;
Ok(Box::new(identity))
} else if content.starts_with(b"age-encryption.org/v1") {
let passphrase = rpassword::prompt_password(
"Enter passphrase for encrypted identity file: ",
)
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read passphrase: {e}"),
})?;
let passphrase =
rpassword::prompt_password("Enter passphrase for encrypted identity file: ")
.map_err(|e| AgeError::SourceError {
name: self.name().to_string(),
source: anyhow::anyhow!("could not read passphrase: {e}"),
})?;
Ok(Box::new(EncryptedIdentity::new(content, passphrase)))
} else {
Err(AgeError::UnsupportedKeyType(format!(

View File

@@ -1,6 +1,6 @@
use crate::error::AgeError;
use crate::identities::scrypt::ScryptIdentity;
use crate::identities::Identity;
use crate::identities::scrypt::ScryptIdentity;
use crate::sources::IdentitySource;
pub struct PromptSource;

View File

@@ -2,8 +2,8 @@ use anyhow::{Context, Result};
use notcore::{HookPhase, Report, StepStatus};
use notfiles::{LinkOptions, link};
use nothooks::{HookRunner, run_phase};
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use notsecrets::sources::IdentitySource;
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use serde::Deserialize;
use std::path::{Path, PathBuf};

View File

@@ -5,8 +5,8 @@ use tempfile::TempDir;
use notcore::{HookPhase, HookSpec};
use notfiles::{LinkOptions, link};
use nothooks::{HookResult, HookRunner};
use notsecrets::{FileSource, resolve_identities};
use notsecrets::sources::IdentitySource;
use notsecrets::{FileSource, resolve_identities};
/// Test that notsecrets and nothooks can each be used independently
/// in the same integration boundary — resolving an age key from a file