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