use std::collections::{HashMap, HashSet};
use crate::analyses::query::query_map_collect;
use crate::complexity::Tier1Language;
use crate::facts::FactsDb;
use crate::repo::Repo;
use crate::{CodeLoreError, Result};
use super::MiningStats;
pub const TANGLED_MAX_FILES: u32 = 8;
pub const TANGLED_MAX_CHURN: u32 = 400;
pub trait LineOriginSource {
fn origins(&self, rev: &str, path: &str, lines: &[u32]) -> Result<Vec<(u32, String)>>;
}
pub fn parse_blame_porcelain(output: &str) -> Result<Vec<(u32, String)>> {
let mut pairs = Vec::new();
let mut pending: Option<(String, u32)> = None;
for line in output.lines() {
if line.starts_with('\t') {
let (sha, final_line) = pending.take().ok_or_else(|| {
CodeLoreError::Analysis(
"blame porcelain: content line with no preceding header".to_string(),
)
})?;
pairs.push((final_line, sha));
continue;
}
if let Some(header) = parse_header_line(line) {
pending = Some(header);
}
}
Ok(pairs)
}
fn parse_header_line(line: &str) -> Option<(String, u32)> {
let mut fields = line.split(' ');
let sha = fields.next()?;
if sha.len() != 40 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let _orig_line: u32 = fields.next()?.parse().ok()?;
let final_line: u32 = fields.next()?.parse().ok()?;
Some((sha.to_string(), final_line))
}
pub fn deleted_ranges(db: &FactsDb, fix_rev: &str) -> Result<Vec<(String, u32, u32)>> {
query_map_collect(
db,
"SELECT path, old_start, old_lines FROM hunks \
WHERE rev = ? AND old_lines > 0 ORDER BY path, old_start",
duckdb::params![fix_rev],
"szz:deleted-ranges",
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, u32>(1)?,
r.get::<_, u32>(2)?,
))
},
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SzzLink {
pub defect_rev: String,
pub fix_rev: String,
pub path: String,
}
#[must_use]
pub fn line_comment_prefix(lang: Tier1Language) -> &'static str {
match lang {
Tier1Language::Rust
| Tier1Language::Java
| Tier1Language::JavaScript
| Tier1Language::TypeScript
| Tier1Language::Tsx => "//",
Tier1Language::Python => "#",
}
}
#[must_use]
pub fn is_cosmetic_line(content: &str, lang: Option<Tier1Language>) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() {
return true;
}
match lang {
Some(lang) => trimmed.starts_with(line_comment_prefix(lang)),
None => false,
}
}
fn is_candidate_cosmetic<R: Repo>(repo: &R, parent_rev: &str, path: &str, line_no: u32) -> bool {
let Ok(Some(bytes)) = repo.read_blob_at(parent_rev, path) else {
return false;
};
let Ok(text) = std::str::from_utf8(&bytes) else {
return false;
};
let line_index = usize::try_from(line_no.saturating_sub(1)).unwrap_or(usize::MAX);
let Some(line_content) = text.lines().nth(line_index) else {
return false;
};
is_cosmetic_line(line_content, Tier1Language::from_path(path))
}
#[derive(Debug, Clone, Copy)]
struct FixContext<'a> {
fix_rev: &'a str,
parent_rev: &'a str,
fix_date: &'a str,
}
struct LinkAccumulator {
stats: MiningStats,
links: Vec<SzzLink>,
seen: HashSet<(String, String, String)>,
}
pub fn link_defects<R: Repo>(
db: &FactsDb,
repo: &R,
origin: &dyn LineOriginSource,
fixes: &[(String, String, String)],
commit_dates: &HashMap<String, String, impl std::hash::BuildHasher>,
) -> Result<(Vec<SzzLink>, MiningStats)> {
let mut acc = LinkAccumulator {
stats: MiningStats {
fixes_found: u32::try_from(fixes.len()).unwrap_or(u32::MAX),
..MiningStats::default()
},
links: Vec::new(),
seen: HashSet::new(),
};
for (fix_rev, parent_rev, fix_date) in fixes {
let fix = FixContext {
fix_rev,
parent_rev,
fix_date,
};
link_one_fix(db, repo, origin, fix, commit_dates, &mut acc)?;
}
acc.stats.links_found = u32::try_from(acc.links.len()).unwrap_or(u32::MAX);
acc.links.sort_by(|a, b| {
(&a.defect_rev, &a.fix_rev, &a.path).cmp(&(&b.defect_rev, &b.fix_rev, &b.path))
});
Ok((acc.links, acc.stats))
}
fn link_one_fix<R: Repo>(
db: &FactsDb,
repo: &R,
origin: &dyn LineOriginSource,
fix: FixContext<'_>,
commit_dates: &HashMap<String, String, impl std::hash::BuildHasher>,
acc: &mut LinkAccumulator,
) -> Result<()> {
if fix_is_tangled(db, fix.fix_rev)? {
acc.stats.fixes_excluded_tangled += 1;
return Ok(());
}
let deleted = deleted_ranges(db, fix.fix_rev)?;
if deleted.is_empty() {
acc.stats.pure_addition_fixes += 1;
return Ok(());
}
let ghost_paths = whole_file_deletions(db, fix.fix_rev)?;
let mut lines_by_path: HashMap<&str, Vec<u32>> = HashMap::new();
let mut ghost_skipped: HashSet<&str> = HashSet::new();
for (path, start, count) in &deleted {
if ghost_paths.contains(path.as_str()) {
ghost_skipped.insert(path.as_str());
continue;
}
lines_by_path
.entry(path.as_str())
.or_default()
.extend(*start..start.saturating_add(*count));
}
acc.stats.ghost_files_skipped += u32::try_from(ghost_skipped.len()).unwrap_or(u32::MAX);
for (path, lines) in lines_by_path {
blame_one_file(repo, origin, fix, path, &lines, commit_dates, acc);
}
Ok(())
}
fn fix_is_tangled(db: &FactsDb, fix_rev: &str) -> Result<bool> {
let footprint: Vec<(i64, i64)> = query_map_collect(
db,
"SELECT COUNT(*), COALESCE(SUM(loc_added + loc_deleted), 0) \
FROM changes WHERE rev = ?",
duckdb::params![fix_rev],
"szz:fix-footprint",
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
)?;
let (files, churn) = footprint.first().copied().unwrap_or((0, 0));
Ok(files > i64::from(TANGLED_MAX_FILES) || churn > i64::from(TANGLED_MAX_CHURN))
}
fn whole_file_deletions(db: &FactsDb, fix_rev: &str) -> Result<HashSet<String>> {
let paths: Vec<String> = query_map_collect(
db,
"SELECT path FROM changes WHERE rev = ? AND change_type = 'deleted'",
duckdb::params![fix_rev],
"szz:ghost-deletions",
|r| r.get::<_, String>(0),
)?;
Ok(paths.into_iter().collect())
}
fn blame_one_file<R: Repo>(
repo: &R,
origin: &dyn LineOriginSource,
fix: FixContext<'_>,
path: &str,
lines: &[u32],
commit_dates: &HashMap<String, String, impl std::hash::BuildHasher>,
acc: &mut LinkAccumulator,
) {
acc.stats.files_blamed += 1;
acc.stats.lines_considered += u32::try_from(lines.len()).unwrap_or(u32::MAX);
let candidates = match origin.origins(fix.parent_rev, path, lines) {
Ok(candidates) => candidates,
Err(e) => {
tracing::warn!("szz: blame failed for {path}@{}: {e}", fix.parent_rev);
acc.stats.blame_failures += 1;
return;
}
};
for (line_no, defect_rev) in candidates {
record_candidate(repo, fix, path, line_no, defect_rev, commit_dates, acc);
}
}
fn record_candidate<R: Repo>(
repo: &R,
fix: FixContext<'_>,
path: &str,
line_no: u32,
defect_rev: String,
commit_dates: &HashMap<String, String, impl std::hash::BuildHasher>,
acc: &mut LinkAccumulator,
) {
if is_candidate_cosmetic(repo, fix.parent_rev, path, line_no) {
acc.stats.lines_dropped_cosmetic += 1;
return;
}
let Some(defect_date) = commit_dates.get(&defect_rev) else {
tracing::debug!(
"szz: no commit date recorded for candidate {defect_rev}; \
discarding (cannot verify the clock-skew guard)"
);
return;
};
if defect_date.as_str() >= fix.fix_date {
return; }
let key = (
defect_rev.clone(),
fix.fix_rev.to_string(),
path.to_string(),
);
if acc.seen.insert(key) {
acc.links.push(SzzLink {
defect_rev,
fix_rev: fix.fix_rev.to_string(),
path: path.to_string(),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repo::TagInfo;
use crate::{CommitEvent, FileChange, Hunk, Options};
const CAPTURED_PORCELAIN: &str = r"19359ed79bd51cd486ac341dad62ef5fb0c1bb5a 1 1 5
author Emre Camdere
author-mail <emre@valocom.nl>
author-time 1784103133
author-tz +0200
committer Emre Camdere
committer-mail <emre@valocom.nl>
committer-time 1784103133
committer-tz +0200
summary feat(stats): AUC and precision@k helpers
previous 400050fef8c362222a3abdb8779f0edcc424f93c crates/codelore-lib/src/stats.rs
filename crates/codelore-lib/src/stats.rs
//! Statistical helpers used by the analyses.
19359ed79bd51cd486ac341dad62ef5fb0c1bb5a 2 2
//!
19359ed79bd51cd486ac341dad62ef5fb0c1bb5a 3 3
//! Fisher's exact two-tail p-value for a 2×2 contingency table, used
19359ed79bd51cd486ac341dad62ef5fb0c1bb5a 4 4
//! by `analyses::coupling` to gate coupling pairs at
19359ed79bd51cd486ac341dad62ef5fb0c1bb5a 5 5
//! `p < fisher_significance`.
e8486215e55737c14e0787394a0467e84b346e69 5 6 1
author Emre Camdere
author-mail <emre@valocom.nl>
author-time 1782082568
author-tz +0200
committer Emre Camdere
committer-mail <emre@valocom.nl>
committer-time 1782082568
committer-tz +0200
summary security: port fishers_exact in-tree; drop unmaintained supply-chain dep
filename crates/codelore-lib/src/stats.rs
//!
f33423b4b6e3662a00c8886426673702aad99b70 6 7 1
author Emre Camdere
author-mail <emre@valocom.nl>
author-time 1782145461
author-tz +0200
committer Emre Camdere
committer-mail <emre@valocom.nl>
committer-time 1782145461
committer-tz +0200
summary docs: strip task-ID (F<NN>) references from all code comments
previous 73b8f2783999ca5ebe55233ac9b9f00e08ca6699 crates/codelore-lib/src/stats.rs
filename crates/codelore-lib/src/stats.rs
//! In-tree port of the algorithm previously consumed via the
e8486215e55737c14e0787394a0467e84b346e69 7 8 1
//! `fishers_exact` crate (last release 2018-11). The crate had no live
";
#[test]
fn parses_captured_porcelain_output_incl_repeated_commit_group() {
let pairs = parse_blame_porcelain(CAPTURED_PORCELAIN).expect("parse captured porcelain");
assert_eq!(
pairs,
vec![
(1, "19359ed79bd51cd486ac341dad62ef5fb0c1bb5a".to_string()),
(2, "19359ed79bd51cd486ac341dad62ef5fb0c1bb5a".to_string()),
(3, "19359ed79bd51cd486ac341dad62ef5fb0c1bb5a".to_string()),
(4, "19359ed79bd51cd486ac341dad62ef5fb0c1bb5a".to_string()),
(5, "19359ed79bd51cd486ac341dad62ef5fb0c1bb5a".to_string()),
(6, "e8486215e55737c14e0787394a0467e84b346e69".to_string()),
(7, "f33423b4b6e3662a00c8886426673702aad99b70".to_string()),
(8, "e8486215e55737c14e0787394a0467e84b346e69".to_string()),
]
);
}
#[test]
fn content_line_without_a_preceding_header_is_a_parse_error() {
let err = parse_blame_porcelain("\tsome content with no header").expect_err("must fail");
assert!(matches!(err, CodeLoreError::Analysis(_)));
}
#[test]
fn metadata_only_lines_produce_no_pairs() {
let pairs = parse_blame_porcelain("author Someone\nsummary nothing to see").expect("parse");
assert!(pairs.is_empty());
}
#[test]
fn blank_content_is_cosmetic_regardless_of_language() {
assert!(is_cosmetic_line("", Some(Tier1Language::Rust)));
assert!(is_cosmetic_line(" ", None));
}
#[test]
fn line_comment_prefix_matches_the_language() {
assert!(is_cosmetic_line("// note", Some(Tier1Language::Rust)));
assert!(is_cosmetic_line("# note", Some(Tier1Language::Python)));
}
#[test]
fn mismatched_comment_syntax_is_not_cosmetic() {
assert!(!is_cosmetic_line("# note", Some(Tier1Language::Rust)));
}
#[test]
fn real_code_with_a_trailing_comment_is_not_cosmetic() {
assert!(!is_cosmetic_line(
"let x = 1; // t",
Some(Tier1Language::Rust)
));
}
#[test]
fn unknown_language_is_conservatively_not_cosmetic() {
assert!(!is_cosmetic_line("// note", None));
}
#[test]
fn line_comment_prefix_table_matches_the_spec() {
assert_eq!(line_comment_prefix(Tier1Language::Rust), "//");
assert_eq!(line_comment_prefix(Tier1Language::Java), "//");
assert_eq!(line_comment_prefix(Tier1Language::JavaScript), "//");
assert_eq!(line_comment_prefix(Tier1Language::TypeScript), "//");
assert_eq!(line_comment_prefix(Tier1Language::Tsx), "//");
assert_eq!(line_comment_prefix(Tier1Language::Python), "#");
}
struct FakeRepo {
blobs: HashMap<(String, String), Vec<u8>>,
}
impl Repo for FakeRepo {
fn walk_commits<'a>(
&'a self,
_opts: &'a Options,
) -> Result<Box<dyn Iterator<Item = Result<CommitEvent>> + Send + 'a>> {
unimplemented!("szz tests never walk commits")
}
fn changed_files(&self, _rev: &str) -> Result<Vec<FileChange>> {
unimplemented!("szz tests never list changed files")
}
fn diff_hunks(&self, _rev: &str, _path: &str) -> Result<Vec<Hunk>> {
unimplemented!("szz tests never diff hunks directly")
}
fn resolve_alias(&self, _name: &str, _email: &str) -> String {
unimplemented!("szz tests never resolve aliases")
}
fn head_sha(&self) -> Result<String> {
unimplemented!("szz tests never read HEAD")
}
fn tracked_paths_at_head(&self) -> Result<Vec<String>> {
unimplemented!("szz tests never list tracked paths")
}
fn tags(&self) -> Result<Vec<TagInfo>> {
unimplemented!("szz tests never read tags")
}
fn read_blob_at(&self, rev: &str, path: &str) -> Result<Option<Vec<u8>>> {
Ok(self
.blobs
.get(&(rev.to_string(), path.to_string()))
.cloned())
}
}
struct FakeOrigin {
table: HashMap<(String, String), Vec<(u32, String)>>,
fail_for: HashSet<(String, String)>,
}
impl LineOriginSource for FakeOrigin {
fn origins(&self, rev: &str, path: &str, lines: &[u32]) -> Result<Vec<(u32, String)>> {
let key = (rev.to_string(), path.to_string());
if self.fail_for.contains(&key) {
return Err(CodeLoreError::Analysis(format!(
"fake blame failure for {path}@{rev}"
)));
}
Ok(self
.table
.get(&key)
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|(line, _)| lines.contains(line))
.collect())
}
}
fn seed_commit(db: &FactsDb, rev: &str) {
db.conn()
.execute(
"INSERT INTO commits (rev, author_email, author_name, \
committer_email, canonical_author, date, committer_date, \
message, is_merge, parent_count) \
VALUES (?, 'a@b.com', 'A', 'a@b.com', 'A', \
TIMESTAMPTZ '2026-01-01', TIMESTAMPTZ '2026-01-01', \
'fix: test', false, 1)",
duckdb::params![rev],
)
.expect("insert commit");
}
fn seed_hunk(db: &FactsDb, rev: &str, path: &str, old_start: u32, old_lines: u32) {
db.conn()
.execute(
"INSERT INTO changes (rev, path, change_type, loc_added, loc_deleted) \
VALUES (?, ?, 'modified', 0, ?)",
duckdb::params![rev, path, old_lines],
)
.expect("insert change");
db.conn()
.execute(
"INSERT INTO hunks (rev, path, old_start, old_lines, new_start, new_lines) \
VALUES (?, ?, ?, ?, 1, 0)",
duckdb::params![rev, path, old_start, old_lines],
)
.expect("insert hunk");
}
#[test]
fn link_defects_links_filters_cosmetic_and_respects_clock_skew_and_blame_failures() {
let db = FactsDb::new_in_memory().expect("in-memory db");
seed_commit(&db, "fix-old");
seed_hunk(&db, "fix-old", "src/a.rs", 1, 1);
seed_hunk(&db, "fix-old", "src/c.rs", 1, 1);
seed_hunk(&db, "fix-old", "src/d.rs", 1, 1);
seed_commit(&db, "fix-new");
seed_hunk(&db, "fix-new", "src/b.rs", 1, 1);
let repo = FakeRepo {
blobs: HashMap::from([
(
("parent-old".to_string(), "src/a.rs".to_string()),
b"let x = 1;\n".to_vec(),
),
(
("parent-old".to_string(), "src/c.rs".to_string()),
b"// cosmetic comment\n".to_vec(),
),
(
("parent-new".to_string(), "src/b.rs".to_string()),
b"return compute();\n".to_vec(),
),
]),
};
let origin = FakeOrigin {
table: HashMap::from([
(
("parent-old".to_string(), "src/a.rs".to_string()),
vec![(1, "A".to_string())],
),
(
("parent-old".to_string(), "src/c.rs".to_string()),
vec![(1, "B".to_string())],
),
(
("parent-new".to_string(), "src/b.rs".to_string()),
vec![(1, "future1".to_string())],
),
]),
fail_for: HashSet::from([("parent-old".to_string(), "src/d.rs".to_string())]),
};
let commit_dates = HashMap::from([
("A".to_string(), "2026-01-01T00:00:00Z".to_string()),
("B".to_string(), "2026-01-15T00:00:00Z".to_string()),
("future1".to_string(), "2026-06-01T00:00:00Z".to_string()),
]);
let fixes = vec![
(
"fix-old".to_string(),
"parent-old".to_string(),
"2026-03-01T00:00:00Z".to_string(),
),
(
"fix-new".to_string(),
"parent-new".to_string(),
"2026-01-01T00:00:00Z".to_string(),
),
];
let (links, stats) =
link_defects(&db, &repo, &origin, &fixes, &commit_dates).expect("link_defects");
assert_eq!(
links,
vec![SzzLink {
defect_rev: "A".to_string(),
fix_rev: "fix-old".to_string(),
path: "src/a.rs".to_string(),
}],
"only the older, non-cosmetic candidate must survive: {links:?}"
);
assert_eq!(stats.fixes_found, 2);
assert_eq!(stats.links_found, 1);
assert_eq!(
stats.files_blamed, 4,
"a.rs, c.rs, d.rs, b.rs each attempted once"
);
assert_eq!(stats.lines_considered, 4);
assert_eq!(stats.lines_dropped_cosmetic, 1, "c.rs's comment-only line");
assert_eq!(stats.blame_failures, 1, "d.rs's fake blame failure");
assert_eq!(stats.pure_addition_fixes, 0);
}
fn seed_change(
db: &FactsDb,
rev: &str,
path: &str,
change_type: &str,
loc_added: u32,
loc_deleted: u32,
) {
db.conn()
.execute(
"INSERT INTO changes (rev, path, change_type, loc_added, loc_deleted) \
VALUES (?, ?, ?, ?, ?)",
duckdb::params![rev, path, change_type, loc_added, loc_deleted],
)
.expect("insert change");
}
fn seed_typed_hunk(
db: &FactsDb,
rev: &str,
path: &str,
change_type: &str,
old_start: u32,
old_lines: u32,
) {
seed_change(db, rev, path, change_type, 0, old_lines);
db.conn()
.execute(
"INSERT INTO hunks (rev, path, old_start, old_lines, new_start, new_lines) \
VALUES (?, ?, ?, ?, 1, 0)",
duckdb::params![rev, path, old_start, old_lines],
)
.expect("insert hunk");
}
#[test]
fn link_defects_excludes_tangled_fixes() {
let db = FactsDb::new_in_memory().expect("in-memory db");
seed_commit(&db, "fix-wide");
seed_typed_hunk(&db, "fix-wide", "src/f0.rs", "modified", 1, 1);
for i in 1..9 {
seed_change(&db, "fix-wide", &format!("src/f{i}.rs"), "modified", 1, 1);
}
seed_commit(&db, "fix-heavy");
seed_change(&db, "fix-heavy", "src/big.rs", "modified", 500, 0);
db.conn()
.execute(
"INSERT INTO hunks (rev, path, old_start, old_lines, new_start, new_lines) \
VALUES ('fix-heavy', 'src/big.rs', 1, 1, 1, 0)",
[],
)
.expect("insert hunk");
let repo = FakeRepo {
blobs: HashMap::from([
(
("parent-wide".to_string(), "src/f0.rs".to_string()),
b"let x = 1;\n".to_vec(),
),
(
("parent-heavy".to_string(), "src/big.rs".to_string()),
b"let y = 2;\n".to_vec(),
),
]),
};
let origin = FakeOrigin {
table: HashMap::from([
(
("parent-wide".to_string(), "src/f0.rs".to_string()),
vec![(1, "old1".to_string())],
),
(
("parent-heavy".to_string(), "src/big.rs".to_string()),
vec![(1, "old2".to_string())],
),
]),
fail_for: HashSet::new(),
};
let commit_dates = HashMap::from([
("old1".to_string(), "2026-01-01T00:00:00Z".to_string()),
("old2".to_string(), "2026-01-01T00:00:00Z".to_string()),
]);
let fixes = vec![
(
"fix-wide".to_string(),
"parent-wide".to_string(),
"2026-03-01T00:00:00Z".to_string(),
),
(
"fix-heavy".to_string(),
"parent-heavy".to_string(),
"2026-03-01T00:00:00Z".to_string(),
),
];
let (links, stats) =
link_defects(&db, &repo, &origin, &fixes, &commit_dates).expect("link_defects");
assert!(
links.is_empty(),
"both fixes are tangled → no links despite would-link origins: {links:?}"
);
assert_eq!(stats.fixes_excluded_tangled, 2, "one wide + one heavy");
assert_eq!(
stats.files_blamed, 0,
"tangled fixes are excluded before any blame"
);
assert_eq!(stats.ghost_files_skipped, 0);
assert_eq!(stats.links_found, 0);
}
#[test]
fn link_defects_skips_whole_file_deletion_ghosts() {
let db = FactsDb::new_in_memory().expect("in-memory db");
seed_commit(&db, "fix-ghost");
seed_typed_hunk(&db, "fix-ghost", "src/gone.rs", "deleted", 1, 1);
seed_typed_hunk(&db, "fix-ghost", "src/kept.rs", "modified", 1, 1);
let repo = FakeRepo {
blobs: HashMap::from([
(
("parent-ghost".to_string(), "src/gone.rs".to_string()),
b"let removed = 1;\n".to_vec(),
),
(
("parent-ghost".to_string(), "src/kept.rs".to_string()),
b"let fixed = 2;\n".to_vec(),
),
]),
};
let origin = FakeOrigin {
table: HashMap::from([
(
("parent-ghost".to_string(), "src/gone.rs".to_string()),
vec![(1, "ghost-origin".to_string())],
),
(
("parent-ghost".to_string(), "src/kept.rs".to_string()),
vec![(1, "kept-origin".to_string())],
),
]),
fail_for: HashSet::new(),
};
let commit_dates = HashMap::from([
(
"ghost-origin".to_string(),
"2026-01-01T00:00:00Z".to_string(),
),
(
"kept-origin".to_string(),
"2026-01-01T00:00:00Z".to_string(),
),
]);
let fixes = vec![(
"fix-ghost".to_string(),
"parent-ghost".to_string(),
"2026-03-01T00:00:00Z".to_string(),
)];
let (links, stats) =
link_defects(&db, &repo, &origin, &fixes, &commit_dates).expect("link_defects");
assert_eq!(
links,
vec![SzzLink {
defect_rev: "kept-origin".to_string(),
fix_rev: "fix-ghost".to_string(),
path: "src/kept.rs".to_string(),
}],
"only the in-place edit links; the whole-file deletion is skipped: {links:?}"
);
assert_eq!(stats.ghost_files_skipped, 1, "src/gone.rs skipped as ghost");
assert_eq!(
stats.files_blamed, 1,
"only src/kept.rs is blamed; the ghost is skipped before blame"
);
assert_eq!(stats.fixes_excluded_tangled, 0);
}
#[test]
fn link_defects_counts_pure_addition_fixes() {
let db = FactsDb::new_in_memory().expect("in-memory db");
db.conn()
.execute(
"INSERT INTO commits (rev, author_email, author_name, \
committer_email, canonical_author, date, committer_date, \
message, is_merge, parent_count) \
VALUES ('fix-empty', 'a@b.com', 'A', 'a@b.com', 'A', \
TIMESTAMPTZ '2026-01-01', TIMESTAMPTZ '2026-01-01', \
'fix: only additions', false, 1)",
[],
)
.expect("insert commit");
let repo = FakeRepo {
blobs: HashMap::new(),
};
let origin = FakeOrigin {
table: HashMap::new(),
fail_for: HashSet::new(),
};
let fixes = vec![(
"fix-empty".to_string(),
"parent-empty".to_string(),
"2026-01-02T00:00:00Z".to_string(),
)];
let (links, stats) = link_defects(&db, &repo, &origin, &fixes, &HashMap::new())
.expect("link_defects on a pure-addition fix");
assert!(links.is_empty());
assert_eq!(stats.pure_addition_fixes, 1);
assert_eq!(stats.fixes_found, 1);
}
}