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