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