diff --git a/Cargo.lock b/Cargo.lock index a50f11f..1899373 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,6 +803,7 @@ dependencies = [ "serde", "serde_json", "syn", + "tempfile", "walkdir", ] diff --git a/crates/notgraph/Cargo.toml b/crates/notgraph/Cargo.toml index d60407d..76fb13c 100644 --- a/crates/notgraph/Cargo.toml +++ b/crates/notgraph/Cargo.toml @@ -12,6 +12,9 @@ path = "src/lib.rs" name = "notgraph" path = "src/main.rs" +[dev-dependencies] +tempfile = { workspace = true } + [dependencies] anyhow = { workspace = true } clap = { workspace = true } diff --git a/crates/notgraph/src/emit.rs b/crates/notgraph/src/emit.rs index a6d3155..75bc88d 100644 --- a/crates/notgraph/src/emit.rs +++ b/crates/notgraph/src/emit.rs @@ -194,12 +194,15 @@ fn write_html( isolated.sort(); 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 (fi, fo) = fan_map.get(n).copied().unwrap_or((0, 0)); let syms = sym_count.get(n).copied().unwrap_or(0); format!( "{}[\"{}
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 { - mermaid.push_str(&format!(" {} --> {}\n", to, from)); + mermaid.push_str(&format!(" {} --> {}\n", mermaid_id(to), mermaid_id(from))); } // Heatmap styles @@ -231,7 +234,7 @@ fn write_html( let color = heatmap_color(fs.fan_in); mermaid.push_str(&format!( " 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 { mermaid.push_str(&format!( " 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())) .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 for node in &mg.nodes { diff --git a/crates/notgraph/tests/integration.rs b/crates/notgraph/tests/integration.rs index 0f581d7..4a32493 100644 --- a/crates/notgraph/tests/integration.rs +++ b/crates/notgraph/tests/integration.rs @@ -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 tempfile::TempDir; -fn fixtures(name: &str) -> std::path::PathBuf { +fn fixture_src(name: &str) -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures") .join(name) @@ -10,7 +11,7 @@ fn fixtures(name: &str) -> std::path::PathBuf { #[test] 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!( analysis::detect_cycles(&mg.nodes, &mg.edges).is_empty(), "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 = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = 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 = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = 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 = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = 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

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 = 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("

clean

"), + "expected per-crate

clean

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 = vec![]; + let stats = analysis::analyse(&crate_graph, &module_graphs, 3); + let symbol_tables: Vec = 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] 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(); assert_eq!( pub_count,