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