use super::RegressionContext;
use super::tests::{is_code_file, is_config_like, is_test_file};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
const MAX_HOTSPOTS: usize = 10;
const MAX_FILE_CHURN_THRESHOLD: usize = 500;
const FILES_CHANGED_THRESHOLD: usize = 50;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiffRegression {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
pub max_file_churn: usize,
pub top_hotspots: Vec<HotspotEntry>,
pub changed_files_by_status: BTreeMap<String, usize>,
pub diff_regression_detected: bool,
#[serde(default)]
pub code_files_changed: usize,
#[serde(default)]
pub code_insertions: usize,
#[serde(default)]
pub code_deletions: usize,
#[serde(default)]
pub max_code_file_churn: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub top_code_hotspots: Vec<HotspotEntry>,
#[serde(default)]
pub test_files_changed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotspotEntry {
pub file: String,
pub churn: usize,
pub additions: usize,
pub deletions: usize,
pub status: char,
}
fn is_production_code(path: &str) -> bool {
is_code_file(path) && !is_test_file(path) && !is_config_like(path)
}
pub fn analyze(ctx: &RegressionContext) -> DiffRegression {
let mut status_counts: BTreeMap<String, usize> = BTreeMap::new();
let mut max_churn: usize = 0;
let mut max_code_churn: usize = 0;
let mut code_files = 0usize;
let mut code_adds = 0usize;
let mut code_dels = 0usize;
let mut test_files = 0usize;
let mut all_entries: Vec<HotspotEntry> = Vec::new();
let mut code_entries: Vec<HotspotEntry> = Vec::new();
for (path, status, adds, dels) in &ctx.file_stats {
let churn = adds + dels;
let status_key = match status {
'A' => "A",
'M' => "M",
'D' => "D",
'R' => "R",
'C' => "C",
_ => "?",
};
*status_counts.entry(status_key.to_string()).or_insert(0) += 1;
if churn > max_churn {
max_churn = churn;
}
let entry = HotspotEntry {
file: path.clone(),
churn,
additions: *adds,
deletions: *dels,
status: *status,
};
if is_production_code(path) {
code_files += 1;
code_adds += adds;
code_dels += dels;
if churn > max_code_churn {
max_code_churn = churn;
}
code_entries.push(entry.clone());
} else if is_test_file(path) {
test_files += 1;
}
all_entries.push(entry);
}
all_entries.sort_by_key(|entry| std::cmp::Reverse(entry.churn));
all_entries.truncate(MAX_HOTSPOTS);
code_entries.sort_by_key(|entry| std::cmp::Reverse(entry.churn));
code_entries.truncate(MAX_HOTSPOTS);
let detected =
max_code_churn > MAX_FILE_CHURN_THRESHOLD || code_files > FILES_CHANGED_THRESHOLD;
DiffRegression {
files_changed: ctx.files_changed,
insertions: ctx.insertions,
deletions: ctx.deletions,
max_file_churn: max_churn,
top_hotspots: all_entries,
changed_files_by_status: status_counts,
diff_regression_detected: detected,
code_files_changed: code_files,
code_insertions: code_adds,
code_deletions: code_dels,
max_code_file_churn: max_code_churn,
top_code_hotspots: code_entries,
test_files_changed: test_files,
}
}
#[cfg(test)]
mod diff_tests {
use super::*;
#[test]
fn test_code_only_metrics_exclude_tests() {
let ctx = RegressionContext {
files_changed: 5,
insertions: 500,
deletions: 200,
file_stats: vec![
("src/main.rs".into(), 'M', 100, 50),
("src/lib.rs".into(), 'M', 50, 30),
("tests/integration.rs".into(), 'A', 200, 0),
("e2e/login.spec.ts".into(), 'A', 100, 70),
("README.md".into(), 'M', 50, 50),
],
..Default::default()
};
let result = analyze(&ctx);
assert_eq!(result.files_changed, 5);
assert_eq!(result.max_file_churn, 200);
assert_eq!(result.code_files_changed, 2);
assert_eq!(result.code_insertions, 150);
assert_eq!(result.code_deletions, 80);
assert_eq!(result.max_code_file_churn, 150);
assert_eq!(result.test_files_changed, 2);
assert_eq!(result.top_code_hotspots.len(), 2);
assert_eq!(result.top_code_hotspots[0].file, "src/main.rs");
}
#[test]
fn test_detection_uses_code_only() {
let mut file_stats: Vec<(String, char, usize, usize)> = (0..55)
.map(|i| (format!("tests/test_{}.rs", i), 'A', 10, 0))
.collect();
file_stats.push(("tests/big_test.rs".into(), 'A', 500, 100));
file_stats.extend((0..5).map(|i| (format!("src/mod_{}.rs", i), 'M', 20_usize, 10_usize)));
let ctx = RegressionContext {
files_changed: 61,
file_stats,
..Default::default()
};
let result = analyze(&ctx);
assert_eq!(result.files_changed, 61);
assert_eq!(result.max_file_churn, 600);
assert_eq!(result.code_files_changed, 5);
assert_eq!(result.max_code_file_churn, 30);
assert!(!result.diff_regression_detected);
}
#[test]
fn test_code_detection_triggers_on_code_churn() {
let ctx = RegressionContext {
files_changed: 3,
file_stats: vec![
("src/huge.rs".into(), 'M', 400, 200), ("tests/test.rs".into(), 'A', 100, 0),
("README.md".into(), 'M', 10, 5),
],
..Default::default()
};
let result = analyze(&ctx);
assert!(result.diff_regression_detected); assert_eq!(result.code_files_changed, 1);
assert_eq!(result.max_code_file_churn, 600);
}
#[test]
fn test_config_like_excluded_from_code() {
let ctx = RegressionContext {
files_changed: 4,
file_stats: vec![
("src/main.ts".into(), 'M', 50, 20),
("playwright.config.ts".into(), 'M', 100, 50),
("src/index.ts".into(), 'M', 5, 0), ("src/types.ts".into(), 'M', 30, 10), ],
..Default::default()
};
let result = analyze(&ctx);
assert_eq!(result.code_files_changed, 1); assert_eq!(result.code_insertions, 50);
assert_eq!(result.code_deletions, 20);
}
#[test]
fn test_devtools_excluded_from_code() {
let ctx = RegressionContext {
files_changed: 3,
file_stats: vec![
("src/main.rs".into(), 'M', 50, 20),
("src/devtools/browserMocks.ts".into(), 'M', 200, 50),
("src/devtools/panel.tsx".into(), 'A', 100, 0),
],
..Default::default()
};
let result = analyze(&ctx);
assert_eq!(result.code_files_changed, 1); assert_eq!(result.max_code_file_churn, 70);
}
}