use codelore_lib::Options;
use codelore_lib::analyses::bus_factor::run_bus_factor;
use codelore_lib::facts::FactsDb;
use codelore_lib::repo::GixRepo;
#[test]
fn bus_factor_on_tiny_repo_concentrates_to_one_author() {
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_bus_factor(&db, &opts).expect("run bus-factor");
assert!(!rows.is_empty(), "expected at least one module row");
for row in &rows {
assert_eq!(
row.bus_factor, 1,
"single-author `tiny_repo` must yield bus_factor=1 for module {}",
row.module,
);
assert!(
row.total_commits > 0,
"module {} reported total_commits=0",
row.module,
);
}
}
fn build_root_file_repo() -> tempfile::TempDir {
use std::process::Command;
fn run(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
run(
path,
"2026-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
run(
path,
"2026-01-01T00:00:00Z",
&["config", "user.email", "t@t"],
);
run(
path,
"2026-01-01T00:00:00Z",
&["config", "user.name", "Tiny"],
);
std::fs::write(path.join("README.md"), "hello\n").unwrap();
run(path, "2026-01-02T12:00:00Z", &["add", "README.md"]);
run(
path,
"2026-01-02T12:00:00Z",
&["commit", "-m", "readme", "--quiet"],
);
std::fs::create_dir_all(path.join("src")).unwrap();
std::fs::write(path.join("src/main.rs"), "fn main() {}\n").unwrap();
run(path, "2026-01-03T12:00:00Z", &["add", "src/main.rs"]);
run(
path,
"2026-01-03T12:00:00Z",
&["commit", "-m", "src", "--quiet"],
);
dir
}
#[test]
fn bus_factor_includes_repo_root_files() {
let fixture = build_root_file_repo();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_bus_factor(&db, &opts).expect("run bus-factor");
let root = rows
.iter()
.find(|r| r.module == "<root>")
.expect("repo-root files must be counted under a <root> bucket");
assert!(
root.total_commits >= 1,
"the <root> bucket must count the README.md commit; got {}",
root.total_commits,
);
assert!(
rows.iter().any(|r| r.module == "src"),
"nested files must still bucket by top-level directory",
);
}
#[test]
fn bus_factor_model_field_reflects_mode() {
let delivery = codelore_lib::test_support::delivery_repo::build();
let repo = GixRepo::open(delivery.dir.path()).expect("open delivery_repo");
let db = FactsDb::new_in_memory().expect("db");
let opts_base = Options {
repo_path: delivery.dir.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts_base).expect("ingest");
let commits_rows = run_bus_factor(&db, &opts_base).expect("commits mode");
assert!(
!commits_rows.is_empty(),
"commits mode must return ≥1 row on delivery_repo"
);
for row in &commits_rows {
assert_eq!(
row.model, "commits",
"commits mode row for module '{}' has wrong model field",
row.module
);
assert!(
row.bus_factor >= 1,
"bus_factor must be ≥1 in commits mode for module '{}'",
row.module
);
}
let opts_doe = Options {
knowledge_model: "doe".to_string(),
..opts_base.clone()
};
let doe_rows = run_bus_factor(&db, &opts_doe).expect("doe mode");
assert!(
!doe_rows.is_empty(),
"doe mode must return ≥1 row on delivery_repo"
);
for row in &doe_rows {
assert_eq!(
row.model, "doe",
"doe mode row for module '{}' has wrong model field",
row.module
);
assert!(
row.bus_factor >= 1,
"bus_factor must be ≥1 in doe mode for module '{}'",
row.module
);
}
}
#[test]
fn doe_bus_factor_hand_verified() {
let delivery = codelore_lib::test_support::delivery_repo::build();
let repo = GixRepo::open(delivery.dir.path()).expect("open delivery_repo");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: delivery.dir.path().to_path_buf(),
min_revs: 1,
knowledge_model: "doe".to_string(),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_bus_factor(&db, &opts).expect("run_bus_factor doe");
assert_eq!(rows.len(), 1, "expected exactly one module row (src)");
let row = &rows[0];
assert_eq!(row.module, "src");
assert_eq!(row.model, "doe");
assert_eq!(
row.bus_factor, 3,
"hand-computed: 3 authors must be removed before >50% of 5 files are uncovered"
);
assert_eq!(
row.top_contributor, "alice@example.com",
"alice is removed first (3-way tie broken to the smallest author string)"
);
assert!(
(row.top_contributor_share - 0.6).abs() < 1e-9,
"alice covers 3/5 expert files → share = 0.600, got {:.4}",
row.top_contributor_share
);
}
fn build_renamed_file_repo() -> tempfile::TempDir {
use std::process::Command;
fn run(path: &std::path::Path, date: &str, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success());
}
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
run(
path,
"2026-01-01T00:00:00Z",
&["init", "-b", "main", "--quiet"],
);
run(
path,
"2026-01-01T00:00:00Z",
&["config", "user.email", "t@t"],
);
run(
path,
"2026-01-01T00:00:00Z",
&["config", "user.name", "Tiny"],
);
std::fs::create_dir_all(path.join("old")).unwrap();
std::fs::write(path.join("old/a.txt"), "one\n").unwrap();
run(path, "2026-01-02T12:00:00Z", &["add", "old/a.txt"]);
run(
path,
"2026-01-02T12:00:00Z",
&["commit", "-m", "c1", "--quiet"],
);
std::fs::write(path.join("old/a.txt"), "one\ntwo\n").unwrap();
run(
path,
"2026-01-03T12:00:00Z",
&["commit", "-am", "c2", "--quiet"],
);
std::fs::create_dir_all(path.join("new")).unwrap();
run(
path,
"2026-01-04T12:00:00Z",
&["mv", "old/a.txt", "new/a.txt"],
);
run(
path,
"2026-01-04T12:00:00Z",
&["commit", "-m", "rename", "--quiet"],
);
std::fs::write(path.join("new/a.txt"), "one\ntwo\nthree\n").unwrap();
run(
path,
"2026-01-05T12:00:00Z",
&["commit", "-am", "c3", "--quiet"],
);
dir
}
#[test]
fn bus_factor_attributes_renamed_file_to_one_module_under_lineage() {
let fixture = build_renamed_file_repo();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
use_canonical_lineage: true,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
let rows = run_bus_factor(&db, &opts).expect("run bus-factor under lineage");
let modules: Vec<&str> = rows.iter().map(|r| r.module.as_str()).collect();
assert!(
!(modules.contains(&"old") && modules.contains(&"new")),
"renamed file should not split across `old` and `new` under \
canonical lineage; got modules {modules:?}",
);
assert!(
!rows.is_empty(),
"lineage-routed bus-factor must still produce rows",
);
}
fn git_as(path: &std::path::Path, date: &str, author: &str, email: &str, args: &[&str]) {
use std::process::Command;
let status = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.env("GIT_AUTHOR_NAME", author)
.env("GIT_AUTHOR_EMAIL", email)
.env("GIT_COMMITTER_NAME", author)
.env("GIT_COMMITTER_EMAIL", email)
.env("GIT_AUTHOR_DATE", date)
.env("GIT_COMMITTER_DATE", date)
.status()
.expect("git");
assert!(status.success(), "git {args:?} failed");
}
fn assert_bot_registered(db: &FactsDb) {
let is_bot: bool = db
.query_row(
"SELECT COALESCE(BOOL_OR(is_bot), FALSE) FROM author_aliases \
WHERE canonical LIKE '%dependabot%'",
[],
|r| r.get(0),
)
.expect("query is_bot");
assert!(is_bot, "dependabot must be flagged is_bot at ingest");
}
fn build_bot_and_humans_repo() -> tempfile::TempDir {
const BOT_NAME: &str = "dependabot[bot]";
const BOT_EMAIL: &str = "dependabot[bot]@noreply.github.com";
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path();
git_as(
path,
"2026-01-01T00:00:00Z",
"Alice",
"alice@example.com",
&["init", "-b", "main", "--quiet"],
);
std::fs::create_dir_all(path.join("src")).unwrap();
std::fs::write(path.join("src/a.rs"), "fn a() {}\n").unwrap();
git_as(
path,
"2026-01-02T12:00:00Z",
"Alice",
"alice@example.com",
&["add", "src/a.rs"],
);
git_as(
path,
"2026-01-02T12:00:00Z",
"Alice",
"alice@example.com",
&["commit", "-m", "a1", "--quiet"],
);
std::fs::write(path.join("src/a.rs"), "fn a() {}\n// more\n").unwrap();
git_as(
path,
"2026-01-03T12:00:00Z",
"Alice",
"alice@example.com",
&["commit", "-am", "a2", "--quiet"],
);
std::fs::write(path.join("src/b.rs"), "fn b() {}\n").unwrap();
git_as(
path,
"2026-01-04T12:00:00Z",
"Bob",
"bob@example.com",
&["add", "src/b.rs"],
);
git_as(
path,
"2026-01-04T12:00:00Z",
"Bob",
"bob@example.com",
&["commit", "-m", "b1", "--quiet"],
);
for (i, day) in [5u32, 6, 7].iter().enumerate() {
std::fs::write(
path.join("src/a.rs"),
format!("fn a() {{}}\n// more\n// bump {i}\n"),
)
.unwrap();
git_as(
path,
&format!("2026-01-{day:02}T12:00:00Z"),
BOT_NAME,
BOT_EMAIL,
&["commit", "-am", &format!("bump {i}"), "--quiet"],
);
}
std::fs::create_dir_all(path.join("deps")).unwrap();
std::fs::write(path.join("deps/x.rs"), "// dep x\n").unwrap();
git_as(
path,
"2026-01-08T12:00:00Z",
BOT_NAME,
BOT_EMAIL,
&["add", "deps/x.rs"],
);
git_as(
path,
"2026-01-08T12:00:00Z",
BOT_NAME,
BOT_EMAIL,
&["commit", "-m", "dep x", "--quiet"],
);
std::fs::write(path.join("deps/y.rs"), "// dep y\n").unwrap();
git_as(
path,
"2026-01-09T12:00:00Z",
BOT_NAME,
BOT_EMAIL,
&["add", "deps/y.rs"],
);
git_as(
path,
"2026-01-09T12:00:00Z",
BOT_NAME,
BOT_EMAIL,
&["commit", "-m", "dep y", "--quiet"],
);
dir
}
#[test]
fn bus_factor_commits_mode_excludes_bot_authors() {
let fixture = build_bot_and_humans_repo();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
assert_bot_registered(&db);
let rows = run_bus_factor(&db, &opts).expect("run bus-factor");
let src = rows
.iter()
.find(|r| r.module == "src")
.expect("`src` module must be present");
assert_eq!(
src.top_contributor, "alice@example.com",
"bot must be excluded from bus-factor ranking; got top_contributor={}",
src.top_contributor,
);
assert!(
!src.top_contributor.contains("[bot]"),
"top contributor must not be a bot",
);
assert_eq!(
src.total_commits, 3,
"module commit total must exclude the bot's commits",
);
assert!(
!rows.iter().any(|r| r.module == "deps"),
"a bot-only module must not appear in bus-factor output; got {:?}",
rows.iter().map(|r| &r.module).collect::<Vec<_>>(),
);
}
#[test]
fn bus_factor_doe_mode_total_commits_excludes_bot() {
let fixture = build_bot_and_humans_repo();
let repo = GixRepo::open(fixture.path()).expect("open");
let db = FactsDb::new_in_memory().expect("db");
let opts = Options {
repo_path: fixture.path().to_path_buf(),
min_revs: 1,
knowledge_model: "doe".to_string(),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest");
assert_bot_registered(&db);
let rows = run_bus_factor(&db, &opts).expect("run bus-factor doe");
let src = rows
.iter()
.find(|r| r.module == "src")
.expect("`src` module must be present in DOE mode");
assert_eq!(src.model, "doe");
assert_eq!(
src.total_commits, 3,
"DOE total_commits must exclude the bot's commits",
);
}