use std::path::Path;
use super::super::{build_git_report, build_predictive_churn_report};
use tokmd_analysis_types::TrendClass;
use tokmd_git::GitCommit;
use tokmd_types::{ChildIncludeMode, ExportData, FileKind, FileRow};
const DAY: i64 = 86_400;
fn file_row(path: &str, module: &str, lines: usize) -> FileRow {
FileRow {
path: path.to_string(),
module: module.to_string(),
lang: "Rust".to_string(),
kind: FileKind::Parent,
code: lines,
comments: 0,
blanks: 0,
lines,
bytes: lines * 40,
tokens: lines * 3,
}
}
fn export(rows: Vec<FileRow>) -> ExportData {
ExportData {
rows,
module_roots: vec![],
module_depth: 1,
children: ChildIncludeMode::Separate,
}
}
fn commit(ts: i64, author: &str, subject: &str, files: &[&str]) -> GitCommit {
GitCommit {
timestamp: ts,
author: author.to_string(),
hash: None,
subject: subject.to_string(),
files: files.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn scenario_empty_commits_produce_empty_report() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits: Vec<GitCommit> = vec![];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.commits_scanned, 0);
assert_eq!(report.files_seen, 0);
assert!(report.hotspots.is_empty());
assert!(report.bus_factor.is_empty());
assert_eq!(report.freshness.total_files, 0);
assert!(report.coupling.is_empty());
}
#[test]
fn scenario_hotspot_score_equals_lines_times_commits() {
let exp = export(vec![file_row("src/lib.rs", "src", 200)]);
let commits = vec![
commit(1000, "alice", "feat: init", &["src/lib.rs"]),
commit(2000, "bob", "fix: bug", &["src/lib.rs"]),
commit(3000, "alice", "refactor: cleanup", &["src/lib.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.hotspots.len(), 1);
assert_eq!(report.hotspots[0].commits, 3);
assert_eq!(report.hotspots[0].lines, 200);
assert_eq!(report.hotspots[0].score, 600);
}
#[test]
fn scenario_hotspots_sorted_by_score_desc() {
let exp = export(vec![
file_row("src/a.rs", "src", 100),
file_row("src/b.rs", "src", 50),
]);
let commits = vec![
commit(1000, "alice", "feat: a", &["src/a.rs"]),
commit(2000, "bob", "feat: b", &["src/b.rs", "src/a.rs"]),
commit(3000, "alice", "fix: b", &["src/b.rs"]),
commit(4000, "alice", "fix: b2", &["src/b.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.hotspots[0].path, "src/a.rs");
assert_eq!(report.hotspots[0].score, 200);
assert_eq!(report.hotspots[1].path, "src/b.rs");
assert_eq!(report.hotspots[1].score, 150);
}
#[test]
fn scenario_bus_factor_unique_authors() {
let exp = export(vec![
file_row("src/a.rs", "src", 100),
file_row("lib/b.rs", "lib", 50),
]);
let commits = vec![
commit(1000, "alice", "feat: a", &["src/a.rs"]),
commit(2000, "bob", "feat: b", &["lib/b.rs"]),
commit(3000, "charlie", "fix: b", &["lib/b.rs"]),
commit(4000, "alice", "fix: b2", &["lib/b.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.bus_factor.len(), 2);
assert_eq!(report.bus_factor[0].module, "src");
assert_eq!(report.bus_factor[0].authors, 1);
assert_eq!(report.bus_factor[1].module, "lib");
assert_eq!(report.bus_factor[1].authors, 3);
}
#[test]
fn scenario_freshness_stale_files() {
let exp = export(vec![
file_row("src/old.rs", "src", 50),
file_row("src/new.rs", "src", 50),
]);
let reference_ts = 500 * DAY;
let commits = vec![
commit(100 * DAY, "alice", "feat: old", &["src/old.rs"]),
commit(reference_ts, "bob", "feat: new", &["src/new.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.freshness.total_files, 2);
assert_eq!(report.freshness.stale_files, 1);
assert_eq!(report.freshness.threshold_days, 365);
assert!(report.freshness.stale_pct > 0.0);
assert!(report.freshness.stale_pct < 1.0);
}
#[test]
fn scenario_freshness_all_fresh() {
let now = 1000 * DAY;
let exp = export(vec![
file_row("src/a.rs", "src", 50),
file_row("src/b.rs", "src", 50),
]);
let commits = vec![
commit(now - 5 * DAY, "alice", "feat: a", &["src/a.rs"]),
commit(now, "bob", "feat: b", &["src/b.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.freshness.stale_files, 0);
assert_eq!(report.freshness.stale_pct, 0.0);
}
#[test]
fn scenario_freshness_by_module() {
let now = 500 * DAY;
let exp = export(vec![
file_row("api/handler.rs", "api", 100),
file_row("db/query.rs", "db", 80),
]);
let commits = vec![
commit(now, "alice", "feat: api", &["api/handler.rs"]),
commit(100 * DAY, "bob", "feat: db", &["db/query.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.freshness.by_module.len(), 2);
assert_eq!(report.freshness.by_module[0].module, "api");
assert_eq!(report.freshness.by_module[1].module, "db");
assert!(report.freshness.by_module[1].avg_days > report.freshness.by_module[0].avg_days);
}
#[test]
fn scenario_coupling_modules_changed_together() {
let exp = export(vec![
file_row("api/handler.rs", "api", 100),
file_row("db/query.rs", "db", 80),
]);
let commits = vec![
commit(
1000,
"alice",
"feat: both",
&["api/handler.rs", "db/query.rs"],
),
commit(2000, "bob", "fix: both", &["api/handler.rs", "db/query.rs"]),
commit(
3000,
"alice",
"refactor: both",
&["api/handler.rs", "db/query.rs"],
),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.coupling.len(), 1);
assert_eq!(report.coupling[0].count, 3);
assert_eq!(report.coupling[0].jaccard, Some(1.0));
}
#[test]
fn scenario_no_coupling_independent_modules() {
let exp = export(vec![
file_row("api/handler.rs", "api", 100),
file_row("db/query.rs", "db", 80),
]);
let commits = vec![
commit(1000, "alice", "feat: api", &["api/handler.rs"]),
commit(2000, "bob", "feat: db", &["db/query.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert!(report.coupling.is_empty());
}
#[test]
fn scenario_intent_report_conventional_commits() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits = vec![
commit(1000, "alice", "feat: add login", &["src/lib.rs"]),
commit(2000, "bob", "fix: null pointer", &["src/lib.rs"]),
commit(3000, "alice", "docs: update readme", &["src/lib.rs"]),
commit(4000, "charlie", "fix: memory leak", &["src/lib.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
let intent = report.intent.as_ref().expect("intent present");
assert_eq!(intent.overall.feat, 1);
assert_eq!(intent.overall.fix, 2);
assert_eq!(intent.overall.docs, 1);
assert_eq!(intent.overall.total, 4);
}
#[test]
fn scenario_corrective_ratio() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits = vec![
commit(1000, "alice", "feat: init", &["src/lib.rs"]),
commit(2000, "bob", "fix: bug1", &["src/lib.rs"]),
commit(3000, "charlie", "fix: bug2", &["src/lib.rs"]),
commit(4000, "alice", "revert: bad change", &["src/lib.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
let intent = report.intent.as_ref().unwrap();
assert_eq!(intent.corrective_ratio, Some(0.75));
}
#[test]
fn scenario_code_age_distribution_buckets() {
let now = 500 * DAY;
let exp = export(vec![
file_row("src/a.rs", "src", 50),
file_row("src/b.rs", "src", 50),
]);
let commits = vec![
commit(now, "alice", "feat: recent", &["src/a.rs"]),
commit(now - 400 * DAY, "bob", "feat: old", &["src/b.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
let age = report.age_distribution.as_ref().unwrap();
assert_eq!(age.buckets.len(), 5);
assert_eq!(age.buckets[0].label, "0-30d");
assert_eq!(age.buckets[4].label, "366d+");
}
#[test]
fn scenario_age_distribution_pct_sum() {
let now = 500 * DAY;
let exp = export(vec![
file_row("src/a.rs", "src", 50),
file_row("src/b.rs", "src", 50),
file_row("src/c.rs", "src", 50),
]);
let commits = vec![
commit(now, "alice", "feat: a", &["src/a.rs"]),
commit(now - 60 * DAY, "bob", "feat: b", &["src/b.rs"]),
commit(now - 400 * DAY, "charlie", "feat: c", &["src/c.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
let age = report.age_distribution.as_ref().unwrap();
let total_pct: f64 = age.buckets.iter().map(|b| b.pct).sum();
assert!((total_pct - 1.0).abs() < 0.01, "pct sum {total_pct} ≈ 1.0");
}
#[test]
fn scenario_unknown_files_ignored() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits = vec![
commit(1000, "alice", "feat: init", &["src/lib.rs"]),
commit(2000, "bob", "feat: unknown", &["unknown/file.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.commits_scanned, 2);
assert_eq!(report.files_seen, 1);
assert_eq!(report.hotspots.len(), 1);
assert_eq!(report.hotspots[0].path, "src/lib.rs");
}
#[test]
fn scenario_child_file_kind_excluded() {
let mut rows = vec![file_row("src/lib.rs", "src", 100)];
rows.push(FileRow {
path: "src/lib.rs".to_string(),
module: "src".to_string(),
lang: "Markdown".to_string(),
kind: FileKind::Child,
code: 5,
comments: 0,
blanks: 0,
lines: 5,
bytes: 50,
tokens: 10,
});
let exp = export(rows);
let commits = vec![commit(1000, "alice", "feat: init", &["src/lib.rs"])];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.hotspots.len(), 1);
assert_eq!(report.hotspots[0].lines, 100);
}
#[test]
fn scenario_backslash_paths_normalized() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits = vec![
commit(1000, "alice", "feat: init", &["src\\lib.rs"]),
commit(2000, "bob", "fix: it", &["./src/lib.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.hotspots.len(), 1);
assert_eq!(report.hotspots[0].commits, 2);
}
#[test]
fn scenario_churn_empty_commits() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let report = build_predictive_churn_report(&exp, &[], Path::new("."));
assert!(report.per_module.is_empty());
}
#[test]
fn scenario_churn_steady_commits() {
let week = 7 * DAY;
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits: Vec<GitCommit> = (1..=5)
.map(|i| commit(i * week, "alice", "feat: weekly", &["src/lib.rs"]))
.collect();
let report = build_predictive_churn_report(&exp, &commits, Path::new("."));
let trend = report.per_module.get("src").expect("module present");
assert!(
trend.slope.abs() < 0.1,
"constant rate should have near-zero slope, got {}",
trend.slope
);
assert_eq!(trend.classification, TrendClass::Flat);
}
#[test]
fn scenario_churn_rising_trend() {
let week = 7 * DAY;
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let mut commits = Vec::new();
for w in 1..=5i64 {
for _ in 0..w {
commits.push(commit(w * week, "alice", "feat: more", &["src/lib.rs"]));
}
}
let report = build_predictive_churn_report(&exp, &commits, Path::new("."));
let trend = report.per_module.get("src").expect("module present");
assert!(
trend.slope > 0.0,
"increasing activity should have positive slope"
);
assert_eq!(trend.classification, TrendClass::Rising);
}
#[test]
fn scenario_churn_single_commit_flat() {
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits = vec![commit(7 * DAY, "alice", "feat: init", &["src/lib.rs"])];
let report = build_predictive_churn_report(&exp, &commits, Path::new("."));
let trend = report.per_module.get("src").expect("module present");
assert_eq!(trend.slope, 0.0);
assert_eq!(trend.classification, TrendClass::Flat);
}
#[test]
fn scenario_refresh_trend_rising() {
let now = 1000 * DAY;
let exp = export(vec![file_row("src/lib.rs", "src", 100)]);
let commits = vec![
commit(now - 5 * DAY, "alice", "feat: recent1", &["src/lib.rs"]),
commit(now, "bob", "feat: recent2", &["src/lib.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
let age = report.age_distribution.as_ref().unwrap();
assert_eq!(age.refresh_trend, TrendClass::Rising);
assert!(age.recent_refreshes > 0);
assert_eq!(age.prior_refreshes, 0);
}
#[test]
fn scenario_intent_by_module() {
let exp = export(vec![
file_row("api/handler.rs", "api", 100),
file_row("db/query.rs", "db", 80),
]);
let commits = vec![
commit(1000, "alice", "feat: api feature", &["api/handler.rs"]),
commit(2000, "bob", "fix: db bug", &["db/query.rs"]),
commit(
3000,
"charlie",
"feat: both",
&["api/handler.rs", "db/query.rs"],
),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
let intent = report.intent.as_ref().unwrap();
assert_eq!(intent.by_module.len(), 2);
let api_intent = intent.by_module.iter().find(|m| m.module == "api").unwrap();
let db_intent = intent.by_module.iter().find(|m| m.module == "db").unwrap();
assert_eq!(api_intent.counts.feat, 2);
assert_eq!(db_intent.counts.fix, 1);
assert_eq!(db_intent.counts.feat, 1);
}
#[test]
fn scenario_coupling_metrics_computed() {
let exp = export(vec![
file_row("api/handler.rs", "api", 100),
file_row("db/query.rs", "db", 80),
]);
let commits = vec![
commit(
1000,
"alice",
"feat: both1",
&["api/handler.rs", "db/query.rs"],
),
commit(
2000,
"bob",
"feat: both2",
&["api/handler.rs", "db/query.rs"],
),
commit(3000, "alice", "fix: api only", &["api/handler.rs"]),
commit(4000, "bob", "fix: db only", &["db/query.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.coupling.len(), 1);
let c = &report.coupling[0];
assert_eq!(c.count, 2); assert_eq!(c.n_left, Some(3)); assert_eq!(c.n_right, Some(3)); assert_eq!(c.jaccard, Some(0.5));
let lift = c.lift.unwrap();
assert!(
(lift - 0.8889).abs() < 0.001,
"lift should be ~0.889, got {lift}"
);
}
#[test]
fn scenario_files_seen_counts_distinct() {
let exp = export(vec![
file_row("src/a.rs", "src", 50),
file_row("src/b.rs", "src", 30),
]);
let commits = vec![
commit(1000, "alice", "feat: a", &["src/a.rs"]),
commit(2000, "bob", "feat: b", &["src/b.rs"]),
commit(3000, "alice", "fix: a", &["src/a.rs"]),
];
let report = build_git_report(Path::new("."), &exp, &commits).unwrap();
assert_eq!(report.files_seen, 2);
assert_eq!(report.commits_scanned, 3);
}