Skip to main content

cargo_coupling/balance/
issue.rs

1use super::action::RefactoringAction;
2use super::issue_type::IssueType;
3use super::severity::Severity;
4
5/// A detected coupling issue with refactoring recommendation
6#[derive(Debug, Clone)]
7pub struct CouplingIssue {
8    /// Type of issue
9    pub issue_type: IssueType,
10    /// Severity of the issue
11    pub severity: Severity,
12    /// Source component
13    pub source: String,
14    /// Target component
15    pub target: String,
16    /// Specific description of this instance
17    pub description: String,
18    /// Concrete refactoring action to take
19    pub refactoring: RefactoringAction,
20    /// Balance score that triggered this issue
21    pub balance_score: f64,
22}
23
24impl CouplingIssue {
25    /// Stable identity of this issue across snapshots.
26    ///
27    /// Lives here (not in the diff layer) because the normalization depends on how
28    /// this module formats count-only sources/targets — when that formatting
29    /// changes, the key logic must change with it, atomically.
30    pub fn stable_key(&self) -> IssueKey {
31        IssueKey {
32            issue_type: self.issue_type,
33            source: match self.issue_type {
34                IssueType::HighAfferentCoupling if is_count_target(&self.source, "dependents") => {
35                    "<dependent-count>".to_string()
36                }
37                _ => self.source.clone(),
38            },
39            target: match self.issue_type {
40                IssueType::HighEfferentCoupling
41                    if is_count_target(&self.target, "dependencies") =>
42                {
43                    "<dependency-count>".to_string()
44                }
45                _ => self.target.clone(),
46            },
47        }
48    }
49
50    /// Whether this issue is at least as severe as `floor`.
51    pub fn meets(&self, floor: Severity) -> bool {
52        self.severity >= floor
53    }
54}
55
56/// Stable identity for a coupling issue across snapshots.
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub struct IssueKey {
59    /// Issue category used as the primary diff discriminator.
60    pub issue_type: IssueType,
61    /// Normalized issue source; count-only sources are canonicalized.
62    pub source: String,
63    /// Normalized issue target; count-only targets are canonicalized.
64    pub target: String,
65}
66
67impl From<&CouplingIssue> for IssueKey {
68    fn from(issue: &CouplingIssue) -> Self {
69        issue.stable_key()
70    }
71}
72
73fn is_count_target(value: &str, unit: &str) -> bool {
74    let Some((count, suffix)) = value.split_once(' ') else {
75        return false;
76    };
77    count.chars().all(|c| c.is_ascii_digit()) && suffix == unit
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn is_count_target_requires_digits_and_exact_unit() {
86        assert!(is_count_target("3 dependencies", "dependencies"));
87        assert!(is_count_target("12 dependents", "dependents"));
88        // digits but wrong unit -> false (guards against `&&` -> `||`).
89        assert!(!is_count_target("3 widgets", "dependencies"));
90        // non-digit count -> false.
91        assert!(!is_count_target("many dependencies", "dependencies"));
92        // no space separator -> false.
93        assert!(!is_count_target("dependencies", "dependencies"));
94    }
95
96    #[test]
97    fn meets_compares_severity_floor() {
98        let issue = CouplingIssue {
99            issue_type: IssueType::GodModule,
100            severity: Severity::Medium,
101            source: String::new(),
102            target: String::new(),
103            description: String::new(),
104            refactoring: RefactoringAction::General {
105                action: String::new(),
106            },
107            balance_score: 0.5,
108        };
109        assert!(issue.meets(Severity::Low));
110        assert!(issue.meets(Severity::Medium));
111        assert!(!issue.meets(Severity::High));
112    }
113}