use std::time::{SystemTime, UNIX_EPOCH};
use heal_cli::observer::code::change_coupling::{ChangeCouplingObserver, PairDirection};
mod common;
use common::{commit_files, init_repo};
fn now_secs() -> i64 {
i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
)
.unwrap()
}
fn observer(min_coupling: u32) -> ChangeCouplingObserver {
ChangeCouplingObserver {
enabled: true,
excluded: Vec::new(),
since_days: 90,
min_coupling,
min_lift: 0.0,
symmetric_threshold: 0.5,
workspace: None,
}
}
#[test]
fn empty_when_disabled_or_outside_repo() {
let dir = tempfile::tempdir().unwrap();
let report = observer(1).scan(dir.path());
assert!(report.pairs.is_empty());
assert_eq!(report.totals.commits_considered, 0);
let _ = init_repo(dir.path());
let disabled = ChangeCouplingObserver {
enabled: false,
..observer(1)
};
let report = disabled.scan(dir.path());
assert!(report.pairs.is_empty());
}
#[test]
fn counts_co_occurring_pairs() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
for i in 0..3 {
commit_files(
&repo,
&[
("a.rs", &format!("a{i}\n")),
("b.rs", &format!("b{i}\n")),
("c.rs", &format!("c{i}\n")),
],
&format!("abc {i}"),
now - 100 + i,
);
}
for i in 0..2 {
commit_files(
&repo,
&[
("a.rs", &format!("a-extra{i}\n")),
("b.rs", &format!("b-extra{i}\n")),
],
&format!("ab {i}"),
now - 50 + i,
);
}
commit_files(
&repo,
&[("a.rs", "a-final\n"), ("c.rs", "c-final\n")],
"ac",
now - 10,
);
let report = observer(1).scan(dir.path());
let lookup = |a: &str, b: &str| {
report
.pairs
.iter()
.find(|p| p.a.to_string_lossy() == a && p.b.to_string_lossy() == b)
.map(|p| p.count)
};
assert_eq!(lookup("a.rs", "b.rs"), Some(5)); assert_eq!(lookup("a.rs", "c.rs"), Some(4)); assert_eq!(lookup("b.rs", "c.rs"), Some(3));
assert_eq!(report.pairs[0].count, 5);
assert_eq!(report.pairs[1].count, 4);
assert_eq!(report.pairs[2].count, 3);
let sum = |path: &str| {
report
.file_sums
.iter()
.find(|s| s.path.to_string_lossy() == path)
.map(|s| s.sum)
};
assert_eq!(sum("a.rs"), Some(9));
assert_eq!(sum("b.rs"), Some(8));
assert_eq!(sum("c.rs"), Some(7));
assert_eq!(report.file_sums[0].path.to_string_lossy(), "a.rs");
}
#[test]
fn min_coupling_filters_low_count_pairs() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
for i in 0..3 {
commit_files(
&repo,
&[("a.rs", &format!("a{i}\n")), ("b.rs", &format!("b{i}\n"))],
&format!("ab {i}"),
now - 100 + i,
);
}
commit_files(
&repo,
&[("a.rs", "a-x\n"), ("c.rs", "c-x\n")],
"ac",
now - 10,
);
let report = observer(2).scan(dir.path());
assert_eq!(report.pairs.len(), 1);
assert_eq!(report.pairs[0].a.to_string_lossy(), "a.rs");
assert_eq!(report.pairs[0].b.to_string_lossy(), "b.rs");
assert_eq!(report.pairs[0].count, 3);
}
#[test]
fn bulk_commits_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
let bodies: Vec<(String, String)> = (0..51)
.map(|i| (format!("bulk/f{i}.rs"), format!("x{i}\n")))
.collect();
let refs: Vec<(&str, &str)> = bodies
.iter()
.map(|(p, b)| (p.as_str(), b.as_str()))
.collect();
commit_files(&repo, &refs, "bulk", now - 10);
let report = observer(1).scan(dir.path());
assert!(report.pairs.is_empty(), "got {:?}", report.pairs);
assert_eq!(report.totals.commits_considered, 0);
}
#[test]
fn worst_n_pairs_and_files_truncate_in_existing_order() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
for i in 0..3 {
commit_files(
&repo,
&[("a.rs", &format!("a{i}\n")), ("b.rs", &format!("b{i}\n"))],
&format!("ab {i}"),
now - 100 + i,
);
}
for i in 0..2 {
commit_files(
&repo,
&[("a.rs", &format!("a-x{i}\n")), ("c.rs", &format!("c{i}\n"))],
&format!("ac {i}"),
now - 80 + i,
);
}
commit_files(
&repo,
&[("b.rs", "b-y\n"), ("c.rs", "c-y\n")],
"bc",
now - 60,
);
commit_files(
&repo,
&[("c.rs", "c-z\n"), ("d.rs", "d-z\n")],
"cd",
now - 40,
);
let report = observer(1).scan(dir.path());
assert_eq!(report.pairs.len(), 4);
let top2 = report.worst_n_pairs(2);
assert_eq!(top2.len(), 2);
assert_eq!(top2[0].count, 3);
assert_eq!(top2[1].count, 2);
assert_eq!(report.worst_n_pairs(99).len(), 4);
let top_files = report.worst_n_files(2);
assert_eq!(top_files.len(), 2);
assert!(top_files[0].sum >= top_files[1].sum);
}
#[test]
fn excluded_substrings_skip_paths() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
commit_files(
&repo,
&[
("src/a.rs", "1\n"),
("vendor/v.rs", "1\n"),
("src/b.rs", "1\n"),
],
"init",
now - 10,
);
let observer = ChangeCouplingObserver {
enabled: true,
excluded: vec!["vendor/".to_string()],
since_days: 90,
min_coupling: 1,
min_lift: 0.0,
symmetric_threshold: 0.5,
workspace: None,
};
let report = observer.scan(dir.path());
assert_eq!(report.pairs.len(), 1);
assert_eq!(report.pairs[0].a.to_string_lossy(), "src/a.rs");
assert_eq!(report.pairs[0].b.to_string_lossy(), "src/b.rs");
}
#[test]
fn symmetric_pair_when_both_conditional_probs_high() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
for i in 0..4 {
commit_files(
&repo,
&[("a.rs", &format!("a{i}\n")), ("b.rs", &format!("b{i}\n"))],
&format!("ab {i}"),
now - 100 + i,
);
}
let report = observer(2).scan(dir.path());
assert_eq!(report.pairs.len(), 1);
let pair = &report.pairs[0];
assert_eq!(pair.count, 4);
assert_eq!(pair.direction, Some(PairDirection::Symmetric));
}
#[test]
fn one_way_pair_picks_leader_by_conditional_probability() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
for i in 0..4 {
commit_files(
&repo,
&[
("core.rs", &format!("c{i}\n")),
("extras.rs", &format!("e{i}\n")),
],
&format!("co {i}"),
now - 200 + i,
);
}
for i in 0..10 {
commit_files(
&repo,
&[("core.rs", &format!("solo{i}\n"))],
&format!("solo {i}"),
now - 100 + i,
);
}
let report = observer(2).scan(dir.path());
assert_eq!(report.pairs.len(), 1);
let pair = &report.pairs[0];
assert_eq!(pair.count, 4);
let direction = pair.direction.as_ref().expect("direction populated");
let PairDirection::OneWay { from, to } = direction else {
panic!("expected OneWay, got {direction:?}");
};
assert_eq!(
from.to_string_lossy(),
"core.rs",
"core.rs is the leader — its solo edits drive extras.rs",
);
assert_eq!(to.to_string_lossy(), "extras.rs");
}
#[test]
fn symmetric_threshold_above_one_forces_one_way() {
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path());
let now = now_secs();
for i in 0..3 {
commit_files(
&repo,
&[("a.rs", &format!("a{i}\n")), ("b.rs", &format!("b{i}\n"))],
&format!("ab {i}"),
now - 100 + i,
);
}
let strict = ChangeCouplingObserver {
enabled: true,
excluded: Vec::new(),
since_days: 90,
min_coupling: 2,
min_lift: 0.0,
symmetric_threshold: 1.5,
workspace: None,
};
let report = strict.scan(dir.path());
assert_eq!(report.pairs.len(), 1);
assert!(matches!(
report.pairs[0].direction,
Some(PairDirection::OneWay { .. })
));
}