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::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    /// Number of stable issue keys present in both reports.
22    pub unchanged: usize,
23    /// Current average score minus baseline average score.
24    pub score_delta: f64,
25    /// Baseline health grade.
26    pub baseline_grade: HealthGrade,
27    /// Current health grade.
28    pub current_grade: HealthGrade,
29}
30
31impl BaselineDiff {
32    /// New issues at or above `severity`.
33    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
41/// Compute a stable-key issue diff from baseline to current.
42pub 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(&current_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
79/// Diff a baseline git-ref analysis against the current report.
80pub 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        // A non-count issue type keeps source/target verbatim.
166        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        // Efferent issue whose target is NOT a count is preserved (guard must stay false).
176        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        // Afferent issue whose source is NOT a count is preserved.
185        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, &current);
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, &current);
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, &current);
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}