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 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
244fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum HealthGrade {
309 S,
311 A,
313 B,
315 C,
317 D,
319 F,
321}
322
323impl HealthGrade {
324 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
353pub(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 if internal_couplings == 0 {
373 return HealthGrade::B;
374 }
375
376 if critical > 3 {
378 return HealthGrade::F;
379 }
380
381 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 if critical > 0 || high_density > 0.05 {
388 return HealthGrade::D;
389 }
390
391 if high > 0 || medium_density > 0.25 {
394 return HealthGrade::C;
395 }
396
397 if medium_density > 0.10 || total_issue_density > 0.15 {
399 return HealthGrade::B;
400 }
401
402 if high == 0 && medium_density <= 0.05 && internal_couplings >= 20 {
405 return HealthGrade::S;
406 }
407
408 if high == 0 && medium_density <= 0.10 && internal_couplings >= 10 {
411 return HealthGrade::A;
412 }
413
414 HealthGrade::B
416}
417
418#[derive(Debug)]
420pub struct ProjectBalanceReport {
421 pub total_couplings: usize,
423 pub balanced_count: usize,
425 pub needs_review: usize,
427 pub needs_refactoring: usize,
429 pub average_score: f64,
431 pub health_grade: HealthGrade,
433 pub issues_by_severity: HashMap<Severity, usize>,
435 pub issues_by_type: HashMap<IssueType, usize>,
437 pub issues: Vec<CouplingIssue>,
439 pub top_priorities: Vec<CouplingIssue>,
441 pub grade_rationale: GradeRationale,
443}
444
445impl ProjectBalanceReport {
446 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 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}