use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
pub const MIN_DC_APPEARANCES: usize = 10;
pub const DC_JACCARD_THRESHOLD: f64 = 0.15;
pub const DC_TRAIN_PCT: f64 = 0.8;
pub const DC_TOP_FILE_PCT: f64 = 0.2;
pub const DC_WINDOW_365D: u32 = 365;
const SEP: &str = "@@C@@";
fn is_fix_subject(subject: &str) -> bool {
let lower = subject.to_lowercase();
if lower.contains("fix")
|| lower.contains("bug")
|| lower.contains("patch")
|| lower.contains("regression")
|| lower.contains("defect")
|| lower.contains("hotfix")
{
return true;
}
if let Some(rest) = lower.strip_prefix("fix") {
let rest = rest
.trim_start_matches(|c: char| c == '(' || c.is_alphanumeric() || c == '_' || c == '-');
let rest = rest.trim_start_matches(')');
let rest = rest.trim_start_matches('!');
if rest.starts_with(':') {
return true;
}
}
false
}
fn load_commits(git_dir: &Path) -> Vec<(i64, String, Vec<String>)> {
let format = format!("{}%at %s", SEP);
let out = Command::new("git")
.args([
"--git-dir",
&git_dir.to_string_lossy(),
"log",
"--first-parent",
"--name-only",
"--diff-filter=ACDMRT",
&format!("--format={}", format),
])
.output();
let stdout = match out {
Ok(o) if o.status.success() || !o.stdout.is_empty() => o.stdout,
_ => return vec![],
};
let text = String::from_utf8_lossy(&stdout);
let mut commits: Vec<(i64, String, Vec<String>)> = Vec::new();
let mut cur_ts: i64 = 0;
let mut cur_subj = String::new();
let mut cur_files: Vec<String> = Vec::new();
let mut in_commit = false;
for line in text.lines() {
if let Some(rest) = line.strip_prefix(SEP) {
if in_commit && !cur_files.is_empty() {
commits.push((cur_ts, cur_subj.clone(), cur_files.clone()));
}
let (ts_str, subj) = rest.split_once(' ').unwrap_or((rest, ""));
cur_ts = ts_str.parse().unwrap_or(0);
cur_subj = subj.to_string();
cur_files = Vec::new();
in_commit = true;
} else if in_commit && !line.trim().is_empty() {
cur_files.push(line.trim().to_string());
}
}
if in_commit && !cur_files.is_empty() {
commits.push((cur_ts, cur_subj, cur_files));
}
commits.sort_by_key(|c| c.0);
commits
}
pub fn git_dir(repo_root: &Path) -> std::path::PathBuf {
if repo_root
.file_name()
.map(|n| n.to_string_lossy().ends_with(".git"))
.unwrap_or(false)
{
repo_root.to_path_buf()
} else {
repo_root.join(".git")
}
}
pub fn compute_directed_coupling(
commits: &[(i64, String, Vec<String>)],
partner_scores: &HashMap<String, f64>,
min_appearances: usize,
window_days: Option<u32>,
) -> HashMap<String, f64> {
let filtered: Vec<&(i64, String, Vec<String>)> = if let Some(days) = window_days {
let cutoff_ts = commits.last().map(|c| c.0).unwrap_or(0) - days as i64 * 86_400;
commits.iter().filter(|c| c.0 >= cutoff_ts).collect()
} else {
commits.iter().collect()
};
let mut appearances: HashMap<String, usize> = HashMap::new();
let mut weighted_co: HashMap<String, f64> = HashMap::new();
for (_, _, files) in &filtered {
let mut fs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
fs.sort_unstable();
fs.dedup();
for &f in &fs {
*appearances.entry(f.to_string()).or_insert(0) += 1;
}
for i in 0..fs.len() {
for j in (i + 1)..fs.len() {
let fa = fs[i];
let fb = fs[j];
if let Some(&sb) = partner_scores.get(fb) {
if sb > 0.0 {
*weighted_co.entry(fa.to_string()).or_insert(0.0) += sb;
}
}
if let Some(&sa) = partner_scores.get(fa) {
if sa > 0.0 {
*weighted_co.entry(fb.to_string()).or_insert(0.0) += sa;
}
}
}
}
}
appearances
.into_iter()
.filter(|(_, count)| *count >= min_appearances)
.map(|(f, count)| {
let score = weighted_co.get(&f).copied().unwrap_or(0.0) / count as f64;
(f, score)
})
.collect()
}
pub fn compute_jaccard_stability(
commits: &[(i64, String, Vec<String>)],
train_pct: f64,
top_pct: f64,
) -> Option<f64> {
if commits.is_empty() {
return None;
}
let cutoff_idx = (commits.len() as f64 * train_pct) as usize;
let train = &commits[..cutoff_idx];
let holdout = &commits[cutoff_idx..];
let fix_counts = |window: &[(i64, String, Vec<String>)]| -> HashMap<String, usize> {
let mut counts: HashMap<String, usize> = HashMap::new();
for (_, subj, files) in window {
if is_fix_subject(subj) {
for f in files {
*counts.entry(f.clone()).or_insert(0) += 1;
}
}
}
counts
};
let train_fixes = fix_counts(train);
let holdout_fixes = fix_counts(holdout);
if train_fixes.is_empty() || holdout_fixes.is_empty() {
return None;
}
let n_top = ((train_fixes.len() as f64 * top_pct) as usize).max(1);
let mut train_ranked: Vec<(&String, usize)> =
train_fixes.iter().map(|(f, &c)| (f, c)).collect();
train_ranked.sort_by_key(|a| std::cmp::Reverse(a.1));
let train_top: std::collections::HashSet<&str> = train_ranked
.iter()
.take(n_top)
.map(|(f, _)| f.as_str())
.collect();
let n_holdout_top = ((holdout_fixes.len() as f64 * top_pct) as usize).max(1);
let mut holdout_ranked: Vec<(&String, usize)> =
holdout_fixes.iter().map(|(f, &c)| (f, c)).collect();
holdout_ranked.sort_by_key(|a| std::cmp::Reverse(a.1));
let holdout_top: std::collections::HashSet<&str> = holdout_ranked
.iter()
.take(n_holdout_top)
.map(|(f, _)| f.as_str())
.collect();
let intersection = train_top.intersection(&holdout_top).count();
let union = train_top.union(&holdout_top).count();
if union == 0 {
return None;
}
Some(intersection as f64 / union as f64)
}
pub fn compute_directed_coupling_for_repo(
repo_root: &Path,
partner_scores: &HashMap<String, f64>,
) -> (HashMap<String, f64>, Option<f64>) {
let gd = git_dir(repo_root);
if !gd.exists() {
return (HashMap::new(), None);
}
let commits = load_commits(&gd);
if commits.is_empty() {
return (HashMap::new(), None);
}
if commits.len() > 50_000 {
eprintln!(
"hotspots: directed coupling loading {} commits (large repo — may be slow; pass --skip-touch-metrics to skip)",
commits.len()
);
}
let jaccard = compute_jaccard_stability(&commits, DC_TRAIN_PCT, DC_TOP_FILE_PCT);
let window_days = match jaccard {
Some(j) if j < DC_JACCARD_THRESHOLD => Some(DC_WINDOW_365D),
_ => None,
};
let scores =
compute_directed_coupling(&commits, partner_scores, MIN_DC_APPEARANCES, window_days);
(scores, jaccard)
}
pub fn compute_raw_coupling_ratios(
commits: &[(i64, String, Vec<String>)],
min_appearances: usize,
) -> HashMap<(String, String), f64> {
let mut appearances: HashMap<String, usize> = HashMap::new();
let mut co_occurrence: HashMap<(String, String), usize> = HashMap::new();
for (_, _, files) in commits {
let mut fs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
fs.sort_unstable();
fs.dedup();
for &f in &fs {
*appearances.entry(f.to_string()).or_insert(0) += 1;
}
for i in 0..fs.len() {
for j in (i + 1)..fs.len() {
let (fa, fb) = (fs[i], fs[j]);
let key = if fa < fb {
(fa.to_string(), fb.to_string())
} else {
(fb.to_string(), fa.to_string())
};
*co_occurrence.entry(key).or_insert(0) += 1;
}
}
}
co_occurrence
.into_iter()
.filter_map(|((a, b), co_count)| {
let count_a = *appearances.get(&a).unwrap_or(&0);
let count_b = *appearances.get(&b).unwrap_or(&0);
if count_a < min_appearances || count_b < min_appearances {
return None;
}
let denom = count_a.min(count_b);
if denom == 0 {
return None;
}
Some(((a, b), co_count as f64 / denom as f64))
})
.collect()
}
pub fn compute_raw_coupling_for_repo(repo_root: &Path) -> HashMap<(String, String), f64> {
let gd = git_dir(repo_root);
if !gd.exists() {
return HashMap::new();
}
let commits = load_commits(&gd);
if commits.is_empty() {
return HashMap::new();
}
compute_raw_coupling_ratios(&commits, 1)
}
#[cfg(test)]
mod tests {
use super::*;
fn commit(ts: i64, subj: &str, files: &[&str]) -> (i64, String, Vec<String>) {
(
ts,
subj.to_string(),
files.iter().map(|f| f.to_string()).collect(),
)
}
#[test]
fn fix_subject_keyword_variants() {
assert!(is_fix_subject("fix: null deref"));
assert!(is_fix_subject("bug in parser"));
assert!(is_fix_subject("hotfix prod"));
assert!(is_fix_subject("patch security hole"));
assert!(is_fix_subject("regression in v2"));
assert!(is_fix_subject("defect resolved"));
assert!(!is_fix_subject("feat: add login"));
assert!(!is_fix_subject("refactor: cleanup"));
assert!(!is_fix_subject("chore: bump deps"));
}
#[test]
fn dc_empty_commits_returns_empty() {
let scores = compute_directed_coupling(&[], &HashMap::new(), 1, None);
assert!(scores.is_empty());
}
#[test]
fn dc_single_file_commits_no_co_change() {
let commits = vec![
commit(1, "fix: a", &["a.rs"]),
commit(2, "fix: a", &["a.rs"]),
commit(3, "fix: a", &["a.rs"]),
];
let partner_scores: HashMap<String, f64> = [("a.rs".to_string(), 1.0)].into();
let scores = compute_directed_coupling(&commits, &partner_scores, 1, None);
assert_eq!(scores.get("a.rs").copied(), Some(0.0));
}
#[test]
fn dc_co_change_weights_by_partner_score() {
let commits: Vec<_> = (0..10)
.map(|i| commit(i, "fix: x", &["a.rs", "b.rs"]))
.collect();
let partner_scores: HashMap<String, f64> = [("b.rs".to_string(), 2.0)].into();
let scores = compute_directed_coupling(&commits, &partner_scores, 1, None);
assert!((scores["a.rs"] - 2.0).abs() < 1e-9);
}
#[test]
fn dc_min_appearances_filters_rare_files() {
let commits: Vec<_> = (0..5)
.map(|i| commit(i, "fix: x", &["a.rs", "b.rs"]))
.collect();
let partner_scores: HashMap<String, f64> = [("b.rs".to_string(), 1.0)].into();
let scores = compute_directed_coupling(&commits, &partner_scores, 10, None);
assert!(scores.is_empty());
}
#[test]
fn dc_window_restricts_to_recent_commits() {
let mut commits: Vec<_> = (0..5)
.map(|i| commit(i * 86_400, "chore: old", &["a.rs", "b.rs"]))
.collect();
let base = 1_000 * 86_400_i64;
commits.extend((0..5).map(|i| commit(base + i * 86_400, "fix: new", &["a.rs", "b.rs"])));
let partner_scores: HashMap<String, f64> = [("b.rs".to_string(), 1.0)].into();
let scores = compute_directed_coupling(&commits, &partner_scores, 1, Some(10));
assert!((scores["a.rs"] - 1.0).abs() < 1e-9);
}
#[test]
fn jaccard_empty_returns_none() {
assert_eq!(compute_jaccard_stability(&[], 0.8, 0.2), None);
}
#[test]
fn jaccard_no_fix_commits_returns_none() {
let commits = vec![
commit(1, "feat: add login", &["a.rs"]),
commit(2, "chore: cleanup", &["b.rs"]),
];
assert_eq!(compute_jaccard_stability(&commits, 0.8, 0.2), None);
}
#[test]
fn jaccard_same_files_in_both_windows_returns_one() {
let commits: Vec<_> = (0..10).map(|i| commit(i, "fix: x", &["a.rs"])).collect();
let j = compute_jaccard_stability(&commits, 0.8, 0.2).unwrap();
assert!((j - 1.0).abs() < 1e-9);
}
#[test]
fn jaccard_disjoint_files_returns_zero() {
let mut commits: Vec<_> = (0..8).map(|i| commit(i, "fix: x", &["a.rs"])).collect();
commits.extend((8..10).map(|i| commit(i, "fix: y", &["b.rs"])));
let j = compute_jaccard_stability(&commits, 0.8, 0.2).unwrap();
assert!((j - 0.0).abs() < 1e-9);
}
#[test]
fn raw_coupling_empty_commits_returns_empty() {
let ratios = compute_raw_coupling_ratios(&[], 1);
assert!(ratios.is_empty());
}
#[test]
fn raw_coupling_single_file_commits_no_pairs() {
let commits = vec![
commit(1, "feat: a", &["a.rs"]),
commit(2, "feat: a", &["a.rs"]),
];
let ratios = compute_raw_coupling_ratios(&commits, 1);
assert!(ratios.is_empty());
}
#[test]
fn raw_coupling_exact_ratio_matches_formula() {
let commits = vec![
commit(1, "feat: a", &["a.rs"]),
commit(2, "feat: ab", &["a.rs", "b.rs"]),
commit(3, "feat: a", &["a.rs"]),
commit(4, "feat: ab", &["a.rs", "b.rs"]),
];
let ratios = compute_raw_coupling_ratios(&commits, 1);
let key = ("a.rs".to_string(), "b.rs".to_string());
assert!((ratios[&key] - 1.0).abs() < 1e-9);
}
#[test]
fn raw_coupling_key_is_order_independent() {
let commits = vec![commit(1, "feat: ab", &["b.rs", "a.rs"])];
let ratios = compute_raw_coupling_ratios(&commits, 1);
assert!(ratios.contains_key(&("a.rs".to_string(), "b.rs".to_string())));
assert!(!ratios.contains_key(&("b.rs".to_string(), "a.rs".to_string())));
}
#[test]
fn raw_coupling_min_appearances_excludes_sparse_files() {
let commits = vec![
commit(1, "feat: ab", &["a.rs", "b.rs"]),
commit(2, "feat: a", &["a.rs"]),
];
let ratios = compute_raw_coupling_ratios(&commits, 2);
assert!(!ratios.contains_key(&("a.rs".to_string(), "b.rs".to_string())));
}
#[test]
fn raw_coupling_for_repo_missing_git_dir_returns_empty() {
let ratios = compute_raw_coupling_for_repo(Path::new("/nonexistent/repo/path"));
assert!(ratios.is_empty());
}
}