1use std::io::{self, Write};
7
8use crate::balance::action::RefactoringAction;
9use crate::balance::coupling::is_crate_root_facade;
10use crate::balance::grade::{HealthGrade, ProjectBalanceReport};
11use crate::balance::issue::CouplingIssue;
12use crate::balance::issue_type::IssueType;
13use crate::balance::project::analyze_project_balance_with_thresholds;
14use crate::balance::score::{BalanceInterpretation, BalanceScore, IssueThresholds};
15use crate::balance::severity::Severity;
16use crate::manifest::{AnalysisManifest, ManifestContext, build_manifest};
17use crate::metrics::dimensions::{Distance, IntegrationStrength};
18use crate::metrics::project::ProjectMetrics;
19use crate::volatility::TemporalCoupling;
20
21const DEFAULT_STRONG_TEMPORAL_LIMIT: usize = 5;
22
23#[derive(Debug, Clone, Copy, Default)]
27pub struct TextReportOptions {
28 pub show_structural_blind_spots: bool,
30 pub show_all_temporal_couplings: bool,
32}
33
34pub fn generate_summary<W: Write>(metrics: &ProjectMetrics, writer: &mut W) -> io::Result<()> {
38 let manifest = default_manifest();
39 generate_summary_with_thresholds(metrics, &IssueThresholds::default(), &manifest, writer)
40}
41
42pub fn generate_summary_with_thresholds<W: Write>(
44 metrics: &ProjectMetrics,
45 thresholds: &IssueThresholds,
46 manifest: &AnalysisManifest,
47 writer: &mut W,
48) -> io::Result<()> {
49 generate_summary_with_options(metrics, thresholds, manifest, false, writer)
50}
51
52pub fn generate_summary_with_options<W: Write>(
54 metrics: &ProjectMetrics,
55 thresholds: &IssueThresholds,
56 manifest: &AnalysisManifest,
57 show_structural_blind_spots: bool,
58 writer: &mut W,
59) -> io::Result<()> {
60 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
61 let dimension_stats = metrics.calculate_dimension_stats();
62 let jp = thresholds.japanese;
63
64 let project_name = metrics.workspace_name.as_deref().unwrap_or("project");
65
66 if jp {
67 writeln!(writer, "カップリング分析: {}", project_name)?;
68 writeln!(writer, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")?;
69 writeln!(writer)?;
70 writeln!(
71 writer,
72 "評価: {} | スコア: {:.2}/1.00 | モジュール数: {}",
73 report.health_grade,
74 report.average_score,
75 metrics.module_count()
76 )?;
77 writeln!(writer, "理由: {}", report.grade_rationale.summary)?;
78 } else {
79 writeln!(writer, "Balanced Coupling Analysis: {}", project_name)?;
80 writeln!(writer, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")?;
81 writeln!(writer)?;
82 writeln!(
83 writer,
84 "Grade: {} | Score: {:.2}/1.00 | Modules: {}",
85 report.health_grade,
86 report.average_score,
87 metrics.module_count()
88 )?;
89 writeln!(writer, "Why this grade: {}", report.grade_rationale.summary)?;
90 }
91 writeln!(writer)?;
92
93 if !metrics.couplings.is_empty() {
95 let (intr_pct, func_pct, model_pct, contract_pct) = dimension_stats.strength_percentages();
97 let (same_pct, diff_pct, ext_pct) = dimension_stats.distance_percentages();
99 let (low_pct, med_pct, high_pct) = dimension_stats.volatility_percentages();
101
102 if jp {
103 writeln!(writer, "3次元分析:")?;
104 writeln!(
105 writer,
106 " 結合強度: Contract {:.0}% / Model {:.0}% / Functional {:.0}% / Intrusive {:.0}%",
107 contract_pct, model_pct, func_pct, intr_pct
108 )?;
109 writeln!(
110 writer,
111 " (トレイト) (型) (関数) (内部アクセス)"
112 )?;
113 writeln!(
114 writer,
115 " 距離: 同一モジュール {:.0}% / 別モジュール {:.0}% / 外部 {:.0}%",
116 same_pct, diff_pct, ext_pct
117 )?;
118 writeln!(
119 writer,
120 " 変更頻度: 低 {:.0}% / 中 {:.0}% / 高 {:.0}%",
121 low_pct, med_pct, high_pct
122 )?;
123 } else {
124 writeln!(writer, "3-Dimensional Analysis:")?;
125 writeln!(
126 writer,
127 " Strength: Contract {:.0}% / Model {:.0}% / Functional {:.0}% / Intrusive {:.0}%",
128 contract_pct, model_pct, func_pct, intr_pct
129 )?;
130 writeln!(
131 writer,
132 " Distance: Same {:.0}% / Different {:.0}% / External {:.0}%",
133 same_pct, diff_pct, ext_pct
134 )?;
135 writeln!(
136 writer,
137 " Volatility: Low {:.0}% / Medium {:.0}% / High {:.0}%",
138 low_pct, med_pct, high_pct
139 )?;
140 }
141 writeln!(writer)?;
142
143 if jp {
145 writeln!(writer, "バランス状態:")?;
146 } else {
147 writeln!(writer, "Balance State:")?;
148 }
149 let bc = &dimension_stats.balance_counts;
150 let total = dimension_stats.total();
151 if bc.high_cohesion > 0 {
152 if jp {
153 writeln!(
154 writer,
155 " ✅ 高凝集 (強い結合 + 近い距離): {} ({:.0}%) ← 理想的",
156 bc.high_cohesion,
157 bc.high_cohesion as f64 / total as f64 * 100.0
158 )?;
159 } else {
160 writeln!(
161 writer,
162 " ✅ High Cohesion (strong+close): {} ({:.0}%)",
163 bc.high_cohesion,
164 bc.high_cohesion as f64 / total as f64 * 100.0
165 )?;
166 }
167 }
168 if bc.loose_coupling > 0 {
169 if jp {
170 writeln!(
171 writer,
172 " ✅ 疎結合 (弱い結合 + 遠い距離): {} ({:.0}%) ← 理想的",
173 bc.loose_coupling,
174 bc.loose_coupling as f64 / total as f64 * 100.0
175 )?;
176 } else {
177 writeln!(
178 writer,
179 " ✅ Loose Coupling (weak+far): {} ({:.0}%)",
180 bc.loose_coupling,
181 bc.loose_coupling as f64 / total as f64 * 100.0
182 )?;
183 }
184 }
185 if bc.acceptable > 0 {
186 if jp {
187 writeln!(
188 writer,
189 " 🤔 許容可能 (強い結合 + 遠い距離 + 安定): {} ({:.0}%)",
190 bc.acceptable,
191 bc.acceptable as f64 / total as f64 * 100.0
192 )?;
193 } else {
194 writeln!(
195 writer,
196 " 🤔 Acceptable (strong+far+stable): {} ({:.0}%)",
197 bc.acceptable,
198 bc.acceptable as f64 / total as f64 * 100.0
199 )?;
200 }
201 }
202 if bc.pain > 0 {
203 if jp {
204 writeln!(
205 writer,
206 " ❌ ペインゾーン (強い結合 + 遠い距離 + 頻繁に変更): {} ({:.0}%)",
207 bc.pain,
208 bc.pain as f64 / total as f64 * 100.0
209 )?;
210 } else {
211 writeln!(
212 writer,
213 " ❌ Pain Zone (strong+far+volatile): {} ({:.0}%)",
214 bc.pain,
215 bc.pain as f64 / total as f64 * 100.0
216 )?;
217 }
218 }
219 if bc.local_complexity > 0 {
220 if jp {
221 writeln!(
222 writer,
223 " 🔍 局所的複雑性 (弱い結合 + 近い距離): {} ({:.0}%)",
224 bc.local_complexity,
225 bc.local_complexity as f64 / total as f64 * 100.0
226 )?;
227 } else {
228 writeln!(
229 writer,
230 " 🔍 Local Complexity (weak+close): {} ({:.0}%)",
231 bc.local_complexity,
232 bc.local_complexity as f64 / total as f64 * 100.0
233 )?;
234 }
235 }
236 writeln!(writer)?;
237 }
238
239 let critical = *report
241 .issues_by_severity
242 .get(&Severity::Critical)
243 .unwrap_or(&0);
244 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
245 let medium = *report
246 .issues_by_severity
247 .get(&Severity::Medium)
248 .unwrap_or(&0);
249 let low = *report.issues_by_severity.get(&Severity::Low).unwrap_or(&0);
250
251 if critical > 0 || high > 0 || medium > 0 || low > 0 {
252 if jp {
253 writeln!(writer, "検出された問題:")?;
254 if critical > 0 {
255 writeln!(writer, " 🔴 緊急: {} 件 (すぐに修正が必要)", critical)?;
256 }
257 if high > 0 {
258 writeln!(writer, " 🟠 高: {} 件 (早めに対処)", high)?;
259 }
260 if medium > 0 {
261 writeln!(writer, " 🟡 中: {} 件", medium)?;
262 }
263 if low > 0 {
264 writeln!(writer, " ⚪ 低: {} 件", low)?;
265 }
266 } else {
267 writeln!(writer, "Detected Issues:")?;
268 if critical > 0 {
269 writeln!(writer, " 🔴 Critical: {} (must fix)", critical)?;
270 }
271 if high > 0 {
272 writeln!(writer, " 🟠 High: {} (should fix)", high)?;
273 }
274 if medium > 0 {
275 writeln!(writer, " 🟡 Medium: {}", medium)?;
276 }
277 if low > 0 {
278 writeln!(writer, " ⚪ Low: {}", low)?;
279 }
280 }
281 writeln!(writer)?;
282 } else if thresholds.strict_mode {
283 if jp {
284 writeln!(writer, "検出された問題: なし (--all で低優先度も表示)\n")?;
285 } else {
286 writeln!(
287 writer,
288 "Detected Issues: None (use --all to see Low severity)\n"
289 )?;
290 }
291 }
292
293 if !report.top_priorities.is_empty() {
295 if jp {
296 writeln!(writer, "優先的に対処すべき問題:")?;
297 for issue in report.top_priorities.iter().take(3) {
298 let issue_jp = issue_type_japanese(issue.issue_type);
299 writeln!(writer, " - {} | {}", issue_jp, issue.source)?;
300 writeln!(
301 writer,
302 " → {}",
303 refactoring_action_japanese(&issue.refactoring)
304 )?;
305 }
306 } else {
307 writeln!(writer, "Top Priorities:")?;
308 for issue in report.top_priorities.iter().take(3) {
309 writeln!(
310 writer,
311 " - [{}] {} → {}",
312 issue.severity, issue.source, issue.target
313 )?;
314 }
315 }
316 writeln!(writer)?;
317 }
318
319 let newtype_count = metrics.total_newtype_count();
321 let type_count = metrics.total_type_count();
322 if type_count > 0 {
323 let newtype_ratio = metrics.newtype_ratio() * 100.0;
324 if jp {
325 let quality = if newtype_ratio >= 20.0 {
326 "✅ 良好"
327 } else if newtype_ratio >= 10.0 {
328 "🤔 増やすことを検討"
329 } else {
330 "⚠️ 少ない"
331 };
332 writeln!(
333 writer,
334 "Rustパターン: newtype使用率 {}/{} ({:.0}%) - {}",
335 newtype_count, type_count, newtype_ratio, quality
336 )?;
337 } else {
338 let quality = if newtype_ratio >= 20.0 {
339 "✅ Good"
340 } else if newtype_ratio >= 10.0 {
341 "🤔 Consider more"
342 } else {
343 "⚠️ Low usage"
344 };
345 writeln!(
346 writer,
347 "Rust Patterns: Newtype usage: {}/{} ({:.0}%) - {}",
348 newtype_count, type_count, newtype_ratio, quality
349 )?;
350 }
351 writeln!(writer)?;
352 }
353
354 let circular = metrics.circular_dependency_summary();
356 if circular.total_cycles > 0 {
357 if jp {
358 writeln!(
359 writer,
360 "⚠️ 循環依存: {} 個の代表経路 ({} モジュール)",
361 circular.total_cycles, circular.affected_modules
362 )?;
363 } else {
364 writeln!(
365 writer,
366 "⚠️ Circular Dependencies: {} representative paths ({} modules)",
367 circular.total_cycles, circular.affected_modules
368 )?;
369 }
370 }
371
372 if jp {
374 writeln!(writer)?;
375 writeln!(writer, "設計判断ガイド (Khononov):")?;
376 writeln!(writer, " ✅ 強い結合 + 近い距離 → 高凝集 (理想的)")?;
377 writeln!(writer, " ✅ 弱い結合 + 遠い距離 → 疎結合 (理想的)")?;
378 writeln!(writer, " 🤔 強い結合 + 遠い距離 + 安定 → 許容可能")?;
379 writeln!(
380 writer,
381 " ❌ 強い結合 + 遠い距離 + 頻繁に変更 → 要リファクタリング"
382 )?;
383 }
384
385 write_manifest_summary_section(manifest, jp, show_structural_blind_spots, writer)?;
386
387 Ok(())
388}
389
390fn issue_type_japanese(issue_type: IssueType) -> &'static str {
394 use IssueType;
395 match issue_type {
396 IssueType::GlobalComplexity => "グローバル複雑性 (遠距離への強い依存)",
397 IssueType::CascadingChangeRisk => "変更波及リスク (頻繁に変わるものへの依存)",
398 IssueType::InappropriateIntimacy => "不適切な親密さ (内部実装への依存)",
399 IssueType::HighEfferentCoupling => "出力依存過多 (多くのモジュールに依存)",
400 IssueType::HighAfferentCoupling => "入力依存過多 (多くのモジュールから依存される)",
401 IssueType::UnnecessaryAbstraction => "過剰な抽象化",
402 IssueType::CircularDependency => "循環依存",
403 IssueType::HiddenCoupling => "隠れた結合 (共変更のみで発見)",
404 IssueType::AccidentalVolatility => "偶発的な変更頻度",
405 IssueType::ScatteredExternalCoupling => "外部クレート結合の分散",
406 IssueType::ShallowModule => "浅いモジュール",
407 IssueType::PassThroughMethod => "パススルーメソッド",
408 IssueType::HighCognitiveLoad => "高認知負荷",
409 IssueType::GodModule => "神モジュール (責務が多すぎる)",
410 IssueType::PublicFieldExposure => "公開フィールド (getterを検討)",
411 IssueType::PrimitiveObsession => "プリミティブ過多 (newtypeを検討)",
412 }
413}
414
415fn refactoring_action_japanese(action: &RefactoringAction) -> String {
417 use RefactoringAction;
418 match action {
419 RefactoringAction::IntroduceTrait { suggested_name, .. } => {
420 format!("トレイト `{}` を導入して抽象化する", suggested_name)
421 }
422 RefactoringAction::MoveCloser { target_location } => {
423 format!("`{}` に移動して距離を縮める", target_location)
424 }
425 RefactoringAction::ExtractAdapter { adapter_name, .. } => {
426 format!("アダプタ `{}` を抽出する", adapter_name)
427 }
428 RefactoringAction::SplitModule { suggested_modules } => {
429 format!("モジュールを分割: {}", suggested_modules.join(", "))
430 }
431 RefactoringAction::SimplifyAbstraction { .. } => "抽象化を簡素化する".to_string(),
432 RefactoringAction::BreakCycle {
433 suggested_direction,
434 } => {
435 format!("循環を断つ: {}", suggested_direction)
436 }
437 RefactoringAction::StabilizeInterface { interface_name } => {
438 format!("安定したインターフェース `{}` を追加", interface_name)
439 }
440 RefactoringAction::General { action } => {
441 if action.starts_with("Introduce a `") && action.contains("` facade/wrapper module") {
442 let facade = action.split('`').nth(1).unwrap_or("facade");
443 format!("`{}` モジュールを導入し、直接利用をそこに集約する", facade)
444 } else if action == "Extract a shared abstraction or make the dependency explicit" {
445 "共有された抽象化を抽出するか、依存関係を明示する".to_string()
446 } else {
447 action.clone()
448 }
449 }
450 RefactoringAction::AddGetters { .. } => "getterメソッドを追加する".to_string(),
451 RefactoringAction::IntroduceNewtype {
452 suggested_name,
453 wrapped_type,
454 } => {
455 format!(
456 "newtype `struct {}({})` を導入",
457 suggested_name, wrapped_type
458 )
459 }
460 }
461}
462
463fn issue_instance_description_japanese(issue: &CouplingIssue) -> String {
464 use IssueType;
465 match issue.issue_type {
466 IssueType::HiddenCoupling => {
467 "明示的なコード依存はありませんが、ファイルが頻繁に一緒に変更されています。暗黙の知識や不足した抽象化を示している可能性があります。"
468 .to_string()
469 }
470 IssueType::AccidentalVolatility => {
471 "安定しているはずのサブドメインが頻繁に変更されています。本質的な業務変化ではなく、設計や所有権の問題によるチャーンの可能性があります。"
472 .to_string()
473 }
474 IssueType::ScatteredExternalCoupling => {
475 format!(
476 "{} は複数の内部モジュールから直接使われています。サードパーティ更新時のリスクがコードベース全体に広がっています。",
477 issue.target
478 )
479 }
480 _ => issue.description.clone(),
481 }
482}
483
484pub fn generate_report<W: Write>(metrics: &ProjectMetrics, writer: &mut W) -> io::Result<()> {
488 let manifest = default_manifest();
489 generate_report_with_thresholds(metrics, &IssueThresholds::default(), &manifest, writer)
490}
491
492pub fn generate_report_with_thresholds<W: Write>(
494 metrics: &ProjectMetrics,
495 thresholds: &IssueThresholds,
496 manifest: &AnalysisManifest,
497 writer: &mut W,
498) -> io::Result<()> {
499 generate_report_with_options(
500 metrics,
501 thresholds,
502 manifest,
503 TextReportOptions::default(),
504 writer,
505 )
506}
507
508pub fn generate_report_with_options<W: Write>(
510 metrics: &ProjectMetrics,
511 thresholds: &IssueThresholds,
512 manifest: &AnalysisManifest,
513 options: TextReportOptions,
514 writer: &mut W,
515) -> io::Result<()> {
516 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
517
518 writeln!(writer, "# Coupling Analysis Report\n")?;
519
520 let jp = thresholds.japanese;
522 write_executive_summary(metrics, &report, jp, writer)?;
523
524 if !report.issues.is_empty() {
526 write_refactoring_priorities(&report, jp, writer)?;
527 }
528
529 write_issues_by_type(&report, jp, writer)?;
531
532 write_coupling_section(metrics, writer)?;
534
535 write_module_section(metrics, writer)?;
537
538 write_volatility_section(metrics, writer)?;
540
541 write_temporal_coupling_section(metrics, options.show_all_temporal_couplings, writer)?;
543
544 write_circular_dependencies_section(metrics, writer)?;
546
547 write_best_practices(writer)?;
549
550 write_manifest_markdown_section(manifest, options.show_structural_blind_spots, jp, writer)?;
552
553 Ok(())
554}
555
556fn write_executive_summary<W: Write>(
557 metrics: &ProjectMetrics,
558 report: &ProjectBalanceReport,
559 japanese: bool,
560 writer: &mut W,
561) -> io::Result<()> {
562 writeln!(writer, "## Executive Summary\n")?;
563
564 let grade_emoji = match report.health_grade {
566 HealthGrade::S => "⚠️",
567 HealthGrade::A => "🟢",
568 HealthGrade::B => "🟢",
569 HealthGrade::C => "🟡",
570 HealthGrade::D => "🟠",
571 HealthGrade::F => "🔴",
572 };
573
574 writeln!(
575 writer,
576 "**Health Grade**: {} {}\n",
577 grade_emoji, report.health_grade
578 )?;
579 if japanese {
580 writeln!(
581 writer,
582 "**このグレードの理由**: {}\n",
583 report.grade_rationale.summary
584 )?;
585 } else {
586 writeln!(
587 writer,
588 "**Why this grade**: {}\n",
589 report.grade_rationale.summary
590 )?;
591 }
592
593 writeln!(writer, "| Metric | Value |")?;
594 writeln!(writer, "|--------|-------|")?;
595 writeln!(writer, "| Files Analyzed | {} |", metrics.total_files)?;
596 writeln!(writer, "| Total Modules | {} |", metrics.module_count())?;
597 writeln!(writer, "| Total Couplings | {} |", report.total_couplings)?;
598 writeln!(
599 writer,
600 "| Balance Score | {:.2}/1.00 |",
601 report.average_score
602 )?;
603 writeln!(
604 writer,
605 "| Balanced | {} ({:.0}%) |",
606 report.balanced_count,
607 if report.total_couplings > 0 {
608 (report.balanced_count as f64 / report.total_couplings as f64) * 100.0
609 } else {
610 100.0
611 }
612 )?;
613 writeln!(writer, "| Issues Surfaced | {} |", report.issues.len())?;
616 writeln!(writer)?;
617
618 let critical = *report
620 .issues_by_severity
621 .get(&Severity::Critical)
622 .unwrap_or(&0);
623 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
624 let medium = *report
625 .issues_by_severity
626 .get(&Severity::Medium)
627 .unwrap_or(&0);
628 let low = *report.issues_by_severity.get(&Severity::Low).unwrap_or(&0);
629
630 if critical > 0 || high > 0 {
631 writeln!(writer, "**⚠️ Action Required**\n")?;
632 if critical > 0 {
633 writeln!(
634 writer,
635 "- 🔴 **{} Critical** issues must be fixed immediately",
636 critical
637 )?;
638 }
639 if high > 0 {
640 writeln!(
641 writer,
642 "- 🟠 **{} High** priority issues should be addressed soon",
643 high
644 )?;
645 }
646 if medium > 0 {
647 writeln!(writer, "- 🟡 {} Medium priority issues to review", medium)?;
648 }
649 if low > 0 {
650 writeln!(writer, "- {} Low priority suggestions", low)?;
651 }
652 writeln!(writer)?;
653 } else if medium > 0 {
654 writeln!(
655 writer,
656 "**ℹ️ Review Suggested**: {} issues to consider.\n",
657 medium + low
658 )?;
659 } else {
660 writeln!(
661 writer,
662 "**✅ Good Health**: No significant coupling issues detected.\n"
663 )?;
664 }
665
666 Ok(())
667}
668
669fn write_refactoring_priorities<W: Write>(
670 report: &ProjectBalanceReport,
671 japanese: bool,
672 writer: &mut W,
673) -> io::Result<()> {
674 writeln!(writer, "## 🔧 Refactoring Priorities\n")?;
675
676 writeln!(writer, "### Immediate Actions\n")?;
678
679 let mut seen_priorities = std::collections::HashSet::new();
682 let priority_issues: Vec<_> = report
683 .issues
684 .iter()
685 .filter(|i| i.severity >= Severity::Medium)
686 .filter(|i| seen_priorities.insert((i.issue_type, i.source.clone(), i.target.clone())))
687 .take(5)
688 .collect();
689
690 if priority_issues.is_empty() {
691 writeln!(writer, "No immediate refactoring actions required.\n")?;
692 return Ok(());
693 }
694
695 for (i, issue) in priority_issues.iter().enumerate() {
696 let severity_icon = match issue.severity {
697 Severity::Critical => "🔴",
698 Severity::High => "🟠",
699 Severity::Medium => "🟡",
700 Severity::Low => "⚪",
701 };
702
703 writeln!(
704 writer,
705 "**{}. {} `{}` → `{}`**\n",
706 i + 1,
707 severity_icon,
708 issue.source,
709 issue.target
710 )?;
711
712 let issue_label = if japanese {
713 issue_type_japanese(issue.issue_type).to_string()
714 } else {
715 issue.issue_type.to_string()
716 };
717 let issue_description = if japanese {
718 issue_instance_description_japanese(issue)
719 } else {
720 issue.description.clone()
721 };
722 writeln!(
723 writer,
724 "- **Issue**: {} - {}",
725 issue_label, issue_description
726 )?;
727 if japanese {
728 writeln!(
729 writer,
730 "- **Why**: {}",
731 issue.issue_type.description_japanese()
732 )?;
733 writeln!(
734 writer,
735 "- **Action**: {}",
736 refactoring_action_japanese(&issue.refactoring)
737 )?;
738 } else {
739 writeln!(writer, "- **Why**: {}", issue.issue_type.description())?;
740 writeln!(writer, "- **Action**: {}", issue.refactoring)?;
741 }
742 writeln!(writer, "- **Balance Score**: {:.2}\n", issue.balance_score)?;
743 }
744
745 Ok(())
746}
747
748fn issue_is_volatility_driven(issue: &CouplingIssue) -> bool {
750 match issue.issue_type {
751 IssueType::AccidentalVolatility => true,
752 IssueType::CascadingChangeRisk => issue.description.contains("accidental volatility"),
753 _ => false,
754 }
755}
756
757fn write_issue_triage<W: Write>(
760 report: &ProjectBalanceReport,
761 japanese: bool,
762 writer: &mut W,
763) -> io::Result<()> {
764 let (volatility, structural): (Vec<_>, Vec<_>) = report
765 .issues
766 .iter()
767 .partition(|i| issue_is_volatility_driven(i));
768
769 let label = |i: &CouplingIssue| -> String {
770 let name = if japanese {
771 issue_type_japanese(i.issue_type)
772 } else {
773 return format!("**{}** `{}` → `{}`", i.issue_type, i.source, i.target);
775 };
776 format!("**{}** `{}` → `{}`", name, i.source, i.target)
777 };
778
779 if japanese {
780 writeln!(writer, "## 課題のトリアージ\n")?;
781 writeln!(
782 writer,
783 "### 構造的な課題 — 今すぐ対応 ({} 件)\n",
784 structural.len()
785 )?;
786 for i in &structural {
787 writeln!(writer, "- {}", label(i))?;
788 }
789 writeln!(
790 writer,
791 "\n### 変更頻度由来 — 落ち着く可能性あり ({} 件)\n",
792 volatility.len()
793 )?;
794 for i in &volatility {
795 writeln!(writer, "- {}", label(i))?;
796 }
797 writeln!(
798 writer,
799 "\n> エントリポイントの広い依存や安定した中心モジュールなど、設計上想定される項目は重大度を下げて一覧から除外しています。\n"
800 )?;
801 } else {
802 writeln!(writer, "## Issue Triage\n")?;
803 writeln!(writer, "### Structural — act now ({})\n", structural.len())?;
804 for i in &structural {
805 writeln!(writer, "- {}", label(i))?;
806 }
807 writeln!(
808 writer,
809 "\n### Volatility-driven — may settle ({})\n",
810 volatility.len()
811 )?;
812 for i in &volatility {
813 writeln!(writer, "- {}", label(i))?;
814 }
815 writeln!(
816 writer,
817 "\n> Expected-by-design patterns (entrypoint fan-out, stable central abstractions) are downgraded and omitted here.\n"
818 )?;
819 }
820
821 Ok(())
822}
823
824fn write_issues_by_type<W: Write>(
825 report: &ProjectBalanceReport,
826 japanese: bool,
827 writer: &mut W,
828) -> io::Result<()> {
829 if report.issues.is_empty() {
830 return Ok(());
831 }
832
833 write_issue_triage(report, japanese, writer)?;
834
835 writeln!(writer, "## Issues by Category\n")?;
836
837 let grouped = report.issues_grouped_by_type();
838
839 let mut issue_types: Vec<_> = grouped.keys().collect();
841 issue_types.sort_by(|a, b| {
842 let a_max = grouped
843 .get(a)
844 .and_then(|v| v.iter().map(|i| i.severity).max());
845 let b_max = grouped
846 .get(b)
847 .and_then(|v| v.iter().map(|i| i.severity).max());
848 b_max
849 .cmp(&a_max)
850 .then_with(|| a.to_string().cmp(&b.to_string()))
851 });
852
853 for issue_type in issue_types {
854 if let Some(issues) = grouped.get(issue_type) {
855 let count = issues.len();
856
857 let issue_label = if japanese {
858 issue_type_japanese(*issue_type)
859 } else {
860 ""
861 };
862 if japanese {
863 writeln!(writer, "### {} ({} 件)\n", issue_label, count)?;
864 writeln!(writer, "> {}\n", issue_type.description_japanese())?;
865 } else {
866 writeln!(writer, "### {} ({} instances)\n", issue_type, count)?;
867 writeln!(writer, "> {}\n", issue_type.description())?;
868 }
869
870 writeln!(writer, "| Severity | Source | Target | Action |")?;
872 writeln!(writer, "|----------|--------|--------|--------|")?;
873
874 for issue in issues.iter().take(5) {
875 let action_short = if japanese {
876 refactoring_action_japanese(&issue.refactoring)
877 } else {
878 issue.refactoring.to_string()
879 };
880 let action_truncated = truncate_chars(&action_short, 40);
881 writeln!(
882 writer,
883 "| {} | `{}` | `{}` | {} |",
884 issue.severity,
885 truncate_path(&issue.source, 25),
886 truncate_path(&issue.target, 25),
887 action_truncated
888 )?;
889 }
890
891 if count > 5 {
892 writeln!(writer, "\n*...and {} more instances*", count - 5)?;
893 }
894 writeln!(writer)?;
895 }
896 }
897
898 Ok(())
899}
900
901fn write_coupling_section<W: Write>(metrics: &ProjectMetrics, writer: &mut W) -> io::Result<()> {
902 if metrics.couplings.is_empty() {
903 return Ok(());
904 }
905
906 writeln!(writer, "## Coupling Distribution\n")?;
907
908 writeln!(writer, "### By Integration Strength\n")?;
910 writeln!(writer, "| Strength | Count | % | Description |")?;
911 writeln!(writer, "|----------|-------|---|-------------|")?;
912
913 let total = metrics.couplings.len() as f64;
914 for (strength, label, desc) in [
915 (
916 IntegrationStrength::Contract,
917 "Contract",
918 "Depends on traits/interfaces only",
919 ),
920 (
921 IntegrationStrength::Model,
922 "Model",
923 "Uses data types/structs",
924 ),
925 (
926 IntegrationStrength::Functional,
927 "Functional",
928 "Calls specific functions",
929 ),
930 (
931 IntegrationStrength::Intrusive,
932 "Intrusive",
933 "Accesses internal details",
934 ),
935 ] {
936 let count = metrics
937 .couplings
938 .iter()
939 .filter(|c| c.strength == strength)
940 .count();
941 let pct = (count as f64 / total) * 100.0;
942 writeln!(writer, "| {} | {} | {:.0}% | {} |", label, count, pct, desc)?;
943 }
944 writeln!(writer)?;
945
946 writeln!(writer, "### By Distance\n")?;
948 writeln!(writer, "| Distance | Count | % |")?;
949 writeln!(writer, "|----------|-------|---|")?;
950
951 for (distance, label) in [
952 (Distance::SameModule, "Same Module (close)"),
953 (Distance::DifferentModule, "Different Module"),
954 (Distance::DifferentCrate, "External Crate (far)"),
955 ] {
956 let count = metrics
957 .couplings
958 .iter()
959 .filter(|c| c.distance == distance)
960 .count();
961 let pct = (count as f64 / total) * 100.0;
962 writeln!(writer, "| {} | {} | {:.0}% |", label, count, pct)?;
963 }
964 writeln!(writer)?;
965
966 let internal_couplings: Vec<_> = metrics
968 .couplings
969 .iter()
970 .filter(|c| c.distance != Distance::DifferentCrate)
971 .collect();
972
973 if !internal_couplings.is_empty() {
974 let internal_total = internal_couplings.len() as f64;
975 writeln!(writer, "### By Volatility (Internal Couplings)\n")?;
976 writeln!(writer, "| Volatility | Count | % | Impact on Balance |")?;
977 writeln!(writer, "|------------|-------|---|-------------------|")?;
978
979 for (volatility, label, impact) in [
980 (
981 crate::volatility::Volatility::Low,
982 "Low (rarely changes)",
983 "No penalty",
984 ),
985 (
986 crate::volatility::Volatility::Medium,
987 "Medium (sometimes changes)",
988 "Moderate penalty",
989 ),
990 (
991 crate::volatility::Volatility::High,
992 "High (frequently changes)",
993 "Significant penalty",
994 ),
995 ] {
996 let count = internal_couplings
997 .iter()
998 .filter(|c| c.volatility == volatility)
999 .count();
1000 let pct = (count as f64 / internal_total) * 100.0;
1001 writeln!(
1002 writer,
1003 "| {} | {} | {:.0}% | {} |",
1004 label, count, pct, impact
1005 )?;
1006 }
1007 writeln!(writer)?;
1008 }
1009
1010 writeln!(writer, "### Worst Balanced Couplings\n")?;
1012
1013 let mut worst_by_pair = std::collections::BTreeMap::new();
1019 for coupling in metrics
1020 .couplings
1021 .iter()
1022 .filter(|c| c.distance != Distance::DifferentCrate)
1023 .filter(|c| !is_crate_root_facade(&c.target))
1024 {
1025 let score = BalanceScore::calculate(coupling);
1026 let key = (coupling.source.as_str(), coupling.target.as_str());
1027 match worst_by_pair.entry(key) {
1028 std::collections::btree_map::Entry::Vacant(entry) => {
1029 entry.insert((coupling, score));
1030 }
1031 std::collections::btree_map::Entry::Occupied(mut entry) => {
1032 let (selected, selected_score) = entry.get();
1033 if score.score.total_cmp(&selected_score.score).is_lt()
1034 || (score.score.total_cmp(&selected_score.score).is_eq()
1035 && coupling_dimension_tie_order(coupling)
1036 < coupling_dimension_tie_order(selected))
1037 {
1038 entry.insert((coupling, score));
1039 }
1040 }
1041 }
1042 }
1043 let mut couplings_with_scores: Vec<_> = worst_by_pair.into_values().collect();
1044
1045 couplings_with_scores.sort_by(|a, b| {
1046 a.1.score
1047 .partial_cmp(&b.1.score)
1048 .unwrap_or(std::cmp::Ordering::Equal)
1049 .then_with(|| a.0.source.cmp(&b.0.source))
1050 .then_with(|| a.0.target.cmp(&b.0.target))
1051 });
1052
1053 writeln!(
1054 writer,
1055 "| Source | Target | Strength | Distance | Volatility | Score | Status |"
1056 )?;
1057 writeln!(
1058 writer,
1059 "|--------|--------|----------|----------|------------|-------|--------|"
1060 )?;
1061
1062 for (coupling, score) in couplings_with_scores.iter().take(15) {
1063 let strength_str = match coupling.strength {
1064 IntegrationStrength::Contract => "Contract",
1065 IntegrationStrength::Model => "Model",
1066 IntegrationStrength::Functional => "Functional",
1067 IntegrationStrength::Intrusive => "Intrusive",
1068 };
1069 let distance_str = match coupling.distance {
1070 Distance::SameFunction => "Same Fn",
1071 Distance::SameModule => "Same Mod",
1072 Distance::DifferentModule => "Diff Mod",
1073 Distance::DifferentCrate => "External",
1074 };
1075 let volatility_str = match coupling.volatility {
1076 crate::volatility::Volatility::Low => "Low",
1077 crate::volatility::Volatility::Medium => "Med",
1078 crate::volatility::Volatility::High => "High",
1079 };
1080 let status = match score.interpretation {
1081 BalanceInterpretation::Balanced => "✅ Balanced",
1082 BalanceInterpretation::Acceptable => "✅ OK",
1083 BalanceInterpretation::NeedsReview => "🟡 Review",
1084 BalanceInterpretation::NeedsRefactoring => "🟠 Refactor",
1085 BalanceInterpretation::Critical => "🔴 Critical",
1086 };
1087
1088 writeln!(
1089 writer,
1090 "| `{}` | `{}` | {} | {} | {} | {:.2} | {} |",
1091 truncate_path(&coupling.source, 20),
1092 truncate_path(&coupling.target, 20),
1093 strength_str,
1094 distance_str,
1095 volatility_str,
1096 score.score,
1097 status
1098 )?;
1099 }
1100
1101 if couplings_with_scores.len() > 15 {
1102 writeln!(
1103 writer,
1104 "\n*Showing 15 of {} couplings*",
1105 couplings_with_scores.len()
1106 )?;
1107 }
1108 writeln!(writer)?;
1109
1110 Ok(())
1111}
1112
1113fn coupling_dimension_tie_order(
1114 coupling: &crate::metrics::coupling::CouplingMetrics,
1115) -> (u8, u8, u8) {
1116 let strength = match coupling.strength {
1117 IntegrationStrength::Intrusive => 0,
1118 IntegrationStrength::Functional => 1,
1119 IntegrationStrength::Model => 2,
1120 IntegrationStrength::Contract => 3,
1121 };
1122 let distance = match coupling.distance {
1123 Distance::DifferentCrate => 0,
1124 Distance::DifferentModule => 1,
1125 Distance::SameModule => 2,
1126 Distance::SameFunction => 3,
1127 };
1128 let volatility = match coupling.volatility {
1129 crate::volatility::Volatility::High => 0,
1130 crate::volatility::Volatility::Medium => 1,
1131 crate::volatility::Volatility::Low => 2,
1132 };
1133 (strength, distance, volatility)
1134}
1135
1136fn write_module_section<W: Write>(metrics: &ProjectMetrics, writer: &mut W) -> io::Result<()> {
1137 if metrics.modules.is_empty() {
1138 return Ok(());
1139 }
1140
1141 writeln!(writer, "## Module Statistics\n")?;
1142
1143 let show_subdomain = metrics
1144 .modules
1145 .values()
1146 .any(|module| module.subdomain.is_some());
1147 if show_subdomain {
1148 writeln!(
1149 writer,
1150 "| Module | Subdomain | Trait Impl | Inherent Impl | Internal Deps | External Deps |"
1151 )?;
1152 writeln!(
1153 writer,
1154 "|--------|-----------|------------|---------------|---------------|---------------|"
1155 )?;
1156 } else {
1157 writeln!(
1158 writer,
1159 "| Module | Trait Impl | Inherent Impl | Internal Deps | External Deps |"
1160 )?;
1161 writeln!(
1162 writer,
1163 "|--------|------------|---------------|---------------|---------------|"
1164 )?;
1165 }
1166
1167 let mut modules: Vec<_> = metrics.modules.iter().collect();
1168 modules.sort_by(|a, b| {
1169 let a_deps = a.1.internal_deps.len() + a.1.external_deps.len();
1170 let b_deps = b.1.internal_deps.len() + b.1.external_deps.len();
1171 b_deps.cmp(&a_deps).then_with(|| a.0.cmp(b.0))
1172 });
1173
1174 for (name, module) in modules.iter().take(20) {
1175 if show_subdomain {
1176 writeln!(
1177 writer,
1178 "| `{}` | {} | {} | {} | {} | {} |",
1179 truncate_path(name, 30),
1180 module
1181 .subdomain
1182 .map(|subdomain| subdomain.to_string())
1183 .unwrap_or_else(|| "-".to_string()),
1184 module.trait_impl_count,
1185 module.inherent_impl_count,
1186 module.internal_deps.len(),
1187 module.external_deps.len()
1188 )?;
1189 } else {
1190 writeln!(
1191 writer,
1192 "| `{}` | {} | {} | {} | {} |",
1193 truncate_path(name, 30),
1194 module.trait_impl_count,
1195 module.inherent_impl_count,
1196 module.internal_deps.len(),
1197 module.external_deps.len()
1198 )?;
1199 }
1200 }
1201
1202 if modules.len() > 20 {
1203 writeln!(writer, "\n*Showing top 20 of {} modules*", modules.len())?;
1204 }
1205 writeln!(writer)?;
1206
1207 Ok(())
1208}
1209
1210fn write_volatility_section<W: Write>(metrics: &ProjectMetrics, writer: &mut W) -> io::Result<()> {
1211 writeln!(writer, "## Volatility Analysis\n")?;
1212
1213 if metrics.file_changes.is_empty() {
1214 writeln!(
1215 writer,
1216 "*Git history analysis not available. Run in a git repository for volatility data.*\n"
1217 )?;
1218 return Ok(());
1219 }
1220
1221 let mut high_vol: Vec<_> = metrics
1222 .file_changes
1223 .iter()
1224 .filter(|&(_, count)| *count > 10)
1225 .collect();
1226
1227 high_vol.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
1228
1229 if high_vol.is_empty() {
1230 writeln!(
1231 writer,
1232 "No high volatility files detected (threshold: >10 changes).\n"
1233 )?;
1234 } else {
1235 writeln!(writer, "### High Volatility Files\n")?;
1236 writeln!(
1237 writer,
1238 "⚠️ Strong coupling to these files increases cascading change risk.\n"
1239 )?;
1240 writeln!(writer, "| File | Changes |")?;
1241 writeln!(writer, "|------|---------|")?;
1242 for (file, count) in high_vol.iter().take(10) {
1243 writeln!(writer, "| `{}` | {} |", file, count)?;
1244 }
1245 writeln!(writer)?;
1246 }
1247
1248 Ok(())
1249}
1250
1251fn write_temporal_coupling_section<W: Write>(
1252 metrics: &ProjectMetrics,
1253 show_all: bool,
1254 writer: &mut W,
1255) -> io::Result<()> {
1256 if metrics.temporal_couplings.is_empty() {
1257 return Ok(());
1258 }
1259
1260 let mut strong: Vec<_> = metrics
1261 .temporal_couplings
1262 .iter()
1263 .filter(|tc| tc.is_strong())
1264 .collect();
1265 strong.sort_by(|a, b| compare_temporal_couplings(a, b));
1266
1267 if !show_all && strong.is_empty() {
1268 return Ok(());
1269 }
1270
1271 writeln!(writer, "## Temporal Coupling (Co-Change Analysis)\n")?;
1272 writeln!(
1273 writer,
1274 "Files that frequently change together in git commits, indicating implicit coupling"
1275 )?;
1276 writeln!(writer, "beyond what code structure reveals.\n")?;
1277
1278 if !strong.is_empty() {
1279 writeln!(
1280 writer,
1281 "### Strong Temporal Coupling (>50% co-change ratio)\n"
1282 )?;
1283 writeln!(
1284 writer,
1285 "⚠️ These pairs may share implicit knowledge (business logic, assumptions, data formats).\n"
1286 )?;
1287 writeln!(writer, "| File A | File B | Co-changes | Ratio |")?;
1288 writeln!(writer, "|--------|--------|------------|-------|")?;
1289 let strong_limit = if show_all {
1290 strong.len()
1291 } else {
1292 DEFAULT_STRONG_TEMPORAL_LIMIT
1293 };
1294 for tc in strong.iter().take(strong_limit) {
1295 writeln!(
1296 writer,
1297 "| `{}` | `{}` | {} | {:.0}% |",
1298 tc.file_a,
1299 tc.file_b,
1300 tc.co_change_count,
1301 tc.coupling_ratio * 100.0
1302 )?;
1303 }
1304 if !show_all && strong.len() > strong_limit {
1305 writeln!(
1306 writer,
1307 "\n*... and {} more (use --all)*",
1308 strong.len() - strong_limit
1309 )?;
1310 }
1311 writeln!(writer)?;
1312 }
1313
1314 if !show_all {
1315 return Ok(());
1316 }
1317
1318 let mut moderate: Vec<_> = metrics
1319 .temporal_couplings
1320 .iter()
1321 .filter(|tc| !tc.is_strong())
1322 .collect();
1323 moderate.sort_by(|a, b| compare_temporal_couplings(a, b));
1324
1325 if !moderate.is_empty() {
1326 writeln!(writer, "### Moderate Temporal Coupling\n")?;
1327 writeln!(writer, "| File A | File B | Co-changes | Ratio |")?;
1328 writeln!(writer, "|--------|--------|------------|-------|")?;
1329 for tc in moderate {
1330 writeln!(
1331 writer,
1332 "| `{}` | `{}` | {} | {:.0}% |",
1333 tc.file_a,
1334 tc.file_b,
1335 tc.co_change_count,
1336 tc.coupling_ratio * 100.0
1337 )?;
1338 }
1339 writeln!(writer)?;
1340 }
1341
1342 Ok(())
1343}
1344
1345fn compare_temporal_couplings(a: &TemporalCoupling, b: &TemporalCoupling) -> std::cmp::Ordering {
1346 b.coupling_ratio
1347 .partial_cmp(&a.coupling_ratio)
1348 .unwrap_or(std::cmp::Ordering::Equal)
1349 .then_with(|| b.co_change_count.cmp(&a.co_change_count))
1350 .then_with(|| a.file_a.cmp(&b.file_a))
1351 .then_with(|| a.file_b.cmp(&b.file_b))
1352}
1353
1354fn write_circular_dependencies_section<W: Write>(
1355 metrics: &ProjectMetrics,
1356 writer: &mut W,
1357) -> io::Result<()> {
1358 let summary = metrics.circular_dependency_summary();
1359
1360 if summary.total_cycles == 0 {
1361 writeln!(writer, "## Circular Dependencies\n")?;
1362 writeln!(writer, "✅ No circular dependencies detected.\n")?;
1363 return Ok(());
1364 }
1365
1366 writeln!(writer, "## ⚠️ Circular Dependencies\n")?;
1367 writeln!(
1368 writer,
1369 "Found circular dependencies involving **{} modules**. Showing **{} representative cycle path(s)** that cover every multi-module cyclic edge.\n",
1370 summary.affected_modules, summary.total_cycles
1371 )?;
1372
1373 writeln!(
1374 writer,
1375 "Circular dependencies make code harder to understand, test, and maintain."
1376 )?;
1377 writeln!(writer, "Consider breaking cycles by:\n")?;
1378 writeln!(writer, "1. Extracting shared types into a separate module")?;
1379 writeln!(writer, "2. Inverting dependencies using traits/interfaces")?;
1380 writeln!(writer, "3. Moving functionality to reduce coupling\n")?;
1381
1382 writeln!(writer, "### Representative Cycle Paths\n")?;
1383
1384 for (i, cycle) in summary.cycles.iter().take(10).enumerate() {
1385 let cycle_str = cycle.join(" → ");
1386 writeln!(
1387 writer,
1388 "{}. `{}` → `{}`",
1389 i + 1,
1390 cycle_str,
1391 cycle.first().unwrap_or(&"?".to_string())
1392 )?;
1393 }
1394
1395 if summary.cycles.len() > 10 {
1396 writeln!(
1397 writer,
1398 "\n*...and {} more representative paths*",
1399 summary.cycles.len() - 10
1400 )?;
1401 }
1402 writeln!(writer)?;
1403
1404 Ok(())
1405}
1406
1407fn write_best_practices<W: Write>(writer: &mut W) -> io::Result<()> {
1408 writeln!(writer, "## Balance Guidelines\n")?;
1409
1410 writeln!(
1411 writer,
1412 "The goal is **balanced coupling**, not zero coupling.\n"
1413 )?;
1414
1415 writeln!(writer, "### Ideal Patterns ✅\n")?;
1416 writeln!(writer, "| Pattern | Example | Why It Works |")?;
1417 writeln!(writer, "|---------|---------|--------------|")?;
1418 writeln!(
1419 writer,
1420 "| Strong + Close | `impl` blocks in same module | Cohesion within boundaries |"
1421 )?;
1422 writeln!(
1423 writer,
1424 "| Weak + Far | Trait impl for external crate | Loose coupling across boundaries |"
1425 )?;
1426 writeln!(writer)?;
1427
1428 writeln!(writer, "### Problematic Patterns ❌\n")?;
1429 writeln!(writer, "| Pattern | Problem | Solution |")?;
1430 writeln!(writer, "|---------|---------|----------|")?;
1431 writeln!(
1432 writer,
1433 "| Strong + Far | Global complexity | Introduce adapter or move closer |"
1434 )?;
1435 writeln!(
1436 writer,
1437 "| Strong + Volatile | Cascading changes | Add stable interface |"
1438 )?;
1439 writeln!(
1440 writer,
1441 "| Intrusive + Cross-boundary | Encapsulation violation | Extract trait API |"
1442 )?;
1443 writeln!(writer)?;
1444
1445 Ok(())
1446}
1447
1448fn truncate_path(path: &str, max_len: usize) -> String {
1449 if path.len() <= max_len {
1450 path.to_string()
1451 } else {
1452 format!("...{}", &path[path.len() - max_len + 3..])
1453 }
1454}
1455
1456fn truncate_chars(text: &str, max_chars: usize) -> String {
1457 if text.chars().count() <= max_chars {
1458 text.to_string()
1459 } else {
1460 let prefix: String = text.chars().take(max_chars).collect();
1461 format!("{}...", prefix)
1462 }
1463}
1464
1465pub fn generate_ai_output<W: Write>(metrics: &ProjectMetrics, writer: &mut W) -> io::Result<()> {
1474 let manifest = default_manifest();
1475 generate_ai_output_with_thresholds(metrics, &IssueThresholds::default(), &manifest, writer)
1476}
1477
1478pub fn generate_ai_output_with_thresholds<W: Write>(
1480 metrics: &ProjectMetrics,
1481 thresholds: &IssueThresholds,
1482 manifest: &AnalysisManifest,
1483 writer: &mut W,
1484) -> io::Result<()> {
1485 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
1486
1487 let project_name = metrics.workspace_name.as_deref().unwrap_or("project");
1488 writeln!(writer, "Coupling Issues in {}:", project_name)?;
1489 writeln!(
1490 writer,
1491 "────────────────────────────────────────────────────────────"
1492 )?;
1493 writeln!(writer)?;
1494
1495 writeln!(
1497 writer,
1498 "Grade: {} | Score: {:.2} | Issues: {} High, {} Medium",
1499 report.health_grade,
1500 report.average_score,
1501 report.issues_by_severity.get(&Severity::High).unwrap_or(&0),
1502 report
1503 .issues_by_severity
1504 .get(&Severity::Medium)
1505 .unwrap_or(&0)
1506 )?;
1507 writeln!(writer, "Why this grade: {}", report.grade_rationale.summary)?;
1508 writeln!(writer)?;
1509
1510 if report.issues.is_empty() {
1512 writeln!(writer, "✅ No coupling issues detected.")?;
1513 writeln!(writer)?;
1514 } else {
1515 writeln!(writer, "Issues:")?;
1516 writeln!(writer)?;
1517
1518 for (i, issue) in report.issues.iter().take(10).enumerate() {
1519 let severity_marker = match issue.severity {
1520 Severity::Critical => "🔴",
1521 Severity::High => "🟠",
1522 Severity::Medium => "🟡",
1523 Severity::Low => "⚪",
1524 };
1525
1526 writeln!(
1527 writer,
1528 "{}. {} {} → {}",
1529 i + 1,
1530 severity_marker,
1531 issue.source,
1532 issue.target
1533 )?;
1534 writeln!(writer, " Type: {}", issue.issue_type)?;
1535 writeln!(writer, " Problem: {}", issue.description)?;
1536 writeln!(writer, " Fix: {}", issue.refactoring)?;
1537 writeln!(writer)?;
1538 }
1539
1540 if report.issues.len() > 10 {
1541 writeln!(writer, "... and {} more issues", report.issues.len() - 10)?;
1542 writeln!(writer)?;
1543 }
1544 }
1545
1546 let circular = metrics.circular_dependency_summary();
1548 if circular.total_cycles > 0 {
1549 writeln!(
1550 writer,
1551 "Circular Dependencies ({} representative paths):",
1552 circular.total_cycles
1553 )?;
1554 for cycle in circular.cycles.iter().take(5) {
1555 writeln!(
1556 writer,
1557 " {} → {}",
1558 cycle.join(" → "),
1559 cycle.first().unwrap_or(&"?".to_string())
1560 )?;
1561 }
1562 writeln!(writer)?;
1563 }
1564
1565 let mut strong_temporal: Vec<_> = metrics
1567 .temporal_couplings
1568 .iter()
1569 .filter(|tc| tc.is_strong())
1570 .collect();
1571 strong_temporal.sort_by(|a, b| compare_temporal_couplings(a, b));
1572 if !strong_temporal.is_empty() {
1573 writeln!(writer, "Temporal Coupling (implicit dependencies):")?;
1574 for tc in strong_temporal.iter().take(5) {
1575 writeln!(
1576 writer,
1577 " {} ↔ {} ({} co-changes, {:.0}% ratio)",
1578 tc.file_a,
1579 tc.file_b,
1580 tc.co_change_count,
1581 tc.coupling_ratio * 100.0
1582 )?;
1583 }
1584 writeln!(writer)?;
1585 }
1586
1587 if !report.issues.is_empty() || circular.total_cycles > 0 {
1589 writeln!(
1590 writer,
1591 "────────────────────────────────────────────────────────────"
1592 )?;
1593 writeln!(writer)?;
1594
1595 writeln!(
1597 writer,
1598 "💡 To refactor with AI, copy this output and use this prompt:"
1599 )?;
1600 writeln!(writer)?;
1601 writeln!(writer, "```")?;
1602 writeln!(
1603 writer,
1604 "Analyze the coupling issues above from `cargo coupling --ai`. "
1605 )?;
1606 writeln!(
1607 writer,
1608 "For each issue, suggest specific code changes to reduce coupling."
1609 )?;
1610 writeln!(
1611 writer,
1612 "Focus on introducing traits, moving code closer, or breaking circular dependencies."
1613 )?;
1614 writeln!(writer, "```")?;
1615 }
1616
1617 write_manifest_summary_section(manifest, false, true, writer)?;
1618
1619 Ok(())
1620}
1621
1622fn default_manifest() -> AnalysisManifest {
1623 build_manifest(&ManifestContext {
1624 git_used: true,
1625 tests_excluded: false,
1626 parse_failures: 0,
1627 skipped_crates: Vec::new(),
1628 boundary_skipped_files: 0,
1629 dead_config_patterns: Vec::new(),
1630 })
1631}
1632
1633fn write_manifest_markdown_section<W: Write>(
1634 manifest: &AnalysisManifest,
1635 show_structural_blind_spots: bool,
1636 japanese: bool,
1637 writer: &mut W,
1638) -> io::Result<()> {
1639 if japanese {
1640 writeln!(writer, "## 未分析範囲\n")?;
1641 } else {
1642 writeln!(writer, "## Not Analyzed (blind spots)\n")?;
1643 }
1644
1645 if show_structural_blind_spots {
1646 for blind_spot in &manifest.blind_spots {
1647 let description = if japanese {
1648 blind_spot.description_ja
1649 } else {
1650 blind_spot.description
1651 };
1652 writeln!(writer, "- **{}**: {}", blind_spot.area, description)?;
1653 }
1654 }
1655
1656 let notes = manifest.localized_notes(japanese);
1657 if !notes.is_empty() {
1658 if show_structural_blind_spots {
1659 writeln!(writer)?;
1660 }
1661 if japanese {
1662 writeln!(writer, "実行時の注意:")?;
1663 } else {
1664 writeln!(writer, "Run-specific notes:")?;
1665 }
1666 for note in notes {
1667 writeln!(writer, "- {}", note)?;
1668 }
1669 }
1670
1671 if !show_structural_blind_spots {
1672 if !notes.is_empty() {
1673 writeln!(writer)?;
1674 }
1675 if japanese {
1676 writeln!(
1677 writer,
1678 "ℹ {} 件の構造的な未分析範囲があります。詳細は --blind-spots (または --json) で確認できます。",
1679 manifest.blind_spots.len()
1680 )?;
1681 } else {
1682 writeln!(
1683 writer,
1684 "ℹ {} structural blind spots not analyzed — see --blind-spots (or --json).",
1685 manifest.blind_spots.len()
1686 )?;
1687 }
1688 }
1689
1690 writeln!(writer)?;
1691 Ok(())
1692}
1693
1694fn write_manifest_summary_section<W: Write>(
1695 manifest: &AnalysisManifest,
1696 japanese: bool,
1697 show_structural_blind_spots: bool,
1698 writer: &mut W,
1699) -> io::Result<()> {
1700 if japanese {
1701 writeln!(writer, "未分析範囲:")?;
1702 } else {
1703 writeln!(writer, "Not Analyzed (blind spots):")?;
1704 }
1705
1706 if show_structural_blind_spots {
1707 for blind_spot in &manifest.blind_spots {
1708 let description = if japanese {
1709 blind_spot.description_ja
1710 } else {
1711 blind_spot.description
1712 };
1713 writeln!(writer, " - {}: {}", blind_spot.area, description)?;
1714 }
1715 }
1716
1717 let notes = manifest.localized_notes(japanese);
1718 if !notes.is_empty() {
1719 if japanese {
1720 writeln!(writer, "実行時の注意:")?;
1721 } else {
1722 writeln!(writer, "Run-specific notes:")?;
1723 }
1724 for note in notes {
1725 writeln!(writer, " - {}", note)?;
1726 }
1727 }
1728
1729 if !show_structural_blind_spots {
1730 if japanese {
1731 writeln!(
1732 writer,
1733 "ℹ {} 件の構造的な未分析範囲があります。詳細は --blind-spots (または --json) で確認できます。",
1734 manifest.blind_spots.len()
1735 )?;
1736 } else {
1737 writeln!(
1738 writer,
1739 "ℹ {} structural blind spots not analyzed — see --blind-spots (or --json).",
1740 manifest.blind_spots.len()
1741 )?;
1742 }
1743 }
1744
1745 writeln!(writer)?;
1746 Ok(())
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751 use super::*;
1752 use crate::manifest::{ManifestContext, build_manifest};
1753 use std::path::PathBuf;
1754
1755 #[test]
1756 fn test_generate_summary() {
1757 let metrics = ProjectMetrics::new();
1758 let mut output = Vec::new();
1759
1760 let result = generate_summary(&metrics, &mut output);
1761 assert!(result.is_ok());
1762
1763 let output_str = String::from_utf8(output).unwrap();
1764 assert!(output_str.contains("Balanced Coupling Analysis"));
1765 assert!(output_str.contains("Grade:"));
1766 assert!(output_str.contains("Why this grade:"));
1767 }
1768
1769 #[test]
1770 fn test_generate_report() {
1771 let metrics = ProjectMetrics::new();
1772 let mut output = Vec::new();
1773
1774 let result = generate_report(&metrics, &mut output);
1775 assert!(result.is_ok());
1776
1777 let output_str = String::from_utf8(output).unwrap();
1778 assert!(output_str.contains("# Coupling Analysis Report"));
1779 assert!(output_str.contains("Executive Summary"));
1780 assert!(output_str.contains("**Why this grade**:"));
1781 assert!(output_str.contains("## Not Analyzed (blind spots)"));
1782 assert!(output_str.contains("4 structural blind spots not analyzed"));
1783 assert!(!output_str.contains("Dynamic connascence (Execution"));
1784 }
1785
1786 #[test]
1787 fn test_generate_summary_includes_manifest_notes_and_pointer() {
1788 let metrics = ProjectMetrics::new();
1789 let thresholds = IssueThresholds::default();
1790 let manifest = build_manifest(&ManifestContext {
1791 git_used: false,
1792 tests_excluded: true,
1793 parse_failures: 0,
1794 skipped_crates: Vec::new(),
1795 boundary_skipped_files: 0,
1796 dead_config_patterns: Vec::new(),
1797 });
1798 let mut output = Vec::new();
1799
1800 let result =
1801 generate_summary_with_thresholds(&metrics, &thresholds, &manifest, &mut output);
1802 assert!(result.is_ok());
1803
1804 let output_str = String::from_utf8(output).unwrap();
1805 assert!(output_str.contains("Not Analyzed (blind spots):"));
1806 assert!(output_str.contains("4 structural blind spots not analyzed"));
1807 assert!(!output_str.contains("Dynamic connascence (Execution"));
1808 assert!(output_str.contains("Git history was not analyzed"));
1809 assert!(output_str.contains("Test code was excluded"));
1810 }
1811
1812 #[test]
1813 fn test_text_report_blind_spots_are_opt_in() {
1814 let metrics = ProjectMetrics::new();
1815 let thresholds = IssueThresholds::default();
1816 let manifest = build_manifest(&ManifestContext {
1817 git_used: false,
1818 tests_excluded: true,
1819 parse_failures: 1,
1820 skipped_crates: Vec::new(),
1821 boundary_skipped_files: 0,
1822 dead_config_patterns: Vec::new(),
1823 });
1824
1825 let mut default_output = Vec::new();
1826 generate_report_with_options(
1827 &metrics,
1828 &thresholds,
1829 &manifest,
1830 TextReportOptions::default(),
1831 &mut default_output,
1832 )
1833 .unwrap();
1834 let default_text = String::from_utf8(default_output).unwrap();
1835 assert!(default_text.contains("4 structural blind spots not analyzed"));
1836 assert!(default_text.contains("Git history was not analyzed"));
1837 assert!(default_text.contains("Test code was excluded"));
1838 assert!(default_text.contains("1 source file(s) failed to parse"));
1839 assert!(!default_text.contains("Dynamic connascence (Execution"));
1840
1841 for options in [
1842 TextReportOptions {
1843 show_structural_blind_spots: true,
1844 show_all_temporal_couplings: false,
1845 },
1846 TextReportOptions {
1847 show_structural_blind_spots: true,
1848 show_all_temporal_couplings: true,
1849 },
1850 ] {
1851 let mut output = Vec::new();
1852 generate_report_with_options(&metrics, &thresholds, &manifest, options, &mut output)
1853 .unwrap();
1854 let text = String::from_utf8(output).unwrap();
1855 assert!(text.contains("dynamic-connascence"));
1856 assert!(text.contains("Dynamic connascence (Execution"));
1857 }
1858 }
1859
1860 #[test]
1861 fn test_text_report_temporal_coupling_default_truncates_strong_pairs() {
1862 use crate::volatility::TemporalCoupling;
1863
1864 let mut metrics = ProjectMetrics::new();
1865 metrics.temporal_couplings = (0..7)
1866 .map(|i| TemporalCoupling {
1867 file_a: format!("src/a{}.rs", i),
1868 file_b: format!("src/b{}.rs", i),
1869 co_change_count: i + 1,
1870 coupling_ratio: 0.95 - (i as f64 * 0.05),
1871 })
1872 .collect();
1873 metrics.temporal_couplings.push(TemporalCoupling {
1874 file_a: "src/moderate_a.rs".to_string(),
1875 file_b: "src/moderate_b.rs".to_string(),
1876 co_change_count: 2,
1877 coupling_ratio: 0.4,
1878 });
1879
1880 let manifest = default_manifest();
1881 let thresholds = IssueThresholds::default();
1882 let mut output = Vec::new();
1883 generate_report_with_options(
1884 &metrics,
1885 &thresholds,
1886 &manifest,
1887 TextReportOptions::default(),
1888 &mut output,
1889 )
1890 .unwrap();
1891 let text = String::from_utf8(output).unwrap();
1892 assert!(text.contains("src/a0.rs"));
1893 assert!(text.contains("src/a4.rs"));
1894 assert!(!text.contains("src/a5.rs"));
1895 assert!(!text.contains("src/moderate_a.rs"));
1896 assert!(text.contains("... and 2 more (use --all)"));
1897
1898 let mut all_output = Vec::new();
1899 generate_report_with_options(
1900 &metrics,
1901 &thresholds,
1902 &manifest,
1903 TextReportOptions {
1904 show_structural_blind_spots: false,
1905 show_all_temporal_couplings: true,
1906 },
1907 &mut all_output,
1908 )
1909 .unwrap();
1910 let all_text = String::from_utf8(all_output).unwrap();
1911 assert!(all_text.contains("src/a6.rs"));
1912 assert!(all_text.contains("src/moderate_a.rs"));
1913 }
1914
1915 #[test]
1916 fn test_report_issues_surfaced_count_matches_issue_list() {
1917 use crate::balance::project::analyze_project_balance_with_thresholds;
1918 use crate::metrics::coupling::CouplingMetrics;
1919 use crate::metrics::dimensions::{Distance, IntegrationStrength};
1920 use crate::volatility::Volatility;
1921
1922 let mut metrics = ProjectMetrics::new();
1923 metrics.add_coupling(CouplingMetrics::new(
1924 "source".to_string(),
1925 "target".to_string(),
1926 IntegrationStrength::Intrusive,
1927 Distance::DifferentModule,
1928 Volatility::High,
1929 ));
1930
1931 let thresholds = IssueThresholds::default();
1932 let report = analyze_project_balance_with_thresholds(&metrics, &thresholds);
1933 let mut output = Vec::new();
1934 generate_report_with_options(
1935 &metrics,
1936 &thresholds,
1937 &default_manifest(),
1938 TextReportOptions::default(),
1939 &mut output,
1940 )
1941 .unwrap();
1942 let text = String::from_utf8(output).unwrap();
1943 assert!(text.contains(&format!("| Issues Surfaced | {} |", report.issues.len())));
1944 }
1945
1946 #[test]
1947 fn test_generate_ai_output_includes_manifest() {
1948 let metrics = ProjectMetrics::new();
1949 let manifest = build_manifest(&ManifestContext {
1950 git_used: false,
1951 tests_excluded: false,
1952 parse_failures: 0,
1953 skipped_crates: Vec::new(),
1954 boundary_skipped_files: 0,
1955 dead_config_patterns: Vec::new(),
1956 });
1957 let mut output = Vec::new();
1958
1959 let result = generate_ai_output_with_thresholds(
1960 &metrics,
1961 &IssueThresholds::default(),
1962 &manifest,
1963 &mut output,
1964 );
1965 assert!(result.is_ok());
1966
1967 let output_str = String::from_utf8(output).unwrap();
1968 assert!(output_str.contains("Not Analyzed (blind spots):"));
1969 assert!(output_str.contains("macro-and-cfg"));
1970 assert!(output_str.contains("Git history was not analyzed"));
1971 }
1972
1973 #[test]
1974 fn test_generate_report_with_modules() {
1975 use crate::metrics::module::ModuleMetrics;
1976
1977 let mut metrics = ProjectMetrics::new();
1978 let mut module = ModuleMetrics::new(PathBuf::from("lib.rs"), "lib".to_string());
1979 module.trait_impl_count = 3;
1980 module.inherent_impl_count = 2;
1981 metrics.add_module(module);
1982
1983 let mut output = Vec::new();
1984 let result = generate_report(&metrics, &mut output);
1985 assert!(result.is_ok());
1986
1987 let output_str = String::from_utf8(output).unwrap();
1988 assert!(output_str.contains("Module Statistics"));
1989 }
1990
1991 #[test]
1992 fn test_generate_report_surfaces_subdomain_when_present() {
1993 use crate::config::Subdomain;
1994 use crate::metrics::module::ModuleMetrics;
1995
1996 let mut metrics = ProjectMetrics::new();
1997 let mut module = ModuleMetrics::new(PathBuf::from("src/report.rs"), "report".to_string());
1998 module.subdomain = Some(Subdomain::Supporting);
1999 metrics.add_module(module);
2000
2001 let mut output = Vec::new();
2002 generate_report(&metrics, &mut output).unwrap();
2003
2004 let output_str = String::from_utf8(output).unwrap();
2005 assert!(output_str.contains("| Module | Subdomain |"));
2006 assert!(output_str.contains("| `report` | Supporting |"));
2007 }
2008
2009 #[test]
2010 fn test_report_uses_deterministic_tie_breakers_for_ranked_sections() {
2011 use crate::metrics::module::ModuleMetrics;
2012 use crate::volatility::TemporalCoupling;
2013
2014 let mut metrics = ProjectMetrics::new();
2015 for index in (0..=20).rev() {
2016 let name = format!("module_{index:02}");
2017 let mut module =
2018 ModuleMetrics::new(PathBuf::from(format!("src/{name}.rs")), name.clone());
2019 module.internal_deps.push("shared".to_string());
2020 metrics.add_module(module);
2021 }
2022 for index in (0..=10).rev() {
2023 metrics
2024 .file_changes
2025 .insert(format!("src/file_{index:02}.rs"), 11);
2026 }
2027 metrics.temporal_couplings = (0..=5)
2028 .rev()
2029 .map(|index| TemporalCoupling {
2030 file_a: format!("src/temporal_{index:02}.rs"),
2031 file_b: "src/shared.rs".to_string(),
2032 co_change_count: 8,
2033 coupling_ratio: 0.8,
2034 })
2035 .collect();
2036
2037 let mut output = Vec::new();
2038 generate_report(&metrics, &mut output).unwrap();
2039 let text = String::from_utf8(output).unwrap();
2040
2041 assert!(text.find("`module_00`").unwrap() < text.find("`module_19`").unwrap());
2042 assert!(!text.contains("`module_20`"));
2043 assert!(text.find("`src/file_00.rs`").unwrap() < text.find("`src/file_09.rs`").unwrap());
2044 assert!(!text.contains("`src/file_10.rs`"));
2045 assert!(
2046 text.find("`src/temporal_00.rs`").unwrap() < text.find("`src/temporal_04.rs`").unwrap()
2047 );
2048 assert!(!text.contains("`src/temporal_05.rs`"));
2049 }
2050
2051 #[test]
2052 fn test_issue_categories_use_name_as_severity_tie_breaker() {
2053 use crate::balance::rationale::GradeRationale;
2054 use std::collections::HashMap;
2055
2056 let make_issue = |issue_type| CouplingIssue {
2057 issue_type,
2058 severity: Severity::Medium,
2059 source: "source".to_string(),
2060 target: "target".to_string(),
2061 description: "description".to_string(),
2062 refactoring: RefactoringAction::General {
2063 action: "action".to_string(),
2064 },
2065 balance_score: 0.5,
2066 };
2067 let issues = vec![
2068 make_issue(IssueType::GlobalComplexity),
2069 make_issue(IssueType::CircularDependency),
2070 ];
2071 let report = ProjectBalanceReport {
2072 total_couplings: 2,
2073 balanced_count: 0,
2074 needs_review: 2,
2075 needs_refactoring: 0,
2076 average_score: 0.5,
2077 health_grade: HealthGrade::B,
2078 issues_by_severity: HashMap::from([(Severity::Medium, 2)]),
2079 issues_by_type: HashMap::from([
2080 (IssueType::GlobalComplexity, 1),
2081 (IssueType::CircularDependency, 1),
2082 ]),
2083 top_priorities: issues.clone(),
2084 issues,
2085 grade_rationale: GradeRationale::empty(),
2086 };
2087
2088 let mut output = Vec::new();
2089 write_issues_by_type(&report, false, &mut output).unwrap();
2090 let text = String::from_utf8(output).unwrap();
2091
2092 assert!(
2093 text.find("### Circular Dependency").unwrap()
2094 < text.find("### Global Complexity").unwrap()
2095 );
2096 }
2097
2098 #[test]
2099 fn test_worst_couplings_keep_worst_record_for_duplicate_pair() {
2100 use crate::metrics::coupling::CouplingMetrics;
2101 use crate::volatility::Volatility;
2102
2103 let mut metrics = ProjectMetrics::new();
2104 metrics.add_coupling(CouplingMetrics::new(
2105 "source".to_string(),
2106 "target".to_string(),
2107 IntegrationStrength::Contract,
2108 Distance::SameModule,
2109 Volatility::Low,
2110 ));
2111 metrics.add_coupling(CouplingMetrics::new(
2112 "source".to_string(),
2113 "target".to_string(),
2114 IntegrationStrength::Intrusive,
2115 Distance::DifferentModule,
2116 Volatility::High,
2117 ));
2118
2119 let mut output = Vec::new();
2120 write_coupling_section(&metrics, &mut output).unwrap();
2121 let text = String::from_utf8(output).unwrap();
2122
2123 assert!(text.contains("| `source` | `target` | Intrusive | Diff Mod | High |"));
2124 assert!(!text.contains("| `source` | `target` | Contract |"));
2125 }
2126
2127 #[test]
2128 fn test_truncate_path() {
2129 assert_eq!(truncate_path("short", 10), "short");
2130 assert_eq!(
2131 truncate_path("this_is_a_very_long_path", 15),
2132 "...ry_long_path"
2133 );
2134 }
2135}