1use std::collections::{HashMap, HashSet};
8
9use crate::history::RefAnalysis;
12use crate::{CouplingIssue, HealthGrade, IssueKey, ProjectBalanceReport, Severity};
13
14#[derive(Debug, Clone)]
16pub struct BaselineDiff {
17 pub new_issues: Vec<CouplingIssue>,
19 pub resolved_issues: Vec<CouplingIssue>,
21 pub worsened_issues: Vec<CouplingIssue>,
23 pub unchanged: usize,
25 pub score_delta: f64,
27 pub baseline_grade: HealthGrade,
29 pub current_grade: HealthGrade,
31}
32
33impl BaselineDiff {
34 pub fn ratchet_failures(&self, severity: Severity) -> Vec<&CouplingIssue> {
36 self.new_issues
37 .iter()
38 .chain(&self.worsened_issues)
39 .filter(|issue| issue.meets(severity))
40 .collect()
41 }
42}
43
44pub fn diff_reports(
46 baseline: &ProjectBalanceReport,
47 current: &ProjectBalanceReport,
48) -> BaselineDiff {
49 let baseline_keys: HashSet<IssueKey> = baseline.issues.iter().map(IssueKey::from).collect();
50 let current_keys: HashSet<IssueKey> = current.issues.iter().map(IssueKey::from).collect();
51 let baseline_by_key: HashMap<_, _> = baseline
52 .issues
53 .iter()
54 .map(|issue| (IssueKey::from(issue), issue))
55 .collect();
56 let mut seen_worsened = HashSet::new();
57 let worsened_issues: Vec<_> = current
58 .issues
59 .iter()
60 .filter(|issue| {
61 let key = IssueKey::from(*issue);
62 baseline_by_key.get(&key).is_some_and(|old| {
63 issue.severity > old.severity
64 || issue.balance_score + 1e-9 < old.balance_score
65 || count_breadth(issue) > count_breadth(old)
66 }) && seen_worsened.insert(key)
67 })
68 .cloned()
69 .collect();
70
71 let mut seen_new = HashSet::new();
72 let new_issues = current
73 .issues
74 .iter()
75 .filter_map(|issue| {
76 let key = IssueKey::from(issue);
77 (!baseline_keys.contains(&key) && seen_new.insert(key)).then(|| issue.clone())
78 })
79 .collect();
80
81 let mut seen_resolved = HashSet::new();
82 let resolved_issues = baseline
83 .issues
84 .iter()
85 .filter_map(|issue| {
86 let key = IssueKey::from(issue);
87 (!current_keys.contains(&key) && seen_resolved.insert(key)).then(|| issue.clone())
88 })
89 .collect();
90
91 BaselineDiff {
92 new_issues,
93 resolved_issues,
94 unchanged: baseline_keys.intersection(¤t_keys).count() - worsened_issues.len(),
95 worsened_issues,
96 score_delta: current.average_score - baseline.average_score,
97 baseline_grade: baseline.health_grade,
98 current_grade: current.health_grade,
99 }
100}
101
102fn count_breadth(issue: &CouplingIssue) -> usize {
103 use crate::IssueType;
104 let (value, unit) = match issue.issue_type {
105 IssueType::HighEfferentCoupling => (&issue.target, "dependencies"),
106 IssueType::HighAfferentCoupling => (&issue.source, "dependents"),
107 _ => return 0,
108 };
109 value
110 .split_once(' ')
111 .filter(|(_, suffix)| *suffix == unit)
112 .and_then(|(count, _)| count.parse().ok())
113 .unwrap_or(0)
114}
115
116pub fn diff_ref_analysis(baseline: &RefAnalysis, current: &ProjectBalanceReport) -> BaselineDiff {
118 diff_reports(&baseline.report, current)
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::balance::rationale::GradeRationale;
125 use crate::{IssueType, RefactoringAction};
126
127 fn issue(
128 issue_type: IssueType,
129 severity: Severity,
130 source: &str,
131 target: &str,
132 ) -> CouplingIssue {
133 CouplingIssue {
134 issue_type,
135 severity,
136 source: source.to_string(),
137 target: target.to_string(),
138 description: String::new(),
139 refactoring: RefactoringAction::General {
140 action: String::new(),
141 },
142 balance_score: 0.5,
143 }
144 }
145
146 fn report(issues: Vec<CouplingIssue>, score: f64, grade: HealthGrade) -> ProjectBalanceReport {
147 ProjectBalanceReport {
148 total_couplings: 0,
149 balanced_count: 0,
150 needs_review: 0,
151 needs_refactoring: 0,
152 average_score: score,
153 health_grade: grade,
154 issues_by_severity: Default::default(),
155 issues_by_type: Default::default(),
156 issues,
157 top_priorities: Vec::new(),
158 grade_rationale: GradeRationale::empty(),
159 }
160 }
161
162 #[test]
163 fn issue_key_normalizes_efferent_dependency_count() {
164 let three = IssueKey::from(&issue(
165 IssueType::HighEfferentCoupling,
166 Severity::High,
167 "m",
168 "3 dependencies",
169 ));
170 let five = IssueKey::from(&issue(
171 IssueType::HighEfferentCoupling,
172 Severity::High,
173 "m",
174 "5 dependencies",
175 ));
176 assert_eq!(three, five, "count change must not change the key");
177 assert_eq!(three.target, "<dependency-count>");
178 assert_eq!(three.source, "m", "source passes through unchanged");
179 }
180
181 #[test]
182 fn issue_key_normalizes_afferent_dependent_count() {
183 let three = IssueKey::from(&issue(
184 IssueType::HighAfferentCoupling,
185 Severity::High,
186 "3 dependents",
187 "m",
188 ));
189 let five = IssueKey::from(&issue(
190 IssueType::HighAfferentCoupling,
191 Severity::High,
192 "5 dependents",
193 "m",
194 ));
195 assert_eq!(three, five);
196 assert_eq!(three.source, "<dependent-count>");
197 assert_eq!(three.target, "m", "target passes through unchanged");
198 }
199
200 #[test]
201 fn issue_key_preserves_non_count_fields() {
202 let k = IssueKey::from(&issue(
204 IssueType::GodModule,
205 Severity::High,
206 "src_mod",
207 "dst_mod",
208 ));
209 assert_eq!(k.source, "src_mod");
210 assert_eq!(k.target, "dst_mod");
211
212 let e = IssueKey::from(&issue(
214 IssueType::HighEfferentCoupling,
215 Severity::High,
216 "m",
217 "not a count",
218 ));
219 assert_eq!(e.target, "not a count");
220
221 let a = IssueKey::from(&issue(
223 IssueType::HighAfferentCoupling,
224 Severity::High,
225 "not a count",
226 "m",
227 ));
228 assert_eq!(a.source, "not a count");
229 }
230
231 #[test]
232 fn diffs_by_stable_issue_key() {
233 let unchanged = issue(IssueType::GodModule, Severity::Medium, "a", "too much");
234 let resolved = issue(
235 IssueType::HighAfferentCoupling,
236 Severity::High,
237 "2 deps",
238 "b",
239 );
240 let new = issue(
241 IssueType::HighEfferentCoupling,
242 Severity::High,
243 "c",
244 "3 deps",
245 );
246
247 let baseline = report(
248 vec![unchanged.clone(), resolved.clone()],
249 0.7,
250 HealthGrade::B,
251 );
252 let current = report(vec![unchanged, new.clone()], 0.6, HealthGrade::C);
253
254 let diff = diff_reports(&baseline, ¤t);
255
256 assert_eq!(diff.new_issues.len(), 1);
257 assert_eq!(diff.new_issues[0].source, new.source);
258 assert_eq!(diff.resolved_issues.len(), 1);
259 assert_eq!(diff.resolved_issues[0].source, resolved.source);
260 assert_eq!(diff.unchanged, 1);
261 assert!((diff.score_delta + 0.1).abs() < f64::EPSILON);
262 assert_eq!(diff.baseline_grade, HealthGrade::B);
263 assert_eq!(diff.current_grade, HealthGrade::C);
264 }
265
266 #[test]
267 fn ratchet_filters_by_severity() {
268 let diff = BaselineDiff {
269 new_issues: vec![
270 issue(IssueType::GodModule, Severity::Medium, "a", "too much"),
271 issue(
272 IssueType::HighEfferentCoupling,
273 Severity::High,
274 "b",
275 "3 deps",
276 ),
277 ],
278 resolved_issues: Vec::new(),
279 worsened_issues: Vec::new(),
280 unchanged: 0,
281 score_delta: 0.0,
282 baseline_grade: HealthGrade::B,
283 current_grade: HealthGrade::B,
284 };
285
286 assert_eq!(diff.ratchet_failures(Severity::High).len(), 1);
287 assert_eq!(diff.ratchet_failures(Severity::Medium).len(), 2);
288 }
289
290 #[test]
291 fn high_coupling_count_targets_do_not_create_new_issue_keys() {
292 let baseline = report(
293 vec![
294 issue(
295 IssueType::HighEfferentCoupling,
296 Severity::High,
297 "web::server",
298 "2 dependencies",
299 ),
300 issue(
301 IssueType::HighAfferentCoupling,
302 Severity::High,
303 "2 dependents",
304 "cli_output",
305 ),
306 ],
307 0.7,
308 HealthGrade::B,
309 );
310 let current = report(
311 vec![
312 issue(
313 IssueType::HighEfferentCoupling,
314 Severity::High,
315 "web::server",
316 "3 dependencies",
317 ),
318 issue(
319 IssueType::HighAfferentCoupling,
320 Severity::High,
321 "3 dependents",
322 "cli_output",
323 ),
324 ],
325 0.6,
326 HealthGrade::C,
327 );
328
329 let diff = diff_reports(&baseline, ¤t);
330
331 assert!(diff.new_issues.is_empty());
332 assert!(diff.resolved_issues.is_empty());
333 assert_eq!(diff.unchanged, 0);
334 assert_eq!(diff.worsened_issues.len(), 2);
335 }
336
337 #[test]
338 fn diff_deduplicates_same_key_issues() {
339 let duplicate_new = issue(
340 IssueType::CascadingChangeRisk,
341 Severity::High,
342 "web::server",
343 "cli_output",
344 );
345 let duplicate_resolved = issue(
346 IssueType::GlobalComplexity,
347 Severity::Medium,
348 "history",
349 "volatility",
350 );
351 let baseline = report(
352 vec![duplicate_resolved.clone(), duplicate_resolved],
353 0.7,
354 HealthGrade::B,
355 );
356 let current = report(
357 vec![duplicate_new.clone(), duplicate_new],
358 0.6,
359 HealthGrade::C,
360 );
361
362 let diff = diff_reports(&baseline, ¤t);
363
364 assert_eq!(diff.new_issues.len(), 1);
365 assert_eq!(diff.resolved_issues.len(), 1);
366 let new_keys: HashSet<IssueKey> = diff.new_issues.iter().map(IssueKey::from).collect();
367 let resolved_keys: HashSet<IssueKey> =
368 diff.resolved_issues.iter().map(IssueKey::from).collect();
369 assert_eq!(new_keys.len(), diff.new_issues.len());
370 assert_eq!(resolved_keys.len(), diff.resolved_issues.len());
371 }
372
373 #[test]
374 fn existing_issue_escalation_fails_ratchet() {
375 let baseline = report(
376 vec![issue(IssueType::GodModule, Severity::Medium, "a", "large")],
377 0.7,
378 HealthGrade::B,
379 );
380 let current = report(
381 vec![issue(IssueType::GodModule, Severity::High, "a", "large")],
382 0.6,
383 HealthGrade::C,
384 );
385 let diff = diff_reports(&baseline, ¤t);
386 assert!(diff.new_issues.is_empty());
387 assert_eq!(diff.ratchet_failures(Severity::High).len(), 1);
388 }
389}