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
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/// Stable identity for a coupling issue across snapshots.
16#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct IssueKey {
18    /// Issue category used as the primary diff discriminator.
19    pub issue_type: IssueType,
20    /// Normalized issue source; count-only sources are canonicalized.
21    pub source: String,
22    /// Normalized issue target; count-only targets are canonicalized.
23    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/// Difference between a baseline report and the current report.
64#[derive(Debug, Clone)]
65pub struct BaselineDiff {
66    /// Issues present only in the current report.
67    pub new_issues: Vec<CouplingIssue>,
68    /// Issues present only in the baseline report.
69    pub resolved_issues: Vec<CouplingIssue>,
70    /// Number of stable issue keys present in both reports.
71    pub unchanged: usize,
72    /// Current average score minus baseline average score.
73    pub score_delta: f64,
74    /// Baseline health grade.
75    pub baseline_grade: HealthGrade,
76    /// Current health grade.
77    pub current_grade: HealthGrade,
78}
79
80impl BaselineDiff {
81    /// New issues at or above `severity`.
82    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
90/// Compute a stable-key issue diff from baseline to current.
91pub 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(&current_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
128/// Diff a baseline git-ref analysis against the current report.
129pub 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        // digits but wrong unit -> false (guards against `&&` -> `||`).
180        assert!(!is_count_target("3 widgets", "dependencies"));
181        // non-digit count -> false.
182        assert!(!is_count_target("many dependencies", "dependencies"));
183        // no space separator -> false.
184        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        // A non-count issue type keeps source/target verbatim.
228        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        // Efferent issue whose target is NOT a count is preserved (guard must stay false).
238        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        // Afferent issue whose source is NOT a count is preserved.
247        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, &current);
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, &current);
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, &current);
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}