use rusqlite::params;
use super::{
build_authorship_summary, build_authorship_summary_with, merge_suggestions,
recorded_repository_names, repository_has_commits, AuthorshipSummary,
AUTHORSHIP_SCHEMA_VERSION,
};
use crate::core::db::Database;
use crate::core::errors::Result;
fn summary_for(conn: &rusqlite::Connection, repository: &str) -> Result<AuthorshipSummary> {
let suggestions = merge_suggestions(conn, None)?;
build_authorship_summary_with(conn, repository, &suggestions)
}
fn insert_author(db: &Database, canonical_name: &str, canonical_email: &str) -> i64 {
db.connection()
.execute(
"INSERT INTO authors (canonical_name, canonical_email) VALUES (?1, ?2)",
params![canonical_name, canonical_email],
)
.expect("insert author");
db.connection().last_insert_rowid()
}
fn link_commit(db: &Database, sha: &str, author_id: i64) {
let updated = db
.connection()
.execute(
"UPDATE commits SET author_id = ?1 WHERE sha = ?2",
params![author_id, sha],
)
.expect("link commit");
assert_eq!(updated, 1, "the commit to link must exist: {sha}");
}
#[allow(clippy::too_many_arguments)]
fn insert_commit(
db: &Database,
sha: &str,
author_name: &str,
author_email: &str,
timestamp: &str,
repository: &str,
is_merge: bool,
paths: &[&str],
) {
db.connection()
.execute(
"INSERT INTO commits (sha, author_name, author_email, timestamp, message, \
repository, is_merge) \
VALUES (?1, ?2, ?3, ?4, 'msg', ?5, ?6)",
params![
sha,
author_name,
author_email,
timestamp,
repository,
is_merge as i64
],
)
.expect("insert commit");
let commit_id = db.connection().last_insert_rowid();
for path in paths {
db.connection()
.execute(
"INSERT INTO files (commit_id, path, change_type) VALUES (?1, ?2, 'modified')",
params![commit_id, path],
)
.expect("insert file");
}
}
#[test]
fn builds_from_seeded_commits() {
let db = Database::open_in_memory().expect("open");
insert_commit(
&db,
"a1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
insert_commit(
&db,
"a2",
"Alice",
"alice@x.com",
"2026-02-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.schema_version, AUTHORSHIP_SCHEMA_VERSION);
assert_eq!(summary.repository, "repo");
assert_eq!(summary.distinct_authors, 1);
assert_eq!(summary.bus_factor, 1);
assert!((summary.top_author_share_pct - 100.0).abs() < f64::EPSILON);
assert_eq!(summary.single_author_subsystems, vec!["src".to_string()]);
assert_eq!(summary.monthly_trajectory.len(), 2);
assert!(!summary.caveats.is_empty());
}
#[test]
fn bots_and_merges_are_excluded() {
let db = Database::open_in_memory().expect("open");
insert_commit(
&db,
"h1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
insert_commit(
&db,
"b1",
"dependabot[bot]",
"dependabot[bot]@users.noreply.github.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["deps/lock.json"],
);
insert_commit(
&db,
"m1",
"Bob",
"bob@x.com",
"2026-01-17T00:00:00Z",
"repo",
true,
&["src/merged.rs"],
);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.distinct_authors, 1, "bot and merge author excluded");
assert_eq!(summary.single_author_subsystems, vec!["src".to_string()]);
}
#[test]
fn shared_subsystem_is_not_single_author() {
let db = Database::open_in_memory().expect("open");
insert_commit(
&db,
"a1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
insert_commit(
&db,
"b1",
"Bob",
"bob@x.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/other.rs"],
);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.distinct_authors, 2);
assert!(summary.single_author_subsystems.is_empty());
assert_eq!(summary.bus_factor, 1); }
#[test]
fn empty_repository_yields_zeroed_summary() {
let db = Database::open_in_memory().expect("open");
let summary = summary_for(db.connection(), "nonexistent").expect("summary");
assert_eq!(summary.distinct_authors, 0);
assert_eq!(summary.bus_factor, 0);
assert!(summary.monthly_trajectory.is_empty());
}
#[test]
fn trajectory_caps_at_twelve_months() {
let db = Database::open_in_memory().expect("open");
let months = [
"2024-11", "2024-12", "2025-01", "2025-02", "2025-03", "2025-04", "2025-05", "2025-06",
"2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12",
];
for (i, month) in months.iter().enumerate() {
insert_commit(
&db,
&format!("c{i}"),
"Alice",
"alice@x.com",
&format!("{month}-01T00:00:00Z"),
"repo",
false,
&["src/lib.rs"],
);
}
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.monthly_trajectory.len(), 12);
assert_eq!(summary.monthly_trajectory.first().unwrap().month, "2025-01");
assert_eq!(summary.monthly_trajectory.last().unwrap().month, "2025-12");
}
#[test]
fn commits_count_commits_not_file_touches() {
let db = Database::open_in_memory().expect("open");
insert_commit(
&db,
"a1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/a.rs", "src/b.rs", "src/c.rs", "docs/d.md"],
);
insert_commit(
&db,
"b1",
"Bob",
"bob@x.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/e.rs", "src/f.rs", "docs/g.md"],
);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.monthly_trajectory.len(), 1);
let january = &summary.monthly_trajectory[0];
assert_eq!(january.month, "2026-01");
assert_eq!(
january.commits, 2,
"2 commits touching 7 files must count 2, not 7"
);
assert_eq!(
january.active_authors, 2,
"active_authors was already correct and must stay so"
);
}
#[test]
fn aliases_collapse_through_the_identity_resolver() {
let db = Database::open_in_memory().expect("open");
let alice = insert_author(&db, "Alice", "alice@x.com");
for (sha, email, path) in [
("a1", "alice@x.com", "src/lib.rs"),
("a2", "alice@corp.example", "docs/readme.md"),
] {
insert_commit(
&db,
sha,
"Alice",
email,
"2026-01-15T00:00:00Z",
"repo",
false,
&[path],
);
link_commit(&db, sha, alice);
}
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(
summary.distinct_authors, 1,
"both aliases resolve to one canonical author"
);
assert!(
(summary.top_author_share_pct - 100.0).abs() < f64::EPSILON,
"one author holds every touch: {}",
summary.top_author_share_pct
);
assert_eq!(
summary.unresolved_authors, 0,
"every commit was linked, so nothing is unresolved"
);
assert!(
!summary.caveats.iter().any(|c| c.contains("never linked")),
"a fully-resolved run must not carry the unresolved caveat: {:?}",
summary.caveats
);
}
#[test]
fn unresolved_authors_are_counted_and_caveated() {
let db = Database::open_in_memory().expect("open");
let alice = insert_author(&db, "Alice", "alice@x.com");
insert_commit(
&db,
"a1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "a1", alice);
insert_commit(
&db,
"c1",
"Carol",
"carol@x.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/other.rs"],
);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.distinct_authors, 2);
assert_eq!(
summary.unresolved_authors, 1,
"exactly Carol's identity is unresolved"
);
let caveat = summary
.caveats
.iter()
.find(|c| c.contains("never linked"))
.expect("an unresolved run must name the count in a caveat");
assert!(
caveat.contains('1'),
"the caveat must carry the figure: {caveat}"
);
}
#[test]
fn a_name_that_matches_nothing_is_detectable() {
let db = Database::open_in_memory().expect("open");
assert!(
!repository_has_commits(db.connection(), "acme-web").expect("probe"),
"an empty database matches no name"
);
assert!(
recorded_repository_names(db.connection())
.expect("names")
.is_empty(),
"an empty database records no names"
);
insert_commit(
&db,
"a1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"acme_web",
false,
&["src/lib.rs"],
);
assert!(repository_has_commits(db.connection(), "acme_web").expect("probe"));
assert!(
!repository_has_commits(db.connection(), "acme-web").expect("probe"),
"a name one character off must not match — that is the drift this catches"
);
assert_eq!(
recorded_repository_names(db.connection()).expect("names"),
vec!["acme_web".to_string()],
"the recorded name is what the gap line points the operator at"
);
}
fn seed_split_identity(db: &Database) -> i64 {
let corp = insert_author(db, "Alice", "alice@corp.com");
insert_commit(
db,
"c1",
"Alice",
"alice@corp.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(db, "c1", corp);
insert_commit(
db,
"c2",
"Alice",
"alice@personal.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
corp
}
fn record_confirmed_alias(db: &Database, canonical_email: &str, alias: &str) {
let updated = db
.connection()
.execute(
"UPDATE authors SET aliases = ?1 WHERE canonical_email = ?2",
params![
serde_json::to_string(&vec![alias]).expect("encode aliases"),
canonical_email
],
)
.expect("record alias");
assert_eq!(updated, 1, "the destination identity must exist");
}
#[test]
fn confirmed_alias_merge_collapses_the_authorship_metric() {
let before = Database::open_in_memory().expect("open");
seed_split_identity(&before);
let split = summary_for(before.connection(), "repo").expect("summary");
assert_eq!(
split.distinct_authors, 2,
"the unmerged corpus must still read as two authors"
);
assert!(
(split.top_author_share_pct - 50.0).abs() < f64::EPSILON,
"top_author_share_pct must be understated at 50%, got {}",
split.top_author_share_pct
);
assert_eq!(split.unresolved_authors, 1);
let after = Database::open_in_memory().expect("open");
seed_split_identity(&after);
record_confirmed_alias(&after, "alice@corp.com", "alice@personal.com");
let merged = summary_for(after.connection(), "repo").expect("summary");
assert_eq!(
merged.distinct_authors, 1,
"a confirmed merge must collapse the two identities"
);
assert!(
(merged.top_author_share_pct - 100.0).abs() < f64::EPSILON,
"top_author_share_pct must read 100% once merged, got {}",
merged.top_author_share_pct
);
assert_eq!(
merged.unresolved_authors, 0,
"an identity folded by a confirmed merge is no longer unresolved"
);
}
#[test]
fn suggested_but_unmerged_identity_raises_a_risk_flag() {
let db = Database::open_in_memory().expect("open");
let corp = insert_author(&db, "Alice", "alice@corp.com");
let personal = insert_author(&db, "Alice", "alice@personal.com");
insert_commit(
&db,
"s1",
"Alice",
"alice@corp.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "s1", corp);
insert_commit(
&db,
"s2",
"Alice",
"alice@personal.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "s2", personal);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(
summary.distinct_authors, 2,
"an unconfirmed suggestion must never be auto-merged"
);
let risk = summary
.identity_merge_risk
.as_ref()
.expect("an unconfirmed HIGH-confidence pair must raise the flag");
assert_eq!(risk.suggested_unmerged, 1);
assert_eq!(
risk.affected_metrics,
vec!["bus_factor".to_string(), "top_author_share_pct".to_string()],
"the flag must sit next to the metrics a split identity distorts"
);
assert!(
risk.resolve_command.contains("tga aliases"),
"the flag must name the resolving command, got {}",
risk.resolve_command
);
assert!(
summary
.caveats
.iter()
.any(|c| c.contains("suggested for merge")),
"a caveat-only renderer must still see the flag: {:?}",
summary.caveats
);
}
#[test]
fn a_confirmed_merge_clears_the_risk_flag() {
let db = Database::open_in_memory().expect("open");
let corp = insert_author(&db, "Alice", "alice@corp.com");
insert_commit(
&db,
"s1",
"Alice",
"alice@corp.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "s1", corp);
insert_commit(
&db,
"s2",
"Alice",
"alice@personal.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
record_confirmed_alias(&db, "alice@corp.com", "alice@personal.com");
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.distinct_authors, 1);
assert!(
summary.identity_merge_risk.is_none(),
"a confirmed merge must clear the flag, got {:?}",
summary.identity_merge_risk
);
}
#[test]
fn a_confirmed_merge_survives_a_recollect() {
use crate::collect::identity::resolver::IdentityResolver;
let db = Database::open_in_memory().expect("open");
let corp = insert_author(&db, "Alice", "alice@corp.com");
insert_commit(
&db,
"s1",
"Alice",
"alice@corp.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "s1", corp);
insert_commit(
&db,
"s2",
"Alice",
"alice@personal.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "s2", corp);
record_confirmed_alias(&db, "alice@corp.com", "alice@personal.com");
insert_commit(
&db,
"s3",
"Alice",
"alice@personal.com",
"2026-01-17T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
let resolver = IdentityResolver::new(None);
let id = resolver
.upsert_author(&db, "Alice", "alice@personal.com")
.expect("upsert");
assert_eq!(
id, corp,
"the merged-away email must route to the identity that absorbed it, \
not to a re-created source row"
);
let rows: i64 = db
.connection()
.query_row("SELECT COUNT(*) FROM authors", [], |r| r.get(0))
.expect("count authors");
assert_eq!(rows, 1, "the deleted source row must not come back");
db.connection()
.execute(
"UPDATE commits SET author_id = ?1 WHERE author_id IS NULL",
params![id],
)
.expect("relink");
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(
summary.distinct_authors, 1,
"a re-collect must not split a confirmed merge back apart"
);
assert_eq!(summary.top_author_share_pct.round() as i64, 100);
assert_eq!(summary.bus_factor, 1);
}
#[test]
fn a_merged_email_collapses_even_when_its_row_was_re_created() {
let db = Database::open_in_memory().expect("open");
let corp = insert_author(&db, "Alice", "alice@corp.com");
let personal = insert_author(&db, "Alice", "alice@personal.com");
record_confirmed_alias(&db, "alice@corp.com", "alice@personal.com");
for (sha, email, id) in [
("s1", "alice@corp.com", corp),
("s2", "alice@personal.com", personal),
] {
insert_commit(
&db,
sha,
"Alice",
email,
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, sha, id);
}
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(
summary.distinct_authors, 1,
"a linked commit under a confirmed alias must still collapse"
);
assert_eq!(summary.top_author_share_pct.round() as i64, 100);
assert!(
summary.identity_merge_risk.is_none(),
"a pair the operator already merged is not unmerged, got {:?}",
summary.identity_merge_risk
);
}
#[test]
fn the_configured_domain_reaches_the_risk_flag() {
let db = Database::open_in_memory().expect("open");
let corp = insert_author(&db, "Alice Smith", "alice@corp.com");
let local = insert_author(&db, "A. Smith", "alice@laptop.local");
for (sha, email, id) in [
("s1", "alice@corp.com", corp),
("s2", "alice@laptop.local", local),
] {
insert_commit(
&db,
sha,
"Alice",
email,
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, sha, id);
}
let muted = merge_suggestions(db.connection(), None).expect("scan");
let with_domain = merge_suggestions(db.connection(), Some("corp.com")).expect("scan");
assert!(
muted.is_empty(),
"without the domain the .local signal cannot fire: {muted:?}"
);
assert!(
with_domain.iter().any(|s| s.src == "alice@laptop.local"),
"the configured domain must surface the .local identity: {with_domain:?}"
);
let flagged =
build_authorship_summary_with(db.connection(), "repo", &with_domain).expect("summary");
let risk = flagged
.identity_merge_risk
.expect("a .local identity beside a top author must raise the flag");
assert_eq!(risk.suggested_unmerged, 1);
}
#[test]
fn a_long_tail_suggestion_does_not_flag() {
let db = Database::open_in_memory().expect("open");
const TAIL_FIXTURE_NAMES: &[&str] = &[
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
"juliet", "kilo",
];
for name in TAIL_FIXTURE_NAMES {
let email = format!("{name}@corp.com");
let id = insert_author(&db, name, &email);
let sha = format!("t-{name}");
insert_commit(
&db,
&sha,
name,
&email,
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs", "src/other.rs"],
);
link_commit(&db, &sha, id);
}
let z1 = insert_author(&db, "Zed", "zed@corp.com");
let z2 = insert_author(&db, "Zed", "zed@personal.com");
insert_commit(
&db,
"z1",
"Zed",
"zed@corp.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "z1", z1);
insert_commit(
&db,
"z2",
"Zed",
"zed@personal.com",
"2026-01-16T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, "z2", z2);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.distinct_authors, 13);
assert!(
summary.identity_merge_risk.is_none(),
"a pair outside the top-N must not raise the flag, got {:?}",
summary.identity_merge_risk
);
}
#[test]
fn the_wrapper_scans_its_own_suggestions() {
let db = Database::open_in_memory().expect("open");
insert_commit(
&db,
"a1",
"Alice",
"alice@x.com",
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
insert_commit(
&db,
"b1",
"Alice",
"alice@example.com",
"2026-02-15T00:00:00Z",
"repo",
false,
&["src/other.rs"],
);
let scanned = merge_suggestions(db.connection(), None).expect("scan");
let delegated =
build_authorship_summary_with(db.connection(), "repo", &scanned).expect("summary");
let wrapped = build_authorship_summary(db.connection(), "repo").expect("summary");
assert_eq!(
wrapped.to_json().expect("json"),
delegated.to_json().expect("json"),
"the wrapper must answer exactly as the delegating form does"
);
}
fn seed_near_miss_identity_pair(db: &Database) {
for (sha, name, email) in [
("n1", "Ada Lovelace", "ada.lovelace@example.com"),
(
"n2",
"ada.lovelace",
"ada.lovelace@users.noreply.github.com",
),
] {
let id = insert_author(db, name, email);
insert_commit(
db,
sha,
name,
email,
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(db, sha, id);
}
}
#[test]
fn the_near_miss_split_identity_raises_the_risk_flag() {
let db = Database::open_in_memory().expect("open");
seed_near_miss_identity_pair(&db);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(
summary.distinct_authors, 2,
"a suggestion nobody confirmed must never be auto-merged"
);
let risk = summary
.identity_merge_risk
.as_ref()
.expect("the near-miss pair must raise the flag");
assert_eq!(risk.suggested_unmerged, 1);
assert!(
summary
.caveats
.iter()
.any(|c| c.contains("suggested for merge")),
"a caveat-only renderer must still see the flag: {:?}",
summary.caveats
);
}
#[test]
fn similar_names_from_distinct_people_do_not_raise_the_flag() {
let db = Database::open_in_memory().expect("open");
for (sha, name, email) in [
("d1", "Ada Lovelace", "ada.lovelace@example.com"),
("d2", "Ada Lovelington", "ada.lovelington@example.com"),
] {
let id = insert_author(&db, name, email);
insert_commit(
&db,
sha,
name,
email,
"2026-01-15T00:00:00Z",
"repo",
false,
&["src/lib.rs"],
);
link_commit(&db, sha, id);
}
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(summary.distinct_authors, 2, "two people, two authors");
assert!(
summary.identity_merge_risk.is_none(),
"distinct people must not be flagged for merge, got {:?}",
summary.identity_merge_risk
);
}
#[test]
fn a_confirmed_near_miss_merge_is_not_reported_again() {
let db = Database::open_in_memory().expect("open");
seed_near_miss_identity_pair(&db);
record_confirmed_alias(
&db,
"ada.lovelace@example.com",
"ada.lovelace@users.noreply.github.com",
);
let summary = summary_for(db.connection(), "repo").expect("summary");
assert_eq!(
summary.distinct_authors, 1,
"a confirmed merge must collapse the two identities"
);
assert!(
summary.identity_merge_risk.is_none(),
"a confirmed merge must clear the flag, got {:?}",
summary.identity_merge_risk
);
}