#![allow(clippy::doc_markdown, clippy::items_after_statements)]
use codelore_lib::Options;
use codelore_lib::analyses::knowledge_islands::{owner_activity_for_paths, run_knowledge_islands};
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
fn build_fixture() -> tempfile::TempDir {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
fn run(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
run(
path,
"2024-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
run(
path,
"2024-01-01T00:00:00Z",
&["config", "user.email", "init@x"],
);
run(
path,
"2024-01-01T00:00:00Z",
&["config", "user.name", "Init"],
);
std::fs::write(
path.join("alice_owned.txt"),
"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n",
)
.unwrap();
run(path, "2024-03-01T12:00:00Z", &["add", "alice_owned.txt"]);
run(
path,
"2024-03-01T12:00:00Z",
&[
"commit",
"-m",
"alice",
"--author",
"Alice <alice@old.com>",
"--quiet",
],
);
std::fs::write(path.join("bob_dominates.txt"), "x\n").unwrap();
run(path, "2026-05-25T12:00:00Z", &["add", "bob_dominates.txt"]);
run(
path,
"2026-05-25T12:00:00Z",
&[
"commit",
"-m",
"bob solo",
"--author",
"Bob <bob@active.com>",
"--quiet",
],
);
std::fs::write(
path.join("alice_owned.txt"),
"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\n",
)
.unwrap();
run(path, "2026-05-26T12:00:00Z", &["add", "alice_owned.txt"]);
run(
path,
"2026-05-26T12:00:00Z",
&[
"commit",
"-m",
"bob 1-liner",
"--author",
"Bob <bob@active.com>",
"--quiet",
],
);
dir
}
#[test]
fn knowledge_islands_surfaces_departed_main_author_solo_owner() {
let fixture = build_fixture();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
age_time_now: Some(time::macros::date!(2026 - 06 - 01)),
departed_threshold_days: 30,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_knowledge_islands(&db, &opts).expect("run");
let alice_row = rows
.iter()
.find(|r| r.entity == "alice_owned.txt")
.expect("alice_owned.txt surfaces as knowledge-island");
assert_eq!(alice_row.main_author, "alice@old.com");
assert!(
alice_row.ownership_pct > 80.0,
"Alice owns ~91% (10/11 lines); got {}",
alice_row.ownership_pct,
);
assert!(
alice_row.days_since_main_active > 800,
"should reflect ~822-day gap; got {}",
alice_row.days_since_main_active,
);
assert_eq!(
alice_row.total_loc, 11,
"total_loc must disclose the ownership_pct denominator (10 Alice + 1 Bob), got {}",
alice_row.total_loc,
);
assert!(
!rows.iter().any(|r| r.entity == "bob_dominates.txt"),
"bob_dominates.txt should NOT surface; Bob's last commit is recent",
);
}
#[test]
fn knowledge_islands_threshold_gates_inclusion() {
let fixture = build_fixture();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
age_time_now: Some(time::macros::date!(2026 - 06 - 01)),
departed_threshold_days: 365 * 10,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_knowledge_islands(&db, &opts).expect("run");
assert!(
rows.is_empty(),
"10-year departure threshold → no rows; got {} rows",
rows.len(),
);
}
#[test]
fn knowledge_islands_excludes_deleted_files() {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
fn run(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
run(
path,
"2024-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
run(
path,
"2024-01-01T00:00:00Z",
&["config", "user.email", "t@t"],
);
run(
path,
"2024-01-01T00:00:00Z",
&["config", "user.name", "Tiny"],
);
std::fs::write(path.join("gone.txt"), "x\ny\nz\n").unwrap();
run(path, "2024-03-01T12:00:00Z", &["add", "gone.txt"]);
run(
path,
"2024-03-01T12:00:00Z",
&[
"commit",
"-m",
"add",
"--author",
"Alice <alice@old.com>",
"--quiet",
],
);
std::fs::remove_file(path.join("gone.txt")).unwrap();
run(path, "2024-04-01T12:00:00Z", &["add", "-A"]);
run(
path,
"2024-04-01T12:00:00Z",
&[
"commit",
"-m",
"delete",
"--author",
"Alice <alice@old.com>",
"--quiet",
],
);
let repo = GixRepo::open(path).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: path.to_path_buf(),
min_revs: 1,
age_time_now: Some(time::macros::date!(2026 - 06 - 01)),
departed_threshold_days: 30,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_knowledge_islands(&db, &opts).expect("run");
assert!(
!rows.iter().any(|r| r.entity == "gone.txt"),
"deleted files must not appear in knowledge-islands; got {rows:?}",
);
}
#[test]
fn owner_activity_for_paths_returns_unthresholded_activity_and_omits_unknown() {
let fixture = build_fixture();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
age_time_now: Some(time::macros::date!(2026 - 06 - 01)),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let paths = vec![
"alice_owned.txt".to_string(),
"bob_dominates.txt".to_string(),
"no_such_file.txt".to_string(),
];
let map = owner_activity_for_paths(&db, &opts, &paths).expect("helper");
let alice = map.get("alice_owned.txt").expect("alice file present");
assert_eq!(alice.main_author, "alice@old.com");
assert!(
alice.days_since_main_active > 800,
"Alice last committed 2024-03-01; got {}",
alice.days_since_main_active
);
let bob = map.get("bob_dominates.txt").expect("bob file present");
assert!(
bob.days_since_main_active < 30,
"Bob is active; got {}",
bob.days_since_main_active
);
assert!(
!map.contains_key("no_such_file.txt"),
"unknown path = no entry = Inconclusive"
);
assert_eq!(map.len(), 2, "no extra paths leak into the result");
}
#[test]
fn owner_activity_for_paths_empty_paths_returns_empty_map_without_querying() {
let fixture = build_fixture();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
age_time_now: Some(time::macros::date!(2026 - 06 - 01)),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let map = owner_activity_for_paths(&db, &opts, &[]).expect("empty paths");
assert!(map.is_empty());
}
fn build_single_commit_fixture() -> tempfile::TempDir {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
fn run(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
run(
path,
"2024-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
run(
path,
"2024-01-01T00:00:00Z",
&["config", "user.email", "init@x"],
);
run(
path,
"2024-01-01T00:00:00Z",
&["config", "user.name", "Init"],
);
std::fs::write(path.join("solo.txt"), "a\nb\nc\n").unwrap();
run(path, "2024-03-01T12:00:00Z", &["add", "solo.txt"]);
run(
path,
"2024-03-01T12:00:00Z",
&[
"commit",
"-m",
"solo add",
"--author",
"Old <old@dev.com>",
"--quiet",
],
);
dir
}
#[test]
fn knowledge_islands_min_revs_gates_single_commit_files() {
let fixture = build_single_commit_fixture();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let ingest_opts = Options {
repo_path: fixture.path().to_path_buf(),
age_time_now: Some(time::macros::date!(2026 - 06 - 01)),
departed_threshold_days: 30,
..Options::default()
};
db.ingest(&repo, &ingest_opts).expect("ingest");
let opts_min_revs_2 = Options {
min_revs: 2,
..ingest_opts.clone()
};
let rows_at_2 = run_knowledge_islands(&db, &opts_min_revs_2).expect("run min_revs=2");
assert!(
!rows_at_2.iter().any(|r| r.entity == "solo.txt"),
"solo.txt has 1 revision; must be excluded under min_revs=2, got {rows_at_2:?}",
);
let opts_min_revs_1 = Options {
min_revs: 1,
..ingest_opts
};
let rows_at_1 = run_knowledge_islands(&db, &opts_min_revs_1).expect("run min_revs=1");
let solo_row = rows_at_1
.iter()
.find(|r| r.entity == "solo.txt")
.expect("solo.txt must surface under min_revs=1");
assert_eq!(solo_row.main_author, "old@dev.com");
assert!(
(solo_row.ownership_pct - 100.0).abs() < f64::EPSILON,
"solo author owns 100%; got {}",
solo_row.ownership_pct,
);
assert_eq!(
solo_row.total_loc, 3,
"solo.txt's single commit adds exactly 3 lines; total_loc must disclose that \
thin denominator behind the 100% ownership figure, got {}",
solo_row.total_loc,
);
}
fn seed_commit(db: &FactsDb, rev: &str, day: u32) {
db.execute_batch(&format!(
"INSERT INTO commits (rev, author_email, author_name, committer_email, \
canonical_author, date, committer_date, message, is_merge, parent_count) \
VALUES ('{rev}', 'a@x', 'A', 'a@x', 'a@x', \
'2026-03-{day:02} 12:00:00', '2026-03-{day:02} 12:00:00', 'm', false, 1)"
))
.expect("seed commit");
}
#[test]
fn deleted_paths_do_not_survive_into_knowledge_shares() {
let db = FactsDb::new_in_memory().expect("db");
seed_commit(&db, "c3", 3);
seed_commit(&db, "c2", 2);
seed_commit(&db, "c1", 1);
for stmt in [
"INSERT INTO author_aliases VALUES ('A', 'a@x', 'a@x', false)",
"INSERT INTO changes VALUES ('c1', 'dead.rs', 'added', NULL, 10, 0)",
"INSERT INTO changes VALUES ('c1', 'live.rs', 'added', NULL, 10, 0)",
"INSERT INTO changes VALUES ('c2', 'live.rs', 'modified', NULL, 3, 1)",
"INSERT INTO changes VALUES ('c3', 'dead.rs', 'deleted', NULL, 0, 10)",
] {
db.execute_batch(stmt).expect("seed");
}
let opts = Options {
min_revs: 1,
..Options::default()
};
codelore_lib::analyses::knowledge::shares::materialize_knowledge_shares(&db, &opts)
.expect("materialize");
let dead: i64 = db
.query_row(
"SELECT COUNT(*) FROM knowledge_shares WHERE path = 'dead.rs'",
[],
|r| r.get(0),
)
.expect("query dead");
assert_eq!(
dead, 0,
"a path whose last event is a deletion must not appear in knowledge_shares"
);
let live: i64 = db
.query_row(
"SELECT COUNT(*) FROM knowledge_shares WHERE path = 'live.rs'",
[],
|r| r.get(0),
)
.expect("query live");
assert!(live >= 1, "the live control path must remain present");
}
#[test]
fn count_live_files_folds_renamed_away_paths_under_lineage() {
use codelore_lib::analyses::knowledge_islands::count_live_files;
let db = FactsDb::new_in_memory().expect("db");
seed_commit(&db, "c2", 2);
seed_commit(&db, "c1", 1);
for stmt in [
"INSERT INTO changes VALUES ('c1', 'a.rs', 'added', NULL, 5, 0)",
"INSERT INTO changes VALUES ('c2', 'b.rs', 'renamed', 'a.rs', 0, 0)",
] {
db.execute_batch(stmt).expect("seed");
}
let lineage_on = Options {
min_revs: 1,
use_canonical_lineage: true,
..Options::default()
};
assert_eq!(
count_live_files(&db, &lineage_on).expect("count"),
1,
"under lineage the retired name folds onto its canonical target — one live file"
);
let lineage_off = Options {
min_revs: 1,
use_canonical_lineage: false,
..Options::default()
};
assert_eq!(
count_live_files(&db, &lineage_off).expect("count"),
2,
"with lineage off both sides consistently use raw paths (documented population)"
);
}