Skip to main content

cargo_coupling/balance/
grade.rs

1use std::collections::HashMap;
2
3use super::issue::CouplingIssue;
4use super::issue_type::IssueType;
5use super::rationale::{GradeDimension, GradeRationale, IssueTypeContribution};
6use super::severity::Severity;
7
8pub(crate) fn build_grade_rationale(
9    issues: &[CouplingIssue],
10    internal_couplings: usize,
11    japanese: bool,
12) -> GradeRationale {
13    if issues.is_empty() {
14        let summary = if japanese {
15            if internal_couplings == 0 {
16                "問題密度の採点に使える内部結合はありませんでした。".to_string()
17            } else {
18                format!(
19                    "{} 件の内部結合に検出対象の問題はありません。問題密度が低いため、このグレードになっています。",
20                    internal_couplings
21                )
22            }
23        } else if internal_couplings == 0 {
24            "No internal couplings were available for issue-density scoring.".to_string()
25        } else {
26            format!(
27                "No surfaced coupling issues across {} internal coupling(s); grade reflects low issue density.",
28                internal_couplings
29            )
30        };
31        return GradeRationale {
32            summary,
33            ..GradeRationale::empty()
34        };
35    }
36
37    let mut by_type: HashMap<IssueType, (usize, Severity, usize)> = HashMap::new();
38    let mut by_dimension: HashMap<GradeDimension, usize> = HashMap::new();
39    let mut high_or_critical = 0;
40
41    for issue in issues {
42        let weight = severity_weight(issue.severity);
43        let entry = by_type
44            .entry(issue.issue_type)
45            .or_insert((0, issue.severity, 0));
46        entry.0 += 1;
47        entry.1 = entry.1.max(issue.severity);
48        entry.2 += weight;
49        *by_dimension
50            .entry(dimension_for_issue(issue.issue_type))
51            .or_default() += weight;
52
53        if issue.severity >= Severity::High {
54            high_or_critical += 1;
55        }
56    }
57
58    let mut ranked_types: Vec<_> = by_type
59        .into_iter()
60        .map(|(issue_type, (count, highest_severity, weighted_score))| {
61            (
62                IssueTypeContribution {
63                    issue_type,
64                    count,
65                    highest_severity,
66                },
67                weighted_score,
68            )
69        })
70        .collect();
71    ranked_types.sort_by(|a, b| {
72        b.1.cmp(&a.1)
73            .then_with(|| b.0.count.cmp(&a.0.count))
74            .then_with(|| a.0.issue_type.to_string().cmp(&b.0.issue_type.to_string()))
75    });
76
77    let top_issue_types: Vec<_> = ranked_types
78        .into_iter()
79        .take(3)
80        .map(|(contribution, _)| contribution)
81        .collect();
82
83    let dominant_dimension = by_dimension
84        .into_iter()
85        .max_by_key(|(_, score)| *score)
86        .map(|(dimension, _)| dimension);
87
88    let volatility_issue_count = issues
89        .iter()
90        .filter(|issue| dimension_for_issue(issue.issue_type) == GradeDimension::Volatility)
91        .count();
92    let accidental_count = issues
93        .iter()
94        .filter(|issue| issue.issue_type == IssueType::AccidentalVolatility)
95        .count();
96    let volatility_note = if volatility_issue_count > 0 {
97        let accidental_suffix = if accidental_count > 0 {
98            if japanese {
99                format!(" (偶発的な変更頻度 {} 件を含む)", accidental_count)
100            } else {
101                format!(
102                    ", including {} accidental-volatility finding(s)",
103                    accidental_count
104                )
105            }
106        } else {
107            String::new()
108        };
109        if japanese {
110            Some(format!(
111                "変更頻度/チャーンが {} 件の問題として影響しています{}。",
112                volatility_issue_count, accidental_suffix
113            ))
114        } else {
115            Some(format!(
116                "Volatility/churn contributes through {} issue(s){}.",
117                volatility_issue_count, accidental_suffix
118            ))
119        }
120    } else {
121        None
122    };
123
124    let top_phrase = top_issue_types
125        .iter()
126        .map(|item| {
127            if japanese {
128                format!(
129                    "{} ({})",
130                    issue_type_japanese_label(item.issue_type),
131                    item.count
132                )
133            } else {
134                format!("{} ({})", item.issue_type, item.count)
135            }
136        })
137        .collect::<Vec<_>>()
138        .join(", ");
139    let severity_phrase = if japanese {
140        if high_or_critical > 0 {
141            format!("高/緊急の問題 {} 件", high_or_critical)
142        } else {
143            format!("中/低の問題 {} 件", issues.len())
144        }
145    } else if high_or_critical > 0 {
146        format!("{} high/critical issue(s)", high_or_critical)
147    } else {
148        format!("{} medium/low issue(s)", issues.len())
149    };
150    let dimension_phrase = dominant_dimension
151        .map(|dimension| {
152            if japanese {
153                format!(
154                    "。最大の要因は{}です",
155                    grade_dimension_japanese_label(dimension)
156                )
157            } else {
158                format!("; {} is the largest contributor", dimension)
159            }
160        })
161        .unwrap_or_default();
162    let note_phrase = volatility_note
163        .as_ref()
164        .map(|note| {
165            if japanese {
166                note.clone()
167            } else {
168                format!(" {}", note)
169            }
170        })
171        .unwrap_or_default();
172
173    let summary = if japanese {
174        format!(
175            "{}が主な理由です。特に {} が目立ちます{}。{note_phrase}",
176            severity_phrase, top_phrase, dimension_phrase
177        )
178    } else {
179        format!(
180            "Driven by {}, led by {}{}.{note_phrase}",
181            severity_phrase, top_phrase, dimension_phrase
182        )
183    };
184
185    GradeRationale {
186        summary,
187        top_issue_types,
188        dominant_dimension,
189        volatility_note,
190    }
191}
192
193fn grade_dimension_japanese_label(dimension: GradeDimension) -> &'static str {
194    match dimension {
195        GradeDimension::Strength => "結合強度",
196        GradeDimension::Distance => "距離",
197        GradeDimension::Volatility => "変更頻度",
198    }
199}
200
201fn issue_type_japanese_label(issue_type: IssueType) -> &'static str {
202    match issue_type {
203        IssueType::GlobalComplexity => "グローバル複雑性",
204        IssueType::CascadingChangeRisk => "変更波及リスク",
205        IssueType::InappropriateIntimacy => "不適切な親密さ",
206        IssueType::HighEfferentCoupling => "出力依存過多",
207        IssueType::HighAfferentCoupling => "入力依存過多",
208        IssueType::UnnecessaryAbstraction => "過剰な抽象化",
209        IssueType::CircularDependency => "循環依存",
210        IssueType::HiddenCoupling => "隠れた結合",
211        IssueType::AccidentalVolatility => "偶発的な変更頻度",
212        IssueType::ScatteredExternalCoupling => "外部クレート結合の分散",
213        IssueType::ShallowModule => "浅いモジュール",
214        IssueType::PassThroughMethod => "パススルーメソッド",
215        IssueType::HighCognitiveLoad => "高認知負荷",
216        IssueType::GodModule => "神モジュール",
217        IssueType::PublicFieldExposure => "公開フィールド",
218        IssueType::PrimitiveObsession => "プリミティブ過多",
219    }
220}
221
222fn severity_weight(severity: Severity) -> usize {
223    match severity {
224        Severity::Critical => 4,
225        Severity::High => 3,
226        Severity::Medium => 2,
227        Severity::Low => 1,
228    }
229}
230
231fn dimension_for_issue(issue_type: IssueType) -> GradeDimension {
232    match issue_type {
233        IssueType::CascadingChangeRisk
234        | IssueType::HiddenCoupling
235        | IssueType::AccidentalVolatility => GradeDimension::Volatility,
236        IssueType::InappropriateIntimacy
237        | IssueType::PublicFieldExposure
238        | IssueType::PrimitiveObsession
239        | IssueType::ShallowModule
240        | IssueType::PassThroughMethod => GradeDimension::Strength,
241        IssueType::GlobalComplexity
242        | IssueType::ScatteredExternalCoupling
243        | IssueType::HighEfferentCoupling
244        | IssueType::HighAfferentCoupling
245        | IssueType::UnnecessaryAbstraction
246        | IssueType::CircularDependency
247        | IssueType::HighCognitiveLoad
248        | IssueType::GodModule => GradeDimension::Distance,
249    }
250}
251
252// ===== Health Grading =====
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum HealthGrade {
255    /// Over-optimized signal; the project may be chasing too little coupling.
256    S,
257    /// Coupling is appropriate for the architecture.
258    A,
259    /// Minor issues exist but remain manageable.
260    B,
261    /// Structural issues need planned improvement.
262    C,
263    /// Significant issues are affecting maintainability.
264    D,
265    /// Critical issues are blocking safe change.
266    F,
267}
268
269impl HealthGrade {
270    /// Single-letter representation (S, A, B, C, D, F).
271    ///
272    /// Used for compact, machine-readable output such as the history
273    /// timeline where the verbose `Display` form is too noisy.
274    pub fn letter(&self) -> char {
275        match self {
276            HealthGrade::S => 'S',
277            HealthGrade::A => 'A',
278            HealthGrade::B => 'B',
279            HealthGrade::C => 'C',
280            HealthGrade::D => 'D',
281            HealthGrade::F => 'F',
282        }
283    }
284}
285
286impl std::fmt::Display for HealthGrade {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            HealthGrade::S => write!(f, "S (Over-optimized! Real code has some issues. Ship it!)"),
290            HealthGrade::A => write!(f, "A (Well-balanced)"),
291            HealthGrade::B => write!(f, "B (Healthy)"),
292            HealthGrade::C => write!(f, "C (Room for improvement)"),
293            HealthGrade::D => write!(f, "D (Attention needed)"),
294            HealthGrade::F => write!(f, "F (Immediate action required)"),
295        }
296    }
297}
298
299/// Calculate health grade based on multiple quality factors
300///
301/// Unlike the previous version that only checked for issues,
302/// this version also considers positive quality indicators:
303/// - Contract coupling rate (trait usage)
304/// - Balance score distribution
305/// - Internal coupling complexity
306pub(crate) fn calculate_health_grade(
307    issues_by_severity: &HashMap<Severity, usize>,
308    internal_couplings: usize,
309) -> HealthGrade {
310    let critical = *issues_by_severity.get(&Severity::Critical).unwrap_or(&0);
311    let high = *issues_by_severity.get(&Severity::High).unwrap_or(&0);
312    let medium = *issues_by_severity.get(&Severity::Medium).unwrap_or(&0);
313
314    // No internal couplings = B (not A - we can't assess quality without data)
315    if internal_couplings == 0 {
316        return HealthGrade::B;
317    }
318
319    // F: Multiple critical issues
320    if critical > 3 {
321        return HealthGrade::F;
322    }
323
324    // Calculate issue density (issues per internal coupling)
325    let high_density = high as f64 / internal_couplings as f64;
326    let medium_density = medium as f64 / internal_couplings as f64;
327    let total_issue_density = (critical + high + medium) as f64 / internal_couplings as f64;
328
329    // D: Critical issues or very high issue density (> 5% high)
330    if critical > 0 || high_density > 0.05 {
331        return HealthGrade::D;
332    }
333
334    // C: Any high issues OR high medium density (> 25%)
335    // Projects with structural issues that need attention
336    if high > 0 || medium_density > 0.25 {
337        return HealthGrade::C;
338    }
339
340    // B: Some medium issues but manageable (> 10% medium density)
341    if medium_density > 0.10 || total_issue_density > 0.15 {
342        return HealthGrade::B;
343    }
344
345    // S: Over-optimized! Too few issues (< 5%) = you're probably over-engineering
346    // This is a WARNING, not a reward. Stop refactoring!
347    if high == 0 && medium_density <= 0.05 && internal_couplings >= 20 {
348        return HealthGrade::S;
349    }
350
351    // A: Well-balanced - no high issues AND reasonable medium issues (5-10%)
352    // This is the ideal target grade
353    if high == 0 && medium_density <= 0.10 && internal_couplings >= 10 {
354        return HealthGrade::A;
355    }
356
357    // Default to B for projects with few issues
358    HealthGrade::B
359}
360
361/// Complete project balance analysis report
362#[derive(Debug)]
363pub struct ProjectBalanceReport {
364    /// Total couplings considered in the report.
365    pub total_couplings: usize,
366    /// Number of internal couplings considered balanced or acceptable.
367    pub balanced_count: usize,
368    /// Number of internal couplings that need review.
369    pub needs_review: usize,
370    /// Number of internal couplings that need refactoring.
371    pub needs_refactoring: usize,
372    /// Average balance score across internal couplings.
373    pub average_score: f64,
374    /// Overall project health grade derived from issue density.
375    pub health_grade: HealthGrade,
376    /// Issue counts grouped by severity.
377    pub issues_by_severity: HashMap<Severity, usize>,
378    /// Issue counts grouped by issue type.
379    pub issues_by_type: HashMap<IssueType, usize>,
380    /// All detected issues after threshold and strict-mode filtering.
381    pub issues: Vec<CouplingIssue>,
382    /// Highest-priority issues selected for concise reporting.
383    pub top_priorities: Vec<CouplingIssue>,
384    /// Concise explanation of why the health grade was assigned.
385    pub grade_rationale: GradeRationale,
386}
387
388impl ProjectBalanceReport {
389    /// Add top N priority issues
390    pub(crate) fn with_top_priorities(mut self, n: usize) -> Self {
391        self.top_priorities = self.issues.iter().take(n).cloned().collect();
392        self
393    }
394
395    /// Get issues grouped by type
396    pub fn issues_grouped_by_type(&self) -> HashMap<IssueType, Vec<&CouplingIssue>> {
397        let mut grouped: HashMap<IssueType, Vec<&CouplingIssue>> = HashMap::new();
398        for issue in &self.issues {
399            grouped.entry(issue.issue_type).or_default().push(issue);
400        }
401        grouped
402    }
403}