use std::path::Path;
use codelore_lib::Options;
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
fn run_git(path: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
}
fn write_file(root: &Path, rel: &str, content: &str) {
let p = root.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
}
#[test]
fn ingest_classifies_bot_commits_as_ai_authored() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
run_git(path, &["init", "-b", "main", "--quiet"]);
run_git(
path,
&["config", "user.email", "dependabot[bot]@noreply.github.com"],
);
run_git(path, &["config", "user.name", "dependabot[bot]"]);
write_file(
path,
"Cargo.toml",
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
);
run_git(path, &["add", "."]);
run_git(path, &["commit", "-m", "bump deps", "--quiet"]);
let repo = GixRepo::open(path).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options::default();
db.ingest(&repo, &opts).expect("ingest");
let ai_attr: String = db
.query_one_value("SELECT ai_attribution FROM commits LIMIT 1")
.expect("ai_attribution query");
assert_eq!(
ai_attr, "ai-authored",
"dependabot commits should be ai-authored"
);
let is_bot: String = db
.query_one_value(
"SELECT CAST(is_bot AS TEXT) FROM author_aliases WHERE raw_email = 'dependabot[bot]@noreply.github.com'",
)
.expect("is_bot query");
assert_eq!(
is_bot, "true",
"dependabot should be flagged as bot in author_aliases"
);
}
#[test]
fn ingest_classifies_human_commits_correctly() {
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::default();
db.ingest(&repo, &opts).expect("ingest");
let ai_attr_values: String = db
.query_one_value(
"SELECT CAST(COUNT(*) AS TEXT) FROM commits WHERE ai_attribution = 'human'",
)
.expect("human count query");
let count: u32 = ai_attr_values.parse().unwrap();
assert_eq!(
count, 5,
"all 5 tiny repo commits should be classified as human"
);
}
#[test]
fn ingest_populates_author_aliases() {
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::default();
db.ingest(&repo, &opts).expect("ingest");
let alias_count: String = db
.query_one_value(
"SELECT CAST(COUNT(*) AS TEXT) FROM author_aliases WHERE raw_email = 'tiny@example.com'",
)
.expect("alias count query");
let n: u32 = alias_count.parse().unwrap();
assert_eq!(n, 1, "expected one alias row for tiny@example.com");
}
#[test]
fn ingest_tiny_repo_writes_5_commits() {
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::default();
let n = db.ingest(&repo, &opts).expect("ingest");
assert_eq!(n.commits_ingested, 5);
let count: String = db
.query_one_value("SELECT CAST(COUNT(*) AS TEXT) FROM commits")
.expect("count");
assert_eq!(count, "5");
}
#[test]
fn ingest_populates_complexity_for_tier1_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 entity_count: String = db
.query_one_value("SELECT CAST(COUNT(*) AS TEXT) FROM entities WHERE path = 'src/main.rs'")
.expect("entity count query");
let n: u32 = entity_count.parse().unwrap();
assert!(n >= 1, "expected ≥1 entity for src/main.rs, got {n}");
let metric_count: String = db
.query_one_value(
"SELECT CAST(COUNT(*) AS TEXT) FROM complexity_metrics WHERE path = 'src/main.rs'",
)
.expect("metric count query");
let m: u32 = metric_count.parse().unwrap();
assert!(
m >= 1,
"expected ≥1 complexity row for src/main.rs, got {m}"
);
}
#[cfg(feature = "test-support")]
#[test]
fn ingest_biomarker_repo_persists_nargs_and_nesting() {
let biomarker = codelore_lib::test_support::biomarker_repo::build();
let repo = GixRepo::open(biomarker.dir.path()).expect("open biomarker repo");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: biomarker.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let nargs: String = db
.query_one_value(
"SELECT CAST(nargs AS TEXT) FROM complexity_metrics \
WHERE path = 'src/complex.rs' AND name LIKE 'complex@%'",
)
.expect("nargs query");
let nargs: u32 = nargs.parse().expect("nargs parse");
assert!(
nargs > 0,
"complex() takes 3 args — nargs should be > 0, got {nargs}"
);
let max_nesting: String = db
.query_one_value(
"SELECT CAST(max_nesting AS TEXT) FROM complexity_metrics \
WHERE path = 'src/complex.rs' AND name LIKE 'complex@%'",
)
.expect("max_nesting query");
let max_nesting: u32 = max_nesting.parse().expect("max_nesting parse");
assert!(
max_nesting > 0,
"complex() has nested loops and ifs — max_nesting should be > 0, got {max_nesting}"
);
}
#[test]
fn ingest_writes_hunk_rows_to_hunks_table() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
run_git(path, &["init", "-b", "main", "--quiet"]);
run_git(path, &["config", "user.email", "alice@example.com"]);
run_git(path, &["config", "user.name", "Alice"]);
let initial: String = (1..=30)
.map(|n| format!("line {n}"))
.collect::<Vec<_>>()
.join("\n");
write_file(path, "src/code.rs", &format!("{initial}\n"));
run_git(path, &["add", "."]);
run_git(path, &["commit", "-m", "initial", "--quiet"]);
let edited: String = (1..=30)
.map(|n| match n {
2 => "line 2 EDITED".to_string(),
25 => "line 25 EDITED".to_string(),
other => format!("line {other}"),
})
.collect::<Vec<_>>()
.join("\n");
write_file(path, "src/code.rs", &format!("{edited}\n"));
run_git(
path,
&["commit", "-am", "two non-adjacent edits", "--quiet"],
);
let repo = GixRepo::open(path).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options::default();
db.ingest(&repo, &opts).expect("ingest");
let hunk_count: String = db
.query_one_value("SELECT CAST(COUNT(*) AS TEXT) FROM hunks WHERE path = 'src/code.rs'")
.expect("hunk count query");
let n: u32 = hunk_count.parse().unwrap();
assert!(
n >= 2,
"expected ≥2 hunk rows for the two-edit commit on src/code.rs, got {n}"
);
let nulls: String = db
.query_one_value(
"SELECT CAST(COUNT(*) AS TEXT) FROM hunks \
WHERE old_start IS NULL OR old_lines IS NULL \
OR new_start IS NULL OR new_lines IS NULL",
)
.expect("null offsets query");
assert_eq!(nulls, "0", "no hunk row may have NULL offset columns");
}
#[cfg(not(target_os = "windows"))]
fn commit_at(path: &Path, message: &str, date: &str) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(path)
.args(["commit", "-q", "-m", message, "--date", date])
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git commit");
assert!(status.success(), "git commit '{message}' failed");
}
#[cfg(not(target_os = "windows"))]
#[test]
fn ingest_large_repo_crosses_fk_flush_threshold() {
const FILE_COUNT: usize = 50;
const EDITS_PER_FILE: usize = 20;
const MODIFY_COMMITS: usize = 215;
const ANCHOR_GAP: usize = 8;
const BODY_LINES: usize = EDITS_PER_FILE * ANCHOR_GAP + 4;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
run_git(path, &["init", "-b", "main", "--quiet"]);
run_git(path, &["config", "user.email", "flush@example.com"]);
run_git(path, &["config", "user.name", "Flush Tester"]);
let body = |marker: usize| -> String {
let mut lines: Vec<String> = (0..BODY_LINES).map(|n| format!("line {n}")).collect();
for e in 0..EDITS_PER_FILE {
lines[1 + e * ANCHOR_GAP] = format!("edit {e} v{marker}");
}
lines.join("\n") + "\n"
};
let commit_date = |seq: u64| -> String { format!("@{} +0000", 1_767_225_600 + seq * 3600) };
for f in 0..FILE_COUNT {
write_file(path, &format!("src/f{f}.rs"), &body(0));
}
run_git(path, &["add", "."]);
commit_at(path, "seed", &commit_date(0));
for c in 0..MODIFY_COMMITS {
for f in 0..FILE_COUNT {
write_file(path, &format!("src/f{f}.rs"), &body(c + 1));
}
run_git(path, &["add", "."]);
commit_at(path, &format!("modify {c}"), &commit_date(c as u64 + 1));
}
let repo = GixRepo::open(path).expect("open");
let db = FactsDb::open(dir.path().join("facts.duckdb")).expect("db");
let opts = Options::default();
db.ingest(&repo, &opts)
.expect("ingest must not abort on FK-flush ordering");
let hunk_rows: u64 = db
.query_one_value("SELECT CAST(COUNT(*) AS TEXT) FROM hunks")
.expect("hunk count query")
.parse()
.expect("parse hunk count");
assert!(
hunk_rows > 204_800,
"test must push `hunks` past the 204_800-row FK-check threshold; got {hunk_rows}"
);
let orphan_changes: u64 = db
.query_one_value(
"SELECT CAST(COUNT(*) AS TEXT) FROM changes c \
WHERE NOT EXISTS (SELECT 1 FROM commits m WHERE m.rev = c.rev)",
)
.expect("orphan changes query")
.parse()
.expect("parse orphan changes");
assert_eq!(
orphan_changes, 0,
"no change row may reference a missing commit"
);
let orphan_hunks: u64 = db
.query_one_value(
"SELECT CAST(COUNT(*) AS TEXT) FROM hunks h \
WHERE NOT EXISTS ( \
SELECT 1 FROM changes c WHERE c.rev = h.rev AND c.path = h.path \
)",
)
.expect("orphan hunks query")
.parse()
.expect("parse orphan hunks");
assert_eq!(
orphan_hunks, 0,
"no hunk row may reference a missing change"
);
}
fn complexity_facts(db: &FactsDb) -> Vec<String> {
let mut stmt = db
.prepare(
"SELECT path, name, cyclomatic, cognitive, sloc, nargs, max_nesting, bool_ops \
FROM complexity_metrics \
ORDER BY path, name, cyclomatic, cognitive, sloc, nargs, max_nesting, bool_ops",
)
.expect("prepare complexity rows");
let mapped = stmt
.query_map([], |r| {
Ok(format!(
"{}|{}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<i64>>(4)?,
r.get::<_, Option<i64>>(5)?,
r.get::<_, Option<i64>>(6)?,
r.get::<_, Option<i64>>(7)?,
))
})
.expect("query complexity rows");
mapped
.collect::<Result<Vec<_>, _>>()
.expect("collect complexity rows")
}
#[test]
fn head_only_ingest_matches_full_ingest_complexity_and_leaves_history_empty() {
let bio = codelore_lib::test_support::biomarker_repo::build();
let repo = GixRepo::open(bio.dir.path()).expect("open");
let full_opts = Options {
repo_path: bio.dir.path().to_path_buf(),
..Options::default()
};
let head_only_opts = Options {
head_only_ingest: true,
..full_opts.clone()
};
let full_db = FactsDb::new_in_memory().expect("full db");
full_db.ingest(&repo, &full_opts).expect("full ingest");
let head_db = FactsDb::new_in_memory().expect("head-only db");
let stats = head_db
.ingest(&repo, &head_only_opts)
.expect("head-only ingest");
assert_eq!(stats.commits_ingested, 0, "no commits walked in head-only");
assert_eq!(stats.changes_ingested, 0, "no changes walked in head-only");
assert_eq!(stats.clones_ingested, 0, "clones pass skipped in head-only");
let count = |db: &FactsDb, table: &str| -> u64 {
db.query_one_value(&format!("SELECT CAST(COUNT(*) AS TEXT) FROM {table}"))
.expect("count query")
.parse()
.expect("parse count")
};
assert_eq!(count(&head_db, "commits"), 0, "commits must stay empty");
assert_eq!(count(&head_db, "changes"), 0, "changes must stay empty");
assert!(
count(&head_db, "complexity_metrics") > 0,
"complexity_metrics must be populated by head-only ingest"
);
let head_rev = head_db
.query_one_value("SELECT DISTINCT rev FROM complexity_metrics")
.expect("distinct rev");
assert_eq!(
head_rev, bio.head_sha,
"head-only rows must carry the fixture's HEAD SHA"
);
let head_rows = complexity_facts(&head_db);
let full_rows = complexity_facts(&full_db);
assert!(!head_rows.is_empty(), "fixture must yield complexity rows");
assert_eq!(
head_rows, full_rows,
"head-only and full ingest must extract the same complexity facts from the same tree"
);
let import_rows = |db: &FactsDb| -> Vec<String> {
let mut stmt = db
.prepare(
"SELECT src_path, target_path FROM imports \
WHERE target_path IS NOT NULL \
ORDER BY 1, 2",
)
.expect("prepare imports rows");
let mapped = stmt
.query_map([], |r| {
Ok(format!(
"{}|{}",
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
))
})
.expect("query imports rows");
mapped
.collect::<Result<Vec<_>, _>>()
.expect("collect imports rows")
};
let head_import_rows = import_rows(&head_db);
let full_import_rows = import_rows(&full_db);
assert!(
!head_import_rows.is_empty(),
"fixture must yield at least one resolved import edge"
);
assert_eq!(
head_import_rows, full_import_rows,
"head-only and full ingest must resolve the same import edges from the same tree"
);
assert_eq!(
count(&head_db, "commits"),
0,
"commits must stay empty for head-only ingest"
);
}