use std::collections::HashMap;
use argus_core::ArgusError;
use serde::{Deserialize, Serialize};
use crate::mining::CommitInfo;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CoupledPair {
pub file_a: String,
pub file_b: String,
pub co_changes: u32,
pub coupling_degree: f64,
pub changes_a: u32,
pub changes_b: u32,
}
pub fn detect_coupling(
commits: &[CommitInfo],
min_coupling: f64,
min_co_changes: u32,
) -> Result<Vec<CoupledPair>, ArgusError> {
let mut file_changes: HashMap<String, u32> = HashMap::new();
let mut co_changes: HashMap<(String, String), u32> = HashMap::new();
for commit in commits {
let files: Vec<&str> = commit
.files_changed
.iter()
.map(|f| f.path.as_str())
.collect();
let unique_files: Vec<&str> = {
let mut seen = std::collections::HashSet::new();
let mut unique = Vec::new();
for f in &files {
if seen.insert(*f) {
unique.push(*f);
}
}
unique
};
for file in &unique_files {
*file_changes.entry((*file).to_string()).or_default() += 1;
}
for i in 0..unique_files.len() {
for j in (i + 1)..unique_files.len() {
let key = normalize_pair(unique_files[i], unique_files[j]);
*co_changes.entry(key).or_default() += 1;
}
}
}
let mut pairs = Vec::new();
for ((file_a, file_b), co_count) in &co_changes {
if *co_count < min_co_changes {
continue;
}
let changes_a = file_changes.get(file_a).copied().unwrap_or(0);
let changes_b = file_changes.get(file_b).copied().unwrap_or(0);
let max_changes = changes_a.max(changes_b);
if max_changes == 0 {
continue;
}
let coupling_degree = *co_count as f64 / max_changes as f64;
if coupling_degree < min_coupling {
continue;
}
pairs.push(CoupledPair {
file_a: file_a.clone(),
file_b: file_b.clone(),
co_changes: *co_count,
coupling_degree,
changes_a,
changes_b,
});
}
pairs.sort_by(|a, b| {
b.coupling_degree
.partial_cmp(&a.coupling_degree)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(pairs)
}
fn normalize_pair(a: &str, b: &str) -> (String, String) {
if a <= b {
(a.to_string(), b.to_string())
} else {
(b.to_string(), a.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mining::{ChangeStatus, FileChange};
fn make_commit(files: Vec<&str>) -> CommitInfo {
CommitInfo {
hash: "abc".into(),
author: "alice".into(),
email: "alice@example.com".into(),
timestamp: 1000,
message: "test".into(),
files_changed: files
.into_iter()
.map(|path| FileChange {
path: path.into(),
lines_added: 5,
lines_deleted: 2,
status: ChangeStatus::Modified,
})
.collect(),
}
}
#[test]
fn files_always_changed_together_have_coupling_1() {
let commits = vec![
make_commit(vec!["a.rs", "b.rs"]),
make_commit(vec!["a.rs", "b.rs"]),
make_commit(vec!["a.rs", "b.rs"]),
];
let pairs = detect_coupling(&commits, 0.0, 1).unwrap();
assert_eq!(pairs.len(), 1);
assert!((pairs[0].coupling_degree - 1.0).abs() < f64::EPSILON);
assert_eq!(pairs[0].co_changes, 3);
}
#[test]
fn files_never_changed_together_not_in_results() {
let commits = vec![make_commit(vec!["a.rs"]), make_commit(vec!["b.rs"])];
let pairs = detect_coupling(&commits, 0.0, 1).unwrap();
assert!(pairs.is_empty());
}
#[test]
fn min_coupling_filter_works() {
let commits = vec![
make_commit(vec!["a.rs", "b.rs"]),
make_commit(vec!["a.rs"]),
make_commit(vec!["a.rs"]),
];
let pairs_low = detect_coupling(&commits, 0.3, 1).unwrap();
assert_eq!(pairs_low.len(), 1);
let pairs_high = detect_coupling(&commits, 0.5, 1).unwrap();
assert!(pairs_high.is_empty());
}
#[test]
fn min_co_changes_filter_works() {
let commits = vec![make_commit(vec!["a.rs", "b.rs"])];
let pairs = detect_coupling(&commits, 0.0, 2).unwrap();
assert!(pairs.is_empty(), "need at least 2 co-changes");
let pairs = detect_coupling(&commits, 0.0, 1).unwrap();
assert_eq!(pairs.len(), 1);
}
#[test]
fn pair_normalization_treats_ab_same_as_ba() {
let commits = vec![
make_commit(vec!["z.rs", "a.rs"]),
make_commit(vec!["a.rs", "z.rs"]),
];
let pairs = detect_coupling(&commits, 0.0, 1).unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].file_a, "a.rs");
assert_eq!(pairs[0].file_b, "z.rs");
assert_eq!(pairs[0].co_changes, 2);
}
}