use codelore_lib::Options;
use codelore_lib::analyses::hotspots::run_hotspots;
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
use std::path::Path;
use std::process::Command;
#[test]
fn hotspots_for_tiny_repo() {
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_hotspots(&db, &opts).expect("run");
assert!(!rows.is_empty(), "should produce ≥1 hotspot row");
let main_row = rows
.iter()
.find(|r| r.path == "src/main.rs")
.expect("main.rs should be in hotspots");
let lib_row = rows.iter().find(|r| r.path == "src/lib.rs");
if let Some(lib) = lib_row {
assert!(
main_row.hotspot_score >= lib.hotspot_score,
"main.rs (revs=4) should rank ≥ lib.rs (revs=1)"
);
}
for row in &rows {
assert!(
row.hotspot_score >= 0.0,
"hotspot score should be >= 0, got {} for {}",
row.hotspot_score,
row.path
);
}
}
#[test]
fn hotspots_surface_maintainability_index_for_rust_files() {
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_hotspots(&db, &opts).expect("run");
let any_finite_mi = rows
.iter()
.any(|r| matches!(r.mi, Some(v) if v.is_finite()));
assert!(
any_finite_mi,
"expected ≥1 Rust hotspot row with a finite MI; got rows: {:?}",
rows.iter()
.map(|r| (r.path.as_str(), r.mi))
.collect::<Vec<_>>()
);
}
#[test]
fn hotspots_surface_ai_attribution_percentage() {
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_hotspots(&db, &opts).expect("run");
assert!(!rows.is_empty(), "fixture should produce ≥1 hotspot row");
for row in &rows {
match row.ai_pct {
Some(p) => assert!(
(0.0..=100.0).contains(&p) && p.is_finite(),
"ai_pct out of range for {}: {p}",
row.path
),
None => panic!(
"ai_pct should be Some on every hotspot row (LEFT JOIN over the \
same node set as file_revs); got None on {}",
row.path
),
}
}
}
#[test]
fn explain_sql_returns_non_empty_plan() {
use codelore_lib::analyses::hotspots::build_sql;
use duckdb::params;
let tiny = codelore_lib::test_support::tiny_repo::build();
let repo = GixRepo::open(tiny.dir.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: tiny.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let sql = build_sql(
&Options {
use_canonical_lineage: false,
..Options::default()
},
"complexity_metrics",
);
let plan = db
.explain_sql(&sql, params![1u32, i64::MAX])
.expect("explain");
assert!(
!plan.is_empty(),
"EXPLAIN plan should not be empty; got {plan:?}"
);
let upper = plan.to_uppercase();
assert!(
upper.contains("PROJECTION")
|| upper.contains("ORDER")
|| upper.contains("JOIN")
|| upper.contains("AGGREGATE"),
"EXPLAIN plan missing common operator names; got {plan:?}"
);
}
#[test]
fn build_sql_lineage_off_is_noop_and_on_swaps_both_ctes() {
use codelore_lib::analyses::hotspots::build_sql;
let sql_off = build_sql(
&Options {
use_canonical_lineage: false,
..Options::default()
},
"complexity_metrics",
);
assert!(
!sql_off.contains("changes_lineage"),
"lineage-off must not route through changes_lineage:\n{sql_off}"
);
assert!(
sql_off.contains("FROM changes\n"),
"file_revs must read raw `changes` when lineage is off:\n{sql_off}"
);
assert!(
sql_off.contains("FROM changes ch"),
"file_ai must read raw `changes` when lineage is off:\n{sql_off}"
);
let sql_on = build_sql(
&Options {
use_canonical_lineage: true,
..Options::default()
},
"complexity_metrics",
);
let expected_on = sql_off
.replace("FROM changes\n", "FROM changes_lineage AS changes\n")
.replace("FROM changes ch", "FROM changes_lineage ch");
assert_eq!(
sql_on, expected_on,
"lineage-on must rewrite the source table in both file_revs and \
file_ai and change nothing else"
);
}
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("spawn git")
.success();
assert!(ok, "git {args:?} failed");
}
fn commit(dir: &Path, msgs: &[&str]) {
git(dir, &["add", "."]);
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).arg("commit").arg("--quiet");
for m in msgs {
cmd.arg("-m").arg(m);
}
let ok = cmd.status().expect("spawn git commit").success();
assert!(ok, "git commit failed for {msgs:?}");
}
#[test]
fn ai_pct_covers_pre_rename_commits_under_canonical_lineage() {
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "ai@example.com"]);
git(p, &["config", "user.name", "Dev"]);
std::fs::create_dir_all(p.join("src")).expect("mkdir");
std::fs::write(p.join("src/old.rs"), "pub fn f() -> u32 {\n 1\n}\n").expect("write");
commit(p, &["seed old"]);
std::fs::write(p.join("src/old.rs"), "pub fn f() -> u32 {\n 2\n}\n").expect("write");
commit(p, &["edit old", "Co-Authored-By: Claude"]);
std::fs::write(p.join("src/old.rs"), "pub fn f() -> u32 {\n 3\n}\n").expect("write");
commit(p, &["edit old again", "Co-Authored-By: Claude"]);
git(p, &["mv", "src/old.rs", "src/new.rs"]);
commit(p, &["rename old to new"]);
std::fs::write(p.join("src/new.rs"), "pub fn f() -> u32 {\n 4\n}\n").expect("write");
commit(p, &["edit new"]);
let repo = GixRepo::open(p).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: p.to_path_buf(),
min_revs: 1,
use_canonical_lineage: true,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_hotspots(&db, &opts).expect("run");
assert!(
!rows.iter().any(|r| r.path == "src/old.rs"),
"old.rs should merge into src/new.rs under canonical lineage; got {rows:?}"
);
let row = rows
.iter()
.find(|r| r.path == "src/new.rs")
.expect("src/new.rs must appear in hotspots");
let ai = row.ai_pct.expect("ai_pct present on the canonical row");
let expected = 2.0 / f64::from(row.revisions) * 100.0;
assert!(
(ai - expected).abs() < 1e-9,
"ai_pct must cover the same population as revs: ai_pct={ai} revs={} expected={expected}",
row.revisions
);
assert!(
ai > 0.0,
"pre-rename AI commits must fold into the canonical ai_pct"
);
}
#[test]
fn ai_pct_is_populated_under_time_bucket() {
use codelore_lib::options::TimeBucket;
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "dev@example.com"]);
git(p, &["config", "user.name", "Dev"]);
std::fs::create_dir_all(p.join("src")).expect("mkdir");
std::fs::write(p.join("src/app.rs"), "pub fn f() -> u32 {\n 1\n}\n").expect("write");
commit(p, &["seed app"]);
std::fs::write(p.join("src/app.rs"), "pub fn f() -> u32 {\n 2\n}\n").expect("write");
commit(p, &["edit app", "Co-Authored-By: Claude"]);
let repo = GixRepo::open(p).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: p.to_path_buf(),
min_revs: 1,
time_bucket: Some(TimeBucket::Month),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_hotspots(&db, &opts).expect("run");
let row = rows
.iter()
.find(|r| r.path == "src/app.rs")
.expect("src/app.rs must appear in bucketed hotspots");
let ai = row.ai_pct.expect(
"ai_pct must be populated under --time-bucket (real-rev source, not changes_bucketed)",
);
assert!(
ai > 0.0,
"the AI-assisted commit must lift ai_pct above zero; got {ai}"
);
assert!(
(ai - 50.0).abs() < 1e-9,
"ai_pct is commit-level (1 AI / 2 commits = 50%), independent of bucketing; got {ai}"
);
}
#[test]
fn ai_pct_is_populated_under_time_bucket_with_canonical_lineage() {
use codelore_lib::options::TimeBucket;
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
git(p, &["init", "-b", "main", "--quiet"]);
git(p, &["config", "user.email", "dev@example.com"]);
git(p, &["config", "user.name", "Dev"]);
std::fs::create_dir_all(p.join("src")).expect("mkdir");
std::fs::write(p.join("src/old.rs"), "pub fn f() -> u32 {\n 1\n}\n").expect("write");
commit(p, &["seed old"]);
std::fs::write(p.join("src/old.rs"), "pub fn f() -> u32 {\n 2\n}\n").expect("write");
commit(p, &["edit old", "Co-Authored-By: Claude"]);
git(p, &["mv", "src/old.rs", "src/new.rs"]);
commit(p, &["rename old to new"]);
std::fs::write(p.join("src/new.rs"), "pub fn f() -> u32 {\n 3\n}\n").expect("write");
commit(p, &["edit new"]);
let repo = GixRepo::open(p).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: p.to_path_buf(),
min_revs: 1,
time_bucket: Some(TimeBucket::Month),
use_canonical_lineage: true,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_hotspots(&db, &opts).expect("run");
let row = rows
.iter()
.find(|r| r.path == "src/new.rs")
.expect("src/new.rs must appear under bucketed canonical lineage");
let ai = row
.ai_pct
.expect("ai_pct must be populated under --time-bucket + canonical lineage");
assert!(
ai > 0.0,
"the pre-rename AI commit must fold into the canonical ai_pct; got {ai}"
);
}