use crate::{CodeLoreError, Options, Result, facts::FactsDb};
#[derive(Debug, Clone, serde::Serialize)]
pub struct EvidenceCommit {
pub rev: String,
pub date: String,
pub author: String,
pub churn: i64,
pub message_head: String,
}
pub fn evidence_for_path(
db: &FactsDb,
opts: &Options,
path: &str,
n: u32,
) -> Result<Vec<EvidenceCommit>> {
crate::analyses::lineage::materialize_if_needed(db, opts)?;
let src = crate::analyses::lineage::source_table(opts);
let sql = format!(
"SELECT co.rev,
CAST(co.date AS TEXT),
co.canonical_author,
c.loc_added + c.loc_deleted AS churn,
substr(split_part(co.message, chr(10), 1), 1, 80)
FROM {src} c
JOIN commits co USING (rev)
WHERE c.path = ?
ORDER BY co.date DESC, co.rev DESC
LIMIT ?"
);
let mut stmt = db
.conn()
.prepare(&sql)
.map_err(|e| CodeLoreError::Analysis(format!("evidence_for_path prepare: {e}")))?;
let rows = stmt
.query_map(duckdb::params![path, n], |r| {
Ok(EvidenceCommit {
rev: r.get(0)?,
date: r.get(1)?,
author: r.get(2)?,
churn: r.get(3)?,
message_head: r.get(4)?,
})
})
.map_err(|e| CodeLoreError::Analysis(format!("evidence_for_path query: {e}")))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| CodeLoreError::Analysis(format!("evidence_for_path row: {e}")))?;
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Options;
use crate::facts::FactsDb;
use crate::repo::GixRepo;
#[cfg(feature = "test-support")]
use crate::test_support::biomarker_repo;
#[cfg(feature = "test-support")]
fn ingest_biomarker() -> (FactsDb, Options, biomarker_repo::BiomarkerRepo) {
let repo = biomarker_repo::build();
let opts = Options {
repo_path: repo.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
let gix = GixRepo::open(&opts.repo_path).expect("open gix repo");
let db = FactsDb::open_or_ingest(&opts, &gix).expect("ingest");
(db, opts, repo)
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_newest_first_and_churn_positive() {
let (db, opts, _repo) = ingest_biomarker();
let rows = evidence_for_path(&db, &opts, "src/complex.rs", 5).expect("evidence_for_path");
assert!(
!rows.is_empty(),
"expected evidence rows for src/complex.rs, got none"
);
for r in &rows {
assert!(
r.churn > 0,
"expected churn > 0 for every evidence row, got churn={} at {}",
r.churn,
r.date,
);
}
for w in rows.windows(2) {
assert!(
w[0].date >= w[1].date,
"expected newest-first ordering: {} < {}",
w[0].date,
w[1].date,
);
}
for r in &rows {
assert!(
!r.message_head.is_empty(),
"expected non-empty message_head, got empty at {}",
r.date,
);
}
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_nonexistent_path_returns_empty() {
let (db, opts, _repo) = ingest_biomarker();
let rows = evidence_for_path(&db, &opts, "src/does_not_exist.rs", 5)
.expect("evidence_for_path for nonexistent path");
assert!(
rows.is_empty(),
"expected empty vec for nonexistent path, got {} rows",
rows.len(),
);
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_n_limits_results() {
let (db, opts, _repo) = ingest_biomarker();
let rows =
evidence_for_path(&db, &opts, "src/complex.rs", 2).expect("evidence_for_path with n=2");
assert!(
rows.len() <= 2,
"expected at most 2 rows, got {}",
rows.len(),
);
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_ordering_is_deterministic() {
let (db, opts, _repo) = ingest_biomarker();
let rows1 = evidence_for_path(&db, &opts, "src/complex.rs", 5).expect("first call");
let rows2 = evidence_for_path(&db, &opts, "src/complex.rs", 5).expect("second call");
assert_eq!(
rows1.iter().map(|r| &r.rev).collect::<Vec<_>>(),
rows2.iter().map(|r| &r.rev).collect::<Vec<_>>(),
"evidence ordering must be deterministic across identical calls"
);
}
#[cfg(feature = "test-support")]
fn repo_with_message(msg: &str) -> (FactsDb, Options, tempfile::TempDir) {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
let git = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(p)
.args(args)
.status()
.expect("git")
.success();
assert!(ok, "git {args:?} failed");
};
git(&["init", "-b", "main", "--quiet"]);
git(&["config", "user.email", "t@test.com"]);
git(&["config", "user.name", "T"]);
std::fs::write(p.join("f.rs"), "fn x() {}").unwrap();
git(&["add", "f.rs"]);
let ok = Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "--quiet", "-m"])
.arg(msg)
.status()
.expect("git commit")
.success();
assert!(ok, "git commit with long message failed");
let opts = Options {
repo_path: p.to_path_buf(),
min_revs: 1,
..Options::default()
};
let gix = GixRepo::open(p).expect("open gix repo");
let db = FactsDb::open_or_ingest(&opts, &gix).expect("ingest");
(db, opts, dir)
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_message_head_truncated_to_80_ascii() {
let long_msg = "a".repeat(95);
let (db, opts, _dir) = repo_with_message(&long_msg);
let rows = evidence_for_path(&db, &opts, "f.rs", 5).expect("evidence_for_path");
assert!(
!rows.is_empty(),
"expected at least one evidence row for f.rs"
);
let head = &rows[0].message_head;
assert_eq!(
head.chars().count(),
80,
"message_head must be exactly 80 chars for a 95-char input, got {:?} (len {})",
head,
head.chars().count(),
);
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_message_head_multibyte_safe() {
let msg = format!("{}{}", "x".repeat(79), "字".repeat(5));
assert!(msg.chars().count() == 84, "sanity: 84 code points total");
let (db, opts, _dir) = repo_with_message(&msg);
let rows = evidence_for_path(&db, &opts, "f.rs", 5).expect("evidence_for_path");
assert!(
!rows.is_empty(),
"expected at least one evidence row for f.rs"
);
let head = &rows[0].message_head;
let char_count = head.chars().count();
assert!(
char_count <= 80,
"message_head must be ≤ 80 code points, got {char_count}: {head:?}"
);
assert!(
head.starts_with(&"x".repeat(79)),
"first 79 chars must be ASCII 'x', got: {head:?}"
);
}
#[cfg(feature = "test-support")]
fn repo_with_same_second_edits() -> (FactsDb, Options, tempfile::TempDir) {
use std::process::Command;
let dir = tempfile::tempdir().expect("tempdir");
let p = dir.path();
let git = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(p)
.args(args)
.status()
.expect("git")
.success();
assert!(ok, "git {args:?} failed");
};
let commit_at = |msg: &str, date: &str| {
let ok = Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "--quiet", "-m", msg])
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git commit")
.success();
assert!(ok, "git commit failed: {msg}");
};
git(&["init", "-b", "main", "--quiet"]);
git(&["config", "user.email", "t@test.com"]);
git(&["config", "user.name", "T"]);
std::fs::write(p.join("foo.rs"), "fn a() {}\n").unwrap();
git(&["add", "foo.rs"]);
commit_at("seed foo", "2026-04-01T10:00:00Z");
let same_second = "2026-04-01T11:00:00Z";
std::fs::write(p.join("foo.rs"), "fn a() {}\nfn b() {}\n").unwrap();
git(&["add", "foo.rs"]);
commit_at("edit foo once", same_second);
std::fs::write(p.join("foo.rs"), "fn a() {}\nfn b() {}\nfn c() {}\n").unwrap();
git(&["add", "foo.rs"]);
commit_at("edit foo twice", same_second);
let opts = Options {
repo_path: p.to_path_buf(),
min_revs: 1,
..Options::default()
};
let gix = GixRepo::open(p).expect("open gix repo");
let db = FactsDb::open_or_ingest(&opts, &gix).expect("ingest");
(db, opts, dir)
}
#[test]
#[cfg(feature = "test-support")]
fn evidence_same_second_tie_breaks_by_rev_desc() {
let (db, opts, _dir) = repo_with_same_second_edits();
let rows = evidence_for_path(&db, &opts, "foo.rs", 5).expect("evidence_for_path");
let newest_date = &rows.first().expect("at least one evidence row").date;
let tied: Vec<&str> = rows
.iter()
.filter(|r| &r.date == newest_date)
.map(|r| r.rev.as_str())
.collect();
assert_eq!(
tied.len(),
2,
"expected exactly two commits tied on the newest second, got {tied:?} from rows {rows:?}"
);
assert!(
tied[0] > tied[1],
"same-second commits must be ordered rev DESC (higher SHA first), got {tied:?}"
);
assert_ne!(tied[0], tied[1], "the two tied revs must be distinct SHAs");
}
}