Skip to main content

cargo_coupling/
diff.rs

1//! Baseline diffing for coupling reports.
2//!
3//! A diff compares issue identity across two snapshots using the stable key
4//! `(issue_type, source, target)`, so ratchet checks can focus on regressions
5//! introduced by the current change rather than the codebase's absolute state.
6
7use std::collections::{HashMap, HashSet};
8
9// Consume the crate's published facade rather than deep `balance::*` paths: the
10// re-exported surface stays stable when the balance package reorganizes internally.
11use crate::history::RefAnalysis;
12use crate::{CouplingIssue, HealthGrade, IssueKey, ProjectBalanceReport, Severity};
13
14/// Difference between a baseline report and the current report.
15#[derive(Debug, Clone)]
16pub struct BaselineDiff {
17    /// Issues present only in the current report.
18    pub new_issues: Vec<CouplingIssue>,
19    /// Issues present only in the baseline report.
20    pub resolved_issues: Vec<CouplingIssue>,
21    /// Existing findings whose severity, breadth, or balance deteriorated.
22    pub worsened_issues: Vec<CouplingIssue>,
23    /// Number of stable issue keys present in both reports.
24    pub unchanged: usize,
25    /// Current average score minus baseline average score.
26    pub score_delta: f64,
27    /// Baseline health grade.
28    pub baseline_grade: HealthGrade,
29    /// Current health grade.
30    pub current_grade: HealthGrade,
31}
32
33impl BaselineDiff {
34    /// New issues at or above `severity`.
35    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
44/// Compute a stable-key issue diff from baseline to current.
45pub 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(&current_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
116/// Diff a baseline git-ref analysis against the current report.
117pub 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        // A non-count issue type keeps source/target verbatim.
203        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        // Efferent issue whose target is NOT a count is preserved (guard must stay false).
213        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        // Afferent issue whose source is NOT a count is preserved.
222        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, &current);
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, &current);
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, &current);
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, &current);
386        assert!(diff.new_issues.is_empty());
387        assert_eq!(diff.ratchet_failures(Severity::High).len(), 1);
388    }
389}