use brick::{diff, scan}; use serde_json::Value; use std::fs; use std::path::Path; use std::process::Command; fn write_model(path: &Path, model: &brick::Building) { fs::write(path, serde_json::to_vec_pretty(model).unwrap()).unwrap(); } #[test] fn fixture_cases_assert_all_six_directional_counts() { let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/diff"); let cases = [ ( "positive", diff::DiffCounts { added_nodes: 1, removed_nodes: 1, changed_nodes: 1, added_edges: 2, removed_edges: 1, changed_edges: 1, }, ), ( "negative", diff::DiffCounts { added_nodes: 0, removed_nodes: 0, changed_nodes: 0, added_edges: 0, removed_edges: 0, changed_edges: 0, }, ), ]; for (name, expected) in cases { let directory = tempfile::tempdir().unwrap(); let repository = directory.path().join("repository"); fs::create_dir(&repository).unwrap(); let baseline_source = fixtures.join(name).join("baseline/lib.rs"); let current_source = fixtures.join(name).join("current/lib.rs"); fs::copy(baseline_source, repository.join("lib.rs")).unwrap(); let baseline = scan::scan(&repository).unwrap(); fs::copy(current_source, repository.join("lib.rs")).unwrap(); let current = scan::scan(&repository).unwrap(); assert_eq!(diff::between(&baseline, ¤t).unwrap().counts, expected); } } #[test] fn check_preserves_existing_gates_and_adds_explicit_diff_gates() { let directory = tempfile::tempdir().unwrap(); let project = directory.path().join("project"); fs::create_dir(&project).unwrap(); let source = project.join("lib.rs"); fs::write(&source, "fn alpha() {}\n").unwrap(); let baseline_model = scan::scan(&project).unwrap(); let baseline = directory.path().join("baseline.json"); write_model(&baseline, &baseline_model); fs::write(&source, "fn alpha() {}\nfn beta() { alpha(); }\n").unwrap(); let current_model = scan::scan(&project).unwrap(); let current = directory.path().join("current.json"); write_model(¤t, ¤t_model); let diff = directory.path().join("diff.json"); let existing = Command::new(env!("CARGO_BIN_EXE_brick")) .args(["check", current.to_str().unwrap(), "--min-score", "0"]) .output() .unwrap(); assert!( existing.status.success(), "{}", String::from_utf8_lossy(&existing.stderr) ); let non_finite = Command::new(env!("CARGO_BIN_EXE_brick")) .args(["check", current.to_str().unwrap(), "--min-score", "NaN"]) .output() .unwrap(); assert!(!non_finite.status.success()); assert!(String::from_utf8_lossy(&non_finite.stderr).contains("finite number")); let passing = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", current.to_str().unwrap(), "--min-score", "0", "--baseline", baseline.to_str().unwrap(), "--diff-json", diff.to_str().unwrap(), "--max-added-nodes", "1", "--max-changed-nodes", "1", "--max-added-edges", "2", ]) .output() .unwrap(); assert!( passing.status.success(), "{}", String::from_utf8_lossy(&passing.stderr) ); let value: Value = serde_json::from_slice(&fs::read(&diff).unwrap()).unwrap(); assert_eq!(value["diff_schema_version"], 2); assert_eq!(value["building_schema_version"], 4); assert_eq!(value["comparison_policy"], "strict"); assert_eq!(value["baseline_root"], baseline_model.root); assert_eq!(value["current_root"], current_model.root); assert!( value["repository_id"] .as_str() .is_some_and(|id| id.starts_with("repository:")) ); assert_eq!(value["counts"]["added_nodes"], 1); assert_eq!(value["counts"]["changed_nodes"], 1); let failing = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", current.to_str().unwrap(), "--min-score", "0", "--baseline", baseline.to_str().unwrap(), "--max-added-nodes", "0", ]) .output() .unwrap(); assert!(!failing.status.success()); assert!(String::from_utf8_lossy(&failing.stderr).contains("added nodes 1 exceed 0")); } #[test] fn check_rejects_diff_options_without_baseline_and_incompatible_models() { let directory = tempfile::tempdir().unwrap(); let first = directory.path().join("first"); let second = directory.path().join("second"); fs::create_dir(&first).unwrap(); fs::create_dir(&second).unwrap(); fs::write(first.join("lib.rs"), "fn first() {}\n").unwrap(); fs::write(second.join("lib.rs"), "fn second() {}\n").unwrap(); let first_json = directory.path().join("first.json"); let second_json = directory.path().join("second.json"); write_model(&first_json, &scan::scan(&first).unwrap()); write_model(&second_json, &scan::scan(&second).unwrap()); let missing = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", first_json.to_str().unwrap(), "--min-score", "0", "--max-added-edges", "0", ]) .output() .unwrap(); assert!(!missing.status.success()); assert!(String::from_utf8_lossy(&missing.stderr).contains("require --baseline")); let maximum = usize::MAX.to_string(); let explicit_maximum = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", first_json.to_str().unwrap(), "--min-score", "0", "--max-added-nodes", maximum.as_str(), ]) .output() .unwrap(); assert!(!explicit_maximum.status.success()); assert!(String::from_utf8_lossy(&explicit_maximum.stderr).contains("require --baseline")); let incompatible = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", second_json.to_str().unwrap(), "--min-score", "0", "--baseline", first_json.to_str().unwrap(), ]) .output() .unwrap(); assert!(!incompatible.status.success()); let stderr = String::from_utf8_lossy(&incompatible.stderr); assert!(stderr.contains("cannot compute structural diff")); assert!(stderr.contains("scan roots differ")); } #[test] fn relocated_artifacts_compare_after_checkouts_are_deleted() { let directory = tempfile::tempdir().unwrap(); let mut models = Vec::new(); for name in ["main", "worktree"] { let root = directory.path().join(name); fs::create_dir(&root).unwrap(); fs::write(root.join("lib.rs"), "fn alpha() {}\n").unwrap(); let model = scan::scan(&root).unwrap(); let path = directory.path().join(format!("{name}.json")); write_model(&path, &model); models.push((path, model)); fs::remove_dir_all(root).unwrap(); } let output = directory.path().join("diff.json"); let run = |baseline: bool, limit: &str| { let mut command = Command::new(env!("CARGO_BIN_EXE_brick")); command.arg("check").arg(&models[1].0).args([ "--min-score", "0", "--allow-root-relocation", ]); if baseline { command .arg("--baseline") .arg(&models[0].0) .arg("--diff-json") .arg(&output) .args(["--max-changed-nodes", limit]); } command.output().unwrap() }; let passing = run(true, "1"); assert!( passing.status.success(), "{}", String::from_utf8_lossy(&passing.stderr) ); let value: Value = serde_json::from_slice(&fs::read(&output).unwrap()).unwrap(); assert_eq!(value["diff_schema_version"], 2); assert_eq!(value["comparison_policy"], "explicit-root-equivalence"); assert_eq!(value["baseline_root"], models[0].1.root); assert_eq!(value["current_root"], models[1].1.root); assert_eq!(value["counts"]["changed_nodes"], 1); assert_eq!(value["counts"]["added_nodes"], 0); assert_eq!(value["counts"]["changed_edges"], 0); assert_eq!(value["changed_nodes"][0]["before"]["name"], "main"); assert_eq!(value["changed_nodes"][0]["after"]["name"], "worktree"); let failing = run(true, "0"); assert!(!failing.status.success()); assert!(String::from_utf8_lossy(&failing.stderr).contains("changed nodes 1 exceed 0")); let missing = run(false, "0"); assert!(!missing.status.success()); assert!(String::from_utf8_lossy(&missing.stderr).contains("require --baseline")); for (path, model) in models { assert_eq!( fs::read(path).unwrap(), serde_json::to_vec_pretty(&model).unwrap() ); } } #[test] fn check_recomputes_metrics_instead_of_trusting_the_file() { // `check` gated on the metrics stored in the JSON, so editing `cycle_count` and // `score` turned a failing gate into a pass without changing the graph. let directory = tempfile::tempdir().unwrap(); let project = directory.path().join("project"); fs::create_dir(&project).unwrap(); fs::write( project.join("lib.rs"), "pub fn alpha() { beta(); }\nfn beta() { alpha(); }\n", ) .unwrap(); let model = scan::scan(&project).unwrap(); assert!(model.metrics.cycle_count > 0); let honest = directory.path().join("honest.json"); write_model(&honest, &model); let gate = |path: &Path| { Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", path.to_str().unwrap(), "--max-cycles", "0", "--min-score", "0", ]) .output() .unwrap() }; assert!(!gate(&honest).status.success()); let mut value: Value = serde_json::to_value(&model).unwrap(); value["metrics"]["cycle_count"] = 0.into(); value["metrics"]["structural_health"]["score"] = 100.0.into(); let tampered = directory.path().join("tampered.json"); fs::write(&tampered, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); let output = gate(&tampered); assert!(!output.status.success(), "tampered metrics passed the gate"); let text = format!( "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); assert!(text.contains("stored metrics"), "{text}"); } #[test] fn a_model_from_an_older_build_passes_check_on_recomputed_metrics() { // Any difference between stored and recomputed metrics failed the gate, so a // model written before `metrics.resolution` existed always failed, even with // every threshold met. Gate on the recomputed values; a mismatch is a warning. let directory = tempfile::tempdir().unwrap(); let project = directory.path().join("project"); fs::create_dir(&project).unwrap(); fs::write(project.join("lib.rs"), "pub fn alpha() -> u32 { 1 }\n").unwrap(); let model = scan::scan(&project).unwrap(); let mut value: Value = serde_json::to_value(&model).unwrap(); value["metrics"] .as_object_mut() .unwrap() .remove("resolution"); let older = directory.path().join("older.json"); fs::write(&older, serde_json::to_vec_pretty(&value).unwrap()).unwrap(); let output = Command::new(env!("CARGO_BIN_EXE_brick")) .args(["check", older.to_str().unwrap(), "--min-score", "0"]) .output() .unwrap(); assert!( output.status.success(), "{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } #[test] fn a_scan_with_no_supported_source_is_reported_and_fails_check() { // A Go/Java-only directory built "1 nodes / 0 edges; score 100.0" with no // diagnostic, and `check --min-score 90 --max-cycles 0` passed: an analysis of // nothing looked like a perfectly healthy repository. let directory = tempfile::tempdir().unwrap(); let project = directory.path().join("project"); fs::create_dir(&project).unwrap(); fs::write(project.join("main.go"), "package main\nfunc main() {}\n").unwrap(); fs::write(project.join("App.java"), "class App {}\n").unwrap(); let json = directory.path().join("model.json"); let html = directory.path().join("model.html"); let build = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "build", project.to_str().unwrap(), "--json", json.to_str().unwrap(), "--html", html.to_str().unwrap(), ]) .output() .unwrap(); assert!(build.status.success()); let stderr = String::from_utf8_lossy(&build.stderr); assert!(stderr.contains("no supported source files"), "{stderr}"); let model: Value = serde_json::from_slice(&fs::read(&json).unwrap()).unwrap(); assert!( model["diagnostics"] .as_array() .is_some_and(|d| d.iter().any(|d| d["message"] .as_str() .is_some_and(|m| m.contains("no supported source files")))) ); let check = Command::new(env!("CARGO_BIN_EXE_brick")) .args([ "check", json.to_str().unwrap(), "--min-score", "90", "--max-cycles", "0", ]) .output() .unwrap(); assert!(!check.status.success(), "an empty model passed check"); }