fix(notgraph): sanitize hyphenated crate IDs in Mermaid output; add graph feature integration tests
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -803,6 +803,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"syn",
|
"syn",
|
||||||
|
"tempfile",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ path = "src/lib.rs"
|
|||||||
name = "notgraph"
|
name = "notgraph"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
|
|||||||
@@ -194,12 +194,15 @@ fn write_html(
|
|||||||
isolated.sort();
|
isolated.sort();
|
||||||
roots.extend(isolated);
|
roots.extend(isolated);
|
||||||
|
|
||||||
|
// Sanitize crate name for use as a Mermaid node ID (hyphens are not valid)
|
||||||
|
let mermaid_id = |n: &str| n.replace('-', "_");
|
||||||
|
|
||||||
let node_label = |n: &str| -> String {
|
let node_label = |n: &str| -> String {
|
||||||
let (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0));
|
let (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0));
|
||||||
let syms = sym_count.get(n).copied().unwrap_or(0);
|
let syms = sym_count.get(n).copied().unwrap_or(0);
|
||||||
format!(
|
format!(
|
||||||
"{}[\"{}<br/>in:{} out:{} | {} pub\"]",
|
"{}[\"{}<br/>in:{} out:{} | {} pub\"]",
|
||||||
n, n, fi, fo, syms
|
mermaid_id(n), n, fi, fo, syms
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,7 +226,7 @@ fn write_html(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (from, to) in &crate_graph.edges {
|
for (from, to) in &crate_graph.edges {
|
||||||
mermaid.push_str(&format!(" {} --> {}\n", to, from));
|
mermaid.push_str(&format!(" {} --> {}\n", mermaid_id(to), mermaid_id(from)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heatmap styles
|
// Heatmap styles
|
||||||
@@ -231,7 +234,7 @@ fn write_html(
|
|||||||
let color = heatmap_color(fs.fan_in);
|
let color = heatmap_color(fs.fan_in);
|
||||||
mermaid.push_str(&format!(
|
mermaid.push_str(&format!(
|
||||||
" style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n",
|
" style {} fill:{},stroke:#4a9eff,color:#e0e0e0\n",
|
||||||
fs.name, color
|
mermaid_id(&fs.name), color
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +242,7 @@ fn write_html(
|
|||||||
for n in &cycle_nodes {
|
for n in &cycle_nodes {
|
||||||
mermaid.push_str(&format!(
|
mermaid.push_str(&format!(
|
||||||
" style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n",
|
" style {} fill:#7a1a1a,stroke:#f44336,color:#ffffff\n",
|
||||||
n
|
mermaid_id(n)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +266,7 @@ fn write_html(
|
|||||||
.flat_map(|c| c.iter().map(|n| n.as_str()))
|
.flat_map(|c| c.iter().map(|n| n.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut d = format!("flowchart TD\n");
|
let mut d = "flowchart TD\n".to_string();
|
||||||
|
|
||||||
// Nodes: strip crate prefix for readable labels, skip ::tests modules
|
// Nodes: strip crate prefix for readable labels, skip ::tests modules
|
||||||
for node in &mg.nodes {
|
for node in &mg.nodes {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use notgraph_lib::{analysis, module_graph, symbols};
|
use notgraph_lib::{analysis, emit, module_graph, symbols, types};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn fixtures(name: &str) -> std::path::PathBuf {
|
fn fixture_src(name: &str) -> std::path::PathBuf {
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("tests/fixtures")
|
.join("tests/fixtures")
|
||||||
.join(name)
|
.join(name)
|
||||||
@@ -10,7 +11,7 @@ fn fixtures(name: &str) -> std::path::PathBuf {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clean_fixture_has_no_cycles() {
|
fn clean_fixture_has_no_cycles() {
|
||||||
let mg = module_graph::build("clean".to_string(), &fixtures("clean")).unwrap();
|
let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(),
|
analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(),
|
||||||
"expected no cycles; nodes={:?} edges={:?}",
|
"expected no cycles; nodes={:?} edges={:?}",
|
||||||
@@ -34,9 +35,167 @@ fn detect_cycles_finds_cycles_in_synthetic_graph() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cyclic fixture's module graph builds without error and has the expected
|
||||||
|
/// cross-module containment structure. The cycle detection test using synthetic
|
||||||
|
/// data covers the algorithm; this test confirms the builder handles mutual
|
||||||
|
/// `pub mod` declarations without panicking.
|
||||||
|
#[test]
|
||||||
|
fn cyclic_fixture_module_graph_builds() {
|
||||||
|
let mg = module_graph::build("cyclic".to_string(), &fixture_src("cyclic")).unwrap();
|
||||||
|
assert!(
|
||||||
|
mg.nodes.contains(&"cyclic".to_string()),
|
||||||
|
"expected root module node 'cyclic'"
|
||||||
|
);
|
||||||
|
// foo and bar are declared at the top level in lib.rs
|
||||||
|
assert!(
|
||||||
|
mg.nodes.contains(&"cyclic::foo".to_string()),
|
||||||
|
"expected cyclic::foo node"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mg.nodes.contains(&"cyclic::bar".to_string()),
|
||||||
|
"expected cyclic::bar node"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// write_all produces report.html, report.md, and report.json in the output dir.
|
||||||
|
#[test]
|
||||||
|
fn write_all_creates_expected_files() {
|
||||||
|
use types::{CrateGraph, GraphStats, ModuleGraph};
|
||||||
|
|
||||||
|
let out = TempDir::new().unwrap();
|
||||||
|
let crate_graph = CrateGraph {
|
||||||
|
nodes: vec!["alpha".to_string(), "beta".to_string()],
|
||||||
|
edges: vec![("beta".to_string(), "alpha".to_string())],
|
||||||
|
};
|
||||||
|
let module_graphs: Vec<ModuleGraph> = vec![];
|
||||||
|
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
|
||||||
|
let symbol_tables: Vec<types::SymbolTable> = vec![];
|
||||||
|
|
||||||
|
emit::write_all(out.path(), &crate_graph, &module_graphs, &stats, &symbol_tables).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
out.path().join("report.html").exists(),
|
||||||
|
"report.html missing"
|
||||||
|
);
|
||||||
|
assert!(out.path().join("report.md").exists(), "report.md missing");
|
||||||
|
assert!(
|
||||||
|
out.path().join("report.json").exists(),
|
||||||
|
"report.json missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crate names with hyphens must not produce malformed Mermaid output — the
|
||||||
|
/// generated HTML must contain a sanitized node ID (hyphen replaced with _).
|
||||||
|
#[test]
|
||||||
|
fn write_all_sanitizes_hyphenated_crate_names() {
|
||||||
|
use types::{CrateGraph, ModuleGraph};
|
||||||
|
|
||||||
|
let out = TempDir::new().unwrap();
|
||||||
|
let crate_graph = CrateGraph {
|
||||||
|
nodes: vec!["my-core".to_string(), "my-app".to_string()],
|
||||||
|
edges: vec![("my-app".to_string(), "my-core".to_string())],
|
||||||
|
};
|
||||||
|
let module_graphs: Vec<ModuleGraph> = vec![];
|
||||||
|
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
|
||||||
|
let symbol_tables: Vec<types::SymbolTable> = vec![];
|
||||||
|
|
||||||
|
emit::write_all(out.path(), &crate_graph, &module_graphs, &stats, &symbol_tables).unwrap();
|
||||||
|
|
||||||
|
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
|
||||||
|
// Raw hyphenated names must not appear as unquoted node IDs
|
||||||
|
assert!(
|
||||||
|
!html.contains("my-core["),
|
||||||
|
"hyphenated ID 'my-core[' must not appear in Mermaid — use my_core"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("my_core["),
|
||||||
|
"sanitized ID 'my_core[' must appear in Mermaid output"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heatmap: a single-crate graph (max_fan_in = 0 or 1) must not panic and must
|
||||||
|
/// produce a style directive in the HTML output.
|
||||||
|
#[test]
|
||||||
|
fn write_all_heatmap_does_not_panic_for_single_crate() {
|
||||||
|
use types::{CrateGraph, ModuleGraph};
|
||||||
|
|
||||||
|
let out = TempDir::new().unwrap();
|
||||||
|
let crate_graph = CrateGraph {
|
||||||
|
nodes: vec!["solo".to_string()],
|
||||||
|
edges: vec![],
|
||||||
|
};
|
||||||
|
let module_graphs: Vec<ModuleGraph> = vec![];
|
||||||
|
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
|
||||||
|
let symbol_tables: Vec<types::SymbolTable> = vec![];
|
||||||
|
|
||||||
|
emit::write_all(out.path(), &crate_graph, &module_graphs, &stats, &symbol_tables).unwrap();
|
||||||
|
|
||||||
|
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
|
||||||
|
assert!(
|
||||||
|
html.contains("style solo fill:"),
|
||||||
|
"heatmap style directive missing for solo crate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-crate module graphs: write_all renders a <h3> section for each
|
||||||
|
/// ModuleGraph entry in the HTML output.
|
||||||
|
#[test]
|
||||||
|
fn write_all_renders_per_crate_module_graphs() {
|
||||||
|
use types::{CrateGraph, ModuleGraph};
|
||||||
|
|
||||||
|
let out = TempDir::new().unwrap();
|
||||||
|
let mg = module_graph::build("clean".to_string(), &fixture_src("clean")).unwrap();
|
||||||
|
let crate_graph = CrateGraph {
|
||||||
|
nodes: vec!["clean".to_string()],
|
||||||
|
edges: vec![],
|
||||||
|
};
|
||||||
|
let stats = analysis::analyse(&crate_graph, &[mg.clone()], 3);
|
||||||
|
let symbol_tables: Vec<types::SymbolTable> = vec![];
|
||||||
|
|
||||||
|
emit::write_all(out.path(), &crate_graph, &[mg], &stats, &symbol_tables).unwrap();
|
||||||
|
|
||||||
|
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
|
||||||
|
assert!(
|
||||||
|
html.contains("<h3>clean</h3>"),
|
||||||
|
"expected per-crate <h3>clean</h3> section in HTML"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("flowchart TD"),
|
||||||
|
"expected flowchart TD directive for module graph"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle callouts: when the crate graph contains cycles, the HTML must include
|
||||||
|
/// the cycle-entry element with the cycle nodes.
|
||||||
|
#[test]
|
||||||
|
fn write_all_cycle_callout_appears_in_html() {
|
||||||
|
use types::{CrateGraph, ModuleGraph};
|
||||||
|
|
||||||
|
let out = TempDir::new().unwrap();
|
||||||
|
// Build a two-node crate graph with a synthetic cycle
|
||||||
|
let crate_graph = CrateGraph {
|
||||||
|
nodes: vec!["alpha".to_string(), "beta".to_string()],
|
||||||
|
edges: vec![
|
||||||
|
("alpha".to_string(), "beta".to_string()),
|
||||||
|
("beta".to_string(), "alpha".to_string()),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let module_graphs: Vec<ModuleGraph> = vec![];
|
||||||
|
let stats = analysis::analyse(&crate_graph, &module_graphs, 3);
|
||||||
|
let symbol_tables: Vec<types::SymbolTable> = vec![];
|
||||||
|
|
||||||
|
emit::write_all(out.path(), &crate_graph, &module_graphs, &stats, &symbol_tables).unwrap();
|
||||||
|
|
||||||
|
let html = std::fs::read_to_string(out.path().join("report.html")).unwrap();
|
||||||
|
assert!(
|
||||||
|
html.contains("cycle-entry"),
|
||||||
|
"expected cycle-entry element in HTML when cycles are present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn symbols_fixture_has_six_public_symbols() {
|
fn symbols_fixture_has_six_public_symbols() {
|
||||||
let table = symbols::build("symbols".to_string(), &fixtures("symbols")).unwrap();
|
let table = symbols::build("symbols".to_string(), &fixture_src("symbols")).unwrap();
|
||||||
let pub_count = table.symbols.iter().filter(|s| s.is_pub).count();
|
let pub_count = table.symbols.iter().filter(|s| s.is_pub).count();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pub_count,
|
pub_count,
|
||||||
|
|||||||
Reference in New Issue
Block a user