1pub mod deps;
11pub mod diff;
12pub mod perf;
13pub mod score;
14pub mod tests;
15
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Default)]
23pub struct RegressionContext {
24 pub files_changed: usize,
26 pub insertions: usize,
27 pub deletions: usize,
28 pub file_stats: Vec<(String, char, usize, usize)>,
30
31 pub coverage_ratio: Option<f64>,
33 pub base_coverage_ratio: Option<f64>,
34 pub untested_critical_files: Vec<String>,
35 pub base_untested_critical_count: Option<usize>,
36
37 pub cycles: usize,
39 pub dead_exports: usize,
40 pub unused_symbols: usize,
41
42 pub base_cycles: Option<usize>,
44 pub base_dead_exports: Option<usize>,
45 pub base_unused_symbols: Option<usize>,
46
47 pub top_cycles: Vec<String>,
49 pub top_unused: Vec<String>,
50
51 pub patch_text: Option<String>,
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57pub struct RegressionReport {
58 pub diff: diff::DiffRegression,
59 pub tests: tests::TestRegression,
60 pub deps: deps::DepsRegression,
61 pub perf: perf::PerfRegression,
62 pub score: score::RegressionScore,
63}
64
65pub fn compute_regression(ctx: &RegressionContext) -> RegressionReport {
67 let diff_reg = diff::analyze(ctx);
68 let test_reg = tests::analyze(ctx);
69 let deps_reg = deps::analyze(ctx);
70 let perf_reg = perf::analyze(ctx);
71 let score_reg = score::compute(&diff_reg, &test_reg, &deps_reg, &perf_reg);
72
73 RegressionReport {
74 diff: diff_reg,
75 tests: test_reg,
76 deps: deps_reg,
77 perf: perf_reg,
78 score: score_reg,
79 }
80}
81
82#[cfg(test)]
83mod regression_tests {
84 use super::*;
85 use score::Severity;
86
87 #[test]
88 fn test_compute_regression_diff_only() {
89 let ctx = RegressionContext {
90 files_changed: 5,
91 insertions: 100,
92 deletions: 50,
93 file_stats: vec![
94 ("src/main.rs".into(), 'M', 80, 20),
95 ("src/lib.rs".into(), 'M', 10, 10),
96 ("src/new.rs".into(), 'A', 10, 0),
97 ("README.md".into(), 'M', 0, 10),
98 ("old.rs".into(), 'D', 0, 10),
99 ],
100 ..Default::default()
101 };
102
103 let report = compute_regression(&ctx);
104
105 assert_eq!(report.diff.files_changed, 5);
106 assert_eq!(report.diff.insertions, 100);
107 assert_eq!(report.diff.deletions, 50);
108 assert_eq!(report.diff.max_file_churn, 100); assert!(!report.diff.diff_regression_detected);
110 assert_eq!(report.diff.top_hotspots.len(), 5);
111 assert_eq!(report.diff.top_hotspots[0].file, "src/main.rs");
112 assert_eq!(report.diff.changed_files_by_status.get("M"), Some(&3));
113 assert_eq!(report.diff.changed_files_by_status.get("A"), Some(&1));
114 assert_eq!(report.diff.changed_files_by_status.get("D"), Some(&1));
115 }
116
117 #[test]
118 fn test_compute_regression_high_churn() {
119 let ctx = RegressionContext {
120 files_changed: 60,
121 insertions: 3000,
122 deletions: 1000,
123 file_stats: vec![("big.rs".into(), 'M', 600, 200)],
124 ..Default::default()
125 };
126
127 let report = compute_regression(&ctx);
128 assert!(report.diff.diff_regression_detected);
129 assert_eq!(report.diff.max_file_churn, 800);
130 }
131
132 #[test]
133 fn test_score_severity_ok() {
134 let ctx = RegressionContext {
135 files_changed: 3,
136 insertions: 10,
137 deletions: 5,
138 file_stats: vec![("a.rs".into(), 'M', 10, 5)],
139 ..Default::default()
140 };
141
142 let report = compute_regression(&ctx);
143 assert_eq!(report.score.severity, Severity::OK);
144 assert_eq!(report.score.score, 0);
145 }
146
147 #[test]
148 fn test_score_severity_med() {
149 let ctx = RegressionContext {
150 files_changed: 55,
151 insertions: 2600,
152 deletions: 400,
153 file_stats: vec![("big.rs".into(), 'M', 600, 200)],
154 untested_critical_files: vec!["a.rs".into(), "b.rs".into(), "c.rs".into()],
155 cycles: 5,
156 base_cycles: Some(2),
157 ..Default::default()
158 };
159
160 let report = compute_regression(&ctx);
161 assert!(report.score.score > 10);
162 assert!(matches!(
163 report.score.severity,
164 Severity::LOW | Severity::MED | Severity::HIGH
165 ));
166 assert!(!report.score.score_reasons.is_empty());
167 }
168
169 #[test]
170 fn test_score_severity_critical() {
171 let ctx = RegressionContext {
172 files_changed: 200,
173 insertions: 10000,
174 deletions: 5000,
175 file_stats: vec![("huge.rs".into(), 'M', 5000, 3000)],
176 coverage_ratio: Some(0.2),
177 base_coverage_ratio: Some(0.8),
178 untested_critical_files: (0..10)
179 .map(|i| format!("file_{}.rs", i))
180 .collect(),
181 cycles: 20,
182 base_cycles: Some(5),
183 dead_exports: 30,
184 base_dead_exports: Some(10),
185 unused_symbols: 40,
186 base_unused_symbols: Some(10),
187 patch_text: Some(
188 "diff --git a/big.rs b/big.rs\n+++ b/big.rs\n@@ -1,3 +1,5 @@\n+for item in items {\n+ db.execute(query);\n+}\n"
189 .to_string(),
190 ),
191 ..Default::default()
192 };
193
194 let report = compute_regression(&ctx);
195 assert!(report.score.score >= 76);
196 assert_eq!(report.score.severity, Severity::CRITICAL);
197 }
198
199 #[test]
200 fn test_coverage_delta_detection() {
201 let ctx = RegressionContext {
202 coverage_ratio: Some(0.5),
203 base_coverage_ratio: Some(0.8),
204 ..Default::default()
205 };
206
207 let report = compute_regression(&ctx);
208 assert!(report.tests.coverage_regression_detected);
209 assert!(report.tests.coverage_delta.unwrap() < -0.01);
210 }
211
212 #[test]
213 fn test_deps_regression_detection() {
214 let ctx = RegressionContext {
215 cycles: 5,
216 base_cycles: Some(2),
217 dead_exports: 10,
218 base_dead_exports: Some(10),
219 unused_symbols: 8,
220 base_unused_symbols: Some(3),
221 ..Default::default()
222 };
223
224 let report = compute_regression(&ctx);
225 assert!(report.deps.dependency_regression_detected);
226 assert_eq!(report.deps.cycles_delta, 3);
227 assert_eq!(report.deps.dead_exports_delta, 0);
228 assert_eq!(report.deps.unused_symbols_delta, 5);
229 }
230
231 #[test]
232 fn test_perf_query_in_loop() {
233 let patch = r#"diff --git a/src/handler.rs b/src/handler.rs
234+++ b/src/handler.rs
235@@ -10,3 +10,6 @@
236+for user in users {
237+ let result = db.query("SELECT * FROM orders WHERE user_id = ?");
238+}
239"#;
240
241 let ctx = RegressionContext {
242 patch_text: Some(patch.to_string()),
243 ..Default::default()
244 };
245
246 let report = compute_regression(&ctx);
247 assert!(report.perf.perf_regression_suspected);
248 assert_eq!(report.perf.query_in_loop_count, 1);
249 }
250
251 #[test]
252 fn test_perf_clone_collect_in_loop() {
253 let patch = r#"diff --git a/src/process.rs b/src/process.rs
254+++ b/src/process.rs
255@@ -5,3 +5,5 @@
256+for item in items.iter() {
257+ let cloned = item.clone();
258+ let collected: Vec<_> = data.iter().collect();
259+}
260"#;
261
262 let ctx = RegressionContext {
263 patch_text: Some(patch.to_string()),
264 ..Default::default()
265 };
266
267 let report = compute_regression(&ctx);
268 assert!(report.perf.perf_regression_suspected);
269 assert!(report.perf.clone_collect_in_loop_count > 0);
270 }
271
272 #[test]
273 fn test_empty_context() {
274 let ctx = RegressionContext::default();
275 let report = compute_regression(&ctx);
276
277 assert_eq!(report.score.severity, Severity::OK);
278 assert_eq!(report.score.score, 0);
279 assert!(!report.diff.diff_regression_detected);
280 assert!(!report.tests.coverage_regression_detected);
281 assert!(!report.deps.dependency_regression_detected);
282 assert!(!report.perf.perf_regression_suspected);
283 }
284
285 #[test]
286 fn test_regression_report_round_trip() {
287 let ctx = RegressionContext {
288 files_changed: 3,
289 insertions: 50,
290 deletions: 20,
291 file_stats: vec![("a.rs".into(), 'M', 50, 20)],
292 ..Default::default()
293 };
294
295 let report = compute_regression(&ctx);
296 let json = serde_json::to_string_pretty(&report).unwrap();
297 assert!(json.contains("\"score\""));
298 assert!(json.contains("\"severity\""));
299
300 let deserialized: RegressionReport = serde_json::from_str(&json).unwrap();
302 assert_eq!(deserialized.score.score, report.score.score);
303 assert_eq!(deserialized.score.severity, report.score.severity);
304 assert_eq!(deserialized.diff.files_changed, report.diff.files_changed);
305 assert_eq!(
306 deserialized.perf.perf_regression_suspected,
307 report.perf.perf_regression_suspected
308 );
309 }
310
311 #[test]
312 fn test_score_cap_at_100() {
313 let ctx = RegressionContext {
315 files_changed: 500,
316 insertions: 50000,
317 deletions: 20000,
318 file_stats: vec![("huge.rs".into(), 'M', 50000, 20000)],
319 coverage_ratio: Some(0.0),
320 base_coverage_ratio: Some(1.0),
321 untested_critical_files: (0..50)
322 .map(|i| format!("f{}.rs", i))
323 .collect(),
324 cycles: 100,
325 base_cycles: Some(0),
326 dead_exports: 100,
327 base_dead_exports: Some(0),
328 unused_symbols: 100,
329 base_unused_symbols: Some(0),
330 patch_text: Some(
331 "diff --git a/x.rs b/x.rs\n+++ b/x.rs\n@@ -1,1 +1,3 @@\n+for x in items.iter() {\n+ db.execute(q).clone();\n+ db.query(q2).collect();\n+}\n"
332 .to_string(),
333 ),
334 ..Default::default()
335 };
336
337 let report = compute_regression(&ctx);
338 assert!(
341 report.score.score > 75,
342 "Expected high score, got {}",
343 report.score.score
344 );
345 assert!(report.score.score <= 100, "Score must not exceed 100");
346 assert_eq!(report.score.severity, Severity::CRITICAL);
347 }
348
349 #[test]
350 fn test_deps_no_base_returns_zero_deltas() {
351 let ctx = RegressionContext {
352 cycles: 10,
353 dead_exports: 5,
354 unused_symbols: 3,
355 ..Default::default()
357 };
358
359 let report = compute_regression(&ctx);
360 assert_eq!(report.deps.cycles_delta, 0);
361 assert_eq!(report.deps.dead_exports_delta, 0);
362 assert_eq!(report.deps.unused_symbols_delta, 0);
363 assert!(!report.deps.dependency_regression_detected);
364 assert_eq!(report.deps.current_cycles, 10);
365 }
366}