use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
fn cli_cmd(td_path: &Path) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_entropyx"));
cmd.env("ENTROPYX_CACHE_DIR", td_path);
cmd
}
fn run_git(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.expect("spawn git");
assert!(status.success(), "git {args:?} failed");
}
fn commit_as(cwd: &Path, name: &str, email: &str, time: i64, subject: &str) {
run_git(cwd, &["add", "-A"]);
let status = Command::new("git")
.args([
"-c",
&format!("user.name={name}"),
"-c",
&format!("user.email={email}"),
"commit",
"-q",
"-m",
subject,
])
.env("GIT_AUTHOR_DATE", format!("@{time} +0000"))
.env("GIT_COMMITTER_DATE", format!("@{time} +0000"))
.current_dir(cwd)
.status()
.expect("spawn git");
assert!(status.success(), "git commit {subject} failed");
}
#[test]
fn scan_emits_tq1_summary() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("a.rs"), "one\n").unwrap();
commit_as(root, "Alice", "alice@ex.com", 100, "add a");
fs::write(root.join("a.rs"), "one\ntwo\n").unwrap();
fs::write(root.join("b.rs"), "bb\n").unwrap();
commit_as(root, "Bob", "bob@ex.com", 200, "touch a, add b");
fs::write(root.join("b.rs"), "bb\ncc\n").unwrap();
commit_as(root, "Alice", "alice@ex.com", 400, "touch b");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn entropyx");
assert!(
out.status.success(),
"scan failed: stderr={}",
String::from_utf8_lossy(&out.stderr),
);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
assert_eq!(summary["schema"]["name"], "tq1");
assert_eq!(summary["schema"]["version"], "0.1.0");
assert_eq!(summary["dict"]["metrics"].as_array().unwrap().len(), 8);
assert_eq!(
summary["dict"]["metrics"][0], "change_density",
"RFC-007 column order is load-bearing",
);
assert_eq!(summary["dict"]["metrics"][7], "composite");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["a.rs", "b.rs"]);
let authors: Vec<&str> = summary["dict"]["authors"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(authors.len(), 2);
assert!(authors.contains(&"alice@ex.com"));
assert!(authors.contains(&"bob@ex.com"));
let rows = summary["files"].as_array().unwrap();
assert_eq!(rows.len(), 2);
let a = &rows[0];
let b = &rows[1];
let a_vals: Vec<f64> = a["values"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_f64().unwrap())
.collect();
let b_vals: Vec<f64> = b["values"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_f64().unwrap())
.collect();
for v in [&a_vals, &b_vals] {
assert_eq!(v.len(), 8);
assert_eq!(v[0], 1.0, "D_n: both files touched twice → tied max");
assert!((v[1] - 1.0).abs() < 1e-12, "H_a: 2 authors uniform");
assert_eq!(v[2], 0.0, "V_t: 1 gap → variance of 1 sample = 0");
assert_eq!(v[3], 1.0, "C_s: both co-changed in c2 → tied max");
assert_eq!(v[5], 0.0, "S_n (AST layer not scaffolded)");
assert_eq!(v[6], 0.0, "T_c (tests subsystem not scaffolded)");
}
assert_eq!(a_vals[4], 0.0, "a.rs blame-youth: 0 of 2 lines recent");
assert_eq!(b_vals[4], 0.5, "b.rs blame-youth: 1 of 2 lines recent");
assert!(
(a_vals[7] - 0.50).abs() < 1e-12,
"a composite = {}",
a_vals[7]
);
assert!(
(b_vals[7] - 0.55).abs() < 1e-12,
"b composite = {}",
b_vals[7]
);
for row in [a, b] {
assert_eq!(row["lineage_confidence"], 1.0);
assert_eq!(row["signal_class"], "ownership_fragmentation");
}
assert!(summary["events"].as_array().unwrap().is_empty());
let handles = summary["handles"].as_object().unwrap();
assert_eq!(handles.len(), 2, "one handle per HEAD blob");
for (key, handle) in handles {
assert!(key.starts_with("file:"), "handle key is `file:<prefix>`");
assert_eq!(key.len(), "file:".len() + 12, "12-char blob prefix");
assert_eq!(handle["kind"], "file");
assert_eq!(
handle["blob_prefix"].as_str().unwrap().len(),
12,
"blob prefix is 12 hex chars",
);
}
}
#[test]
fn scan_emits_rename_event() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("old.rs"), "original\n").unwrap();
commit_as(root, "Alice", "alice@ex.com", 100, "add old");
fs::rename(root.join("old.rs"), root.join("new.rs")).unwrap();
commit_as(root, "Alice", "alice@ex.com", 200, "rename");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn entropyx");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let events = summary["events"].as_array().expect("events");
assert_eq!(events.len(), 1, "exactly one rename event");
let ev = &events[0];
assert_eq!(ev["kind"], "rename");
assert_eq!(ev["from"], "old.rs");
assert_eq!(ev["to"], "new.rs");
assert_eq!(ev["at"], 200);
let new_fid = ev["file"].as_u64().unwrap();
let files = summary["dict"]["files"].as_array().unwrap();
assert_eq!(files[new_fid as usize], "new.rs");
let sha = ev["sha"].as_str().expect("sha field");
assert_eq!(sha.len(), 40, "full 40-char SHA of rename commit");
let handles = summary["handles"].as_object().unwrap();
assert_eq!(handles.len(), 1, "renamed-away file has no handle");
let (only_key, _) = handles.iter().next().unwrap();
assert!(only_key.starts_with("file:"));
}
#[test]
fn scan_emits_hotspot_on_recent_burst() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
let schedule = [
(0, "v1"),
(100, "v2"),
(900, "v3"),
(950, "v4"),
(1000, "v5"),
];
for (i, (time, content)) in schedule.iter().enumerate() {
fs::write(root.join("hot.rs"), format!("{content}\n")).unwrap();
commit_as(
root,
"Author",
"author@ex.com",
*time,
&format!("commit {i}"),
);
}
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn entropyx");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let hotspots: Vec<&serde_json::Value> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "hotspot")
.collect();
assert_eq!(
hotspots.len(),
1,
"exactly one hotspot for the bursting file"
);
let h = hotspots[0];
assert_eq!(h["at"], 1000, "event time is the latest touch");
assert_eq!(h["reason"], "recent_burst");
assert_eq!(
h["sha"].as_str().unwrap().len(),
40,
"hotspot event carries SHA of latest touch",
);
let files = summary["dict"]["files"].as_array().unwrap();
let fid = h["file"].as_u64().unwrap() as usize;
assert_eq!(files[fid], "hot.rs");
}
#[test]
fn scan_steady_cadence_emits_no_hotspot() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
for (i, t) in [100, 200, 300, 400, 500, 600].iter().enumerate() {
fs::write(root.join("steady.rs"), format!("v{i}\n")).unwrap();
commit_as(root, "Author", "author@ex.com", *t, &format!("c{i}"));
}
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn entropyx");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let hotspots: Vec<&serde_json::Value> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "hotspot")
.collect();
assert!(
hotspots.is_empty(),
"steady cadence must not trip hotspot rule",
);
}
#[test]
fn scan_semantic_drift_distinguishes_api_change_from_cosmetic() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("a.rs"), "pub fn one() {}\n").unwrap();
fs::write(root.join("b.rs"), "pub fn only() {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init");
fs::write(
root.join("a.rs"),
"pub fn one() {}\npub fn two() {}\npub fn three() {}\n",
)
.unwrap();
fs::write(
root.join("b.rs"),
"pub fn only() {\n // added a comment, no API change\n}\n",
)
.unwrap();
commit_as(root, "B", "b@ex.com", 200, "expand a, comment b");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["a.rs", "b.rs"]);
let rows = summary["files"].as_array().unwrap();
let a_sn = rows[0]["values"][5].as_f64().unwrap();
let b_sn = rows[1]["values"][5].as_f64().unwrap();
assert!((a_sn - 1.0).abs() < 1e-12, "a.rs S_n = {a_sn}");
assert!((b_sn - 1.0 / 3.0).abs() < 1e-12, "b.rs S_n = {b_sn}");
assert_eq!(rows[0]["signal_class"], "api_drift");
assert_eq!(rows[1]["signal_class"], "ownership_fragmentation");
let api_drifts: Vec<&serde_json::Value> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "api_drift")
.collect();
assert_eq!(api_drifts.len(), 1);
let ev = api_drifts[0];
assert_eq!(ev["pub_items_changed"], 3);
assert_eq!(ev["at"], 200, "latest touch time for a.rs");
assert_eq!(
ev["sha"].as_str().unwrap().len(),
40,
"api_drift carries SHA of latest touch",
);
let files = summary["dict"]["files"].as_array().unwrap();
assert_eq!(files[ev["file"].as_u64().unwrap() as usize], "a.rs");
}
#[test]
fn scan_emits_incident_aftershock() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
let schedule = [
(100, "initial state", "v1"),
(300, "feat: add widget", "v2"),
(700, "fix: null deref in hot path", "v3"),
(1500, "follow-up cleanup", "v4"),
];
for (t, subject, body) in schedule {
fs::write(root.join("hot.rs"), format!("{body}\n")).unwrap();
commit_as(root, "Author", "a@ex.com", t, subject);
}
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let events = summary["events"].as_array().unwrap();
let aftershocks: Vec<&serde_json::Value> = events
.iter()
.filter(|e| e["kind"] == "incident_aftershock")
.collect();
assert_eq!(aftershocks.len(), 1, "one aftershock for hot.rs");
let ev = aftershocks[0];
assert_eq!(ev["at"], 700, "event time is the incident commit");
assert_eq!(ev["window_days"], 0, "single incident → zero-day window",);
let files = summary["dict"]["files"].as_array().unwrap();
assert_eq!(files[ev["file"].as_u64().unwrap() as usize], "hot.rs");
let rows = summary["files"].as_array().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0]["signal_class"], "incident_aftershock");
}
#[test]
fn scan_no_incident_without_fix_subject() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
let schedule = [(100, "v1"), (300, "v2"), (700, "v3"), (1500, "v4")];
for (t, body) in schedule {
fs::write(root.join("calm.rs"), format!("{body}\n")).unwrap();
commit_as(root, "Author", "a@ex.com", t, "chore: bump");
}
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let aftershocks: Vec<&serde_json::Value> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "incident_aftershock")
.collect();
assert!(aftershocks.is_empty(), "no fix commits → no aftershock");
}
#[test]
fn scan_test_coevolution_discounts_well_tested_code() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join("tests")).unwrap();
fs::write(root.join("src/lib.rs"), "pub fn one() {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init lib");
fs::write(
root.join("src/lib.rs"),
"pub fn one() {}\npub fn two() {}\n",
)
.unwrap();
fs::write(root.join("tests/lib_test.rs"), "#[test] fn t() {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 200, "add feature + test");
fs::write(
root.join("src/lib.rs"),
"pub fn one() {}\npub fn two() {}\npub fn three() {}\n",
)
.unwrap();
fs::write(
root.join("tests/lib_test.rs"),
"#[test] fn t() {}\n#[test] fn t2() {}\n",
)
.unwrap();
commit_as(root, "A", "a@ex.com", 300, "extend + test");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["src/lib.rs", "tests/lib_test.rs"]);
let rows = summary["files"].as_array().unwrap();
let lib = &rows[0];
let test = &rows[1];
let lib_tc = lib["values"][6].as_f64().unwrap();
assert!((lib_tc - 2.0 / 3.0).abs() < 1e-12, "lib T_c = {lib_tc}");
let test_tc = test["values"][6].as_f64().unwrap();
assert_eq!(test_tc, 1.0);
let expected_lib = 0.15 * lib["values"][0].as_f64().unwrap()
+ 0.15 * lib["values"][1].as_f64().unwrap()
+ 0.10 * lib["values"][2].as_f64().unwrap()
+ 0.20 * lib["values"][3].as_f64().unwrap()
+ 0.10 * lib["values"][4].as_f64().unwrap()
+ 0.30 * lib["values"][5].as_f64().unwrap()
- 0.05 * lib_tc;
let actual_lib = lib["values"][7].as_f64().unwrap();
assert!(
(actual_lib - expected_lib).abs() < 1e-10,
"composite {actual_lib} ≠ expected {expected_lib}",
);
}
#[test]
fn scan_computes_semantic_drift_for_go_files() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("lib.rs"), "pub fn one() {}\n").unwrap();
fs::write(root.join("main.go"), "package main\nfunc One() {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init");
fs::write(root.join("lib.rs"), "pub fn one() {}\npub fn two() {}\n").unwrap();
fs::write(
root.join("main.go"),
"package main\nfunc One() {}\nfunc Two() {}\n",
)
.unwrap();
commit_as(root, "A", "a@ex.com", 200, "extend both");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(
out.status.success(),
"scan failed: {}",
String::from_utf8_lossy(&out.stderr),
);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["lib.rs", "main.go"]);
let rows = summary["files"].as_array().unwrap();
let rust_sn = rows[0]["values"][5].as_f64().unwrap();
let go_sn = rows[1]["values"][5].as_f64().unwrap();
assert_eq!(rust_sn, 1.0, "Rust S_n should fire on API expansion");
assert_eq!(go_sn, 1.0, "Go S_n should fire via tree-sitter backend");
}
#[test]
fn scan_computes_semantic_drift_for_python_and_typescript() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("app.py"), "def one():\n pass\n").unwrap();
fs::write(root.join("index.ts"), "export function one(): void {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init");
fs::write(
root.join("app.py"),
"def one():\n pass\ndef two():\n pass\n",
)
.unwrap();
fs::write(
root.join("index.ts"),
"export function one(): void {}\nexport function two(): void {}\n",
)
.unwrap();
commit_as(root, "A", "a@ex.com", 200, "expand both");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["app.py", "index.ts"]);
let rows = summary["files"].as_array().unwrap();
assert_eq!(
rows[0]["values"][5], 1.0,
"python S_n fires via tree-sitter"
);
assert_eq!(
rows[1]["values"][5], 1.0,
"typescript S_n fires via tree-sitter"
);
}
#[test]
fn scan_lineage_collapses_renamed_file_history() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("lib.rs"), "v1\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "c1 create");
fs::write(root.join("lib.rs"), "v1\nv2\n").unwrap();
commit_as(root, "A", "a@ex.com", 200, "c2 modify");
fs::rename(root.join("lib.rs"), root.join("core.rs")).unwrap();
commit_as(root, "A", "a@ex.com", 300, "c3 rename");
fs::write(root.join("core.rs"), "v1\nv2\nv3\n").unwrap();
commit_as(root, "A", "a@ex.com", 400, "c4 modify");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(
out.status.success(),
"scan failed: {}",
String::from_utf8_lossy(&out.stderr),
);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["core.rs"], "pre-rename name collapsed away");
let rows = summary["files"].as_array().unwrap();
assert_eq!(rows.len(), 1, "renamed file yields one row, not two");
let renames: Vec<&serde_json::Value> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "rename")
.collect();
assert_eq!(renames.len(), 1);
assert_eq!(renames[0]["from"], "lib.rs");
assert_eq!(renames[0]["to"], "core.rs");
assert_eq!(
renames[0]["file"].as_u64().unwrap(),
0,
"event's file FileId resolves to the single trajectory row",
);
}
#[test]
fn scan_bridge_file_surfaces_via_betweenness() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("a.rs"), "v1\n").unwrap();
fs::write(root.join("bridge.rs"), "v1\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init a + bridge");
fs::write(root.join("bridge.rs"), "v2\n").unwrap();
fs::write(root.join("c.rs"), "v1\n").unwrap();
commit_as(root, "A", "a@ex.com", 200, "bridge + c");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let files: Vec<&str> = summary["dict"]["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(files, vec!["a.rs", "bridge.rs", "c.rs"]);
let rows = summary["files"].as_array().unwrap();
let cs_a = rows[0]["values"][3].as_f64().unwrap();
let cs_bridge = rows[1]["values"][3].as_f64().unwrap();
let cs_c = rows[2]["values"][3].as_f64().unwrap();
assert_eq!(cs_bridge, 1.0, "bridge node tops C_s");
assert_eq!(cs_a, 0.5, "leaf exactly at its degree-only value");
assert_eq!(cs_c, 0.5, "leaf exactly at its degree-only value");
assert!(
cs_bridge > cs_a && cs_bridge > cs_c,
"bridge strictly exceeds leaves"
);
}
#[test]
fn scan_ignores_unsupported_languages() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("Main.kt"), "fun foo() {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init");
fs::write(root.join("Main.kt"), "fun foo() {}\nfun bar() {}\n").unwrap();
commit_as(root, "A", "a@ex.com", 200, "add bar");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let sn = summary["files"][0]["values"][5].as_f64().unwrap();
assert_eq!(sn, 0.0, "unsupported language → S_n = 0");
}
#[test]
fn scan_emits_ownership_split_event() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("foo.rs"), "v1\n").unwrap();
commit_as(root, "Alice", "alice@ex.com", 100, "alice c1");
fs::write(root.join("foo.rs"), "v2\n").unwrap();
commit_as(root, "Alice", "alice@ex.com", 200, "alice c2");
fs::write(root.join("foo.rs"), "v3\n").unwrap();
commit_as(root, "Bob", "bob@ex.com", 300, "bob joins");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let splits: Vec<&serde_json::Value> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "ownership_split")
.collect();
assert_eq!(splits.len(), 1, "one ownership split event");
let ev = splits[0];
assert_eq!(ev["at"], 300, "split fires at Bob's first commit");
let author_ids: Vec<u64> = ev["authors"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_u64().unwrap())
.collect();
let dict_authors: Vec<&str> = summary["dict"]["authors"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
let resolved: Vec<&str> = author_ids
.iter()
.map(|&i| dict_authors[i as usize])
.collect();
assert_eq!(resolved.len(), 2);
assert!(resolved.contains(&"alice@ex.com"));
assert!(resolved.contains(&"bob@ex.com"));
let files = summary["dict"]["files"].as_array().unwrap();
assert_eq!(files[ev["file"].as_u64().unwrap() as usize], "foo.rs");
assert_eq!(
ev["sha"].as_str().unwrap().len(),
40,
"ownership split carries SHA of the split commit",
);
}
#[test]
fn scan_no_split_for_single_author_file() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
for (i, t) in [100, 200, 300].iter().enumerate() {
fs::write(root.join("solo.rs"), format!("v{i}\n")).unwrap();
commit_as(root, "Alice", "alice@ex.com", *t, &format!("c{i}"));
}
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.expect("spawn");
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
let splits: Vec<_> = summary["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "ownership_split")
.collect();
assert!(splits.is_empty(), "single author → no split");
}
#[test]
fn scan_incident_aftershock_event_carries_sha_field() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
let schedule = [
(100, "chore: init", "v1"),
(300, "feat: add widget", "v2"),
(700, "fix: crash in hot path", "v3"),
(1500, "follow-up cleanup", "v4"),
];
for (t, subject, body) in schedule {
fs::write(root.join("hot.rs"), format!("{body}\n")).unwrap();
commit_as(root, "A", "a@ex.com", t, subject);
}
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.output()
.unwrap();
assert!(out.status.success());
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let aftershock = summary["events"]
.as_array()
.unwrap()
.iter()
.find(|e| e["kind"] == "incident_aftershock")
.expect("aftershock event");
let sha = aftershock["sha"].as_str().expect("sha field present");
assert_eq!(sha.len(), 40, "full 40-char SHA");
let prs = summary["enrichments"]["pull_requests"]
.as_object()
.expect("enrichments.pull_requests exists");
assert!(prs.is_empty(), "no --github → no PR entries");
}
#[test]
fn scan_github_auto_detect_fails_when_no_remote() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("a.rs"), "fn x(){}").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.arg("--github")
.output()
.unwrap();
assert!(!out.status.success());
assert!(
String::from_utf8_lossy(&out.stderr).contains("auto-detect"),
"stderr: {}",
String::from_utf8_lossy(&out.stderr),
);
}
#[test]
fn scan_no_cache_flag_runs_successfully() {
let td = tempdir().expect("tempdir");
let root = td.path();
run_git(root, &["init", "--quiet"]);
fs::write(root.join("a.rs"), "pub fn foo(){}\n").unwrap();
commit_as(root, "A", "a@ex.com", 100, "init");
let out = cli_cmd(td.path())
.args(["scan"])
.arg(root)
.arg("--no-cache")
.output()
.expect("spawn entropyx");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let summary: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is JSON");
assert!(summary["dict"]["files"].is_array());
assert!(summary["files"].is_array());
assert!(
!td.path().join("items.json").exists(),
"no cache file written"
);
}
#[test]
fn scan_on_nonexistent_path_fails_cleanly() {
let td = tempdir().expect("tempdir");
let out = cli_cmd(td.path())
.args(["scan", "/definitely/not/a/repo/x7f2"])
.output()
.expect("spawn entropyx");
assert!(!out.status.success(), "expected failure");
assert!(
String::from_utf8_lossy(&out.stderr).contains("open failed"),
"stderr should explain the failure: {}",
String::from_utf8_lossy(&out.stderr),
);
}