Skip to main content

cargo_coupling/
report.rs

1//! Text and Markdown report rendering for coupling analysis.
2//!
3//! This module translates balance scores, issue lists, volatility signals, and
4//! blind-spot manifests into CLI-facing summaries and full reports.
5
6use 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// ===== Report Options =====
24
25/// Options for the default human-readable text report.
26#[derive(Debug, Clone, Copy, Default)]
27pub struct TextReportOptions {
28    /// Include the full structural blind-spot descriptions instead of a pointer.
29    pub show_structural_blind_spots: bool,
30    /// Include all temporal-coupling pairs instead of the concise default.
31    pub show_all_temporal_couplings: bool,
32}
33
34// ===== Summary Report =====
35
36/// Generate a summary report to the given writer
37pub 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
42/// Generate a summary report with custom thresholds
43pub 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
52/// Generate a summary report with custom thresholds and blind-spot detail.
53pub 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    // 3-Dimensional Analysis
94    if !metrics.couplings.is_empty() {
95        // Strength distribution
96        let (intr_pct, func_pct, model_pct, contract_pct) = dimension_stats.strength_percentages();
97        // Distance distribution
98        let (same_pct, diff_pct, ext_pct) = dimension_stats.distance_percentages();
99        // Volatility distribution
100        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        // Balance Classification
144        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    // Issue breakdown
240    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    // Top priority if any
294    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    // Rust Design Quality (newtype usage)
320    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    // Circular dependencies
355    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    // Design decision guide (Japanese only, for educational purposes)
373    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
390// ===== Localization Helpers =====
391
392/// Get Japanese translation for issue type
393fn 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
415/// Get Japanese translation for refactoring action
416fn 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
484// ===== Full Markdown Report =====
485
486/// Generate a full Markdown report with refactoring suggestions
487pub 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
492/// Generate a full Markdown report with custom thresholds
493pub 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
508/// Generate a full Markdown report with custom thresholds and text options.
509pub 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    // Executive Summary
521    let jp = thresholds.japanese;
522    write_executive_summary(metrics, &report, jp, writer)?;
523
524    // Refactoring Priorities (if any issues)
525    if !report.issues.is_empty() {
526        write_refactoring_priorities(&report, jp, writer)?;
527    }
528
529    // Detailed Issues by Type
530    write_issues_by_type(&report, jp, writer)?;
531
532    // Coupling details
533    write_coupling_section(metrics, writer)?;
534
535    // Module analysis
536    write_module_section(metrics, writer)?;
537
538    // Volatility section
539    write_volatility_section(metrics, writer)?;
540
541    // Temporal coupling section
542    write_temporal_coupling_section(metrics, options.show_all_temporal_couplings, writer)?;
543
544    // Circular dependency section
545    write_circular_dependencies_section(metrics, writer)?;
546
547    // Best practices
548    write_best_practices(writer)?;
549
550    // Declared analysis blind spots
551    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    // Health Grade with emoji
565    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    // This headline count mirrors `report.issues`/JSON `issues`; balance buckets
614    // such as Pain Zone are separate coupling classifications, not surfaced issues.
615    writeln!(writer, "| Issues Surfaced | {} |", report.issues.len())?;
616    writeln!(writer)?;
617
618    // Issue counts
619    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    // Show top 5 priority issues with concrete actions
677    writeln!(writer, "### Immediate Actions\n")?;
678
679    // Deduplicate by (type, source, target): several couplings between the same
680    // module pair otherwise fill the list with identical "Immediate Actions".
681    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
748/// Whether an issue is driven by volatility/churn (may settle) rather than structure.
749fn 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
757/// Triage surfaced issues by nature: structural (act now) vs volatility-driven
758/// (may settle). Expected-by-design patterns are downgraded elsewhere and omitted.
759fn 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            // IssueType Display is English.
774            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    // Order by severity of issues in each group
840    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            // Show up to 5 examples
871            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    // Strength distribution
909    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    // Distance distribution
947    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    // Volatility distribution (only for internal couplings where we have git data)
967    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    // Worst balanced couplings
1011    writeln!(writer, "### Worst Balanced Couplings\n")?;
1012
1013    // External (DifferentCrate) couplings are outside our control and are excluded
1014    // from issue detection everywhere else; the crate-root re-export facade is a
1015    // stable Contract. Exclude both, and keep the worst record for each
1016    // (source, target) pair, so repeated use sites cannot hide a more severe
1017    // coupling merely because they were encountered later.
1018    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
1465// ===== AI-Oriented Report =====
1466
1467/// Generate AI-friendly output format for coding agents
1468///
1469/// This format is designed to be:
1470/// 1. Concise and structured for LLM consumption
1471/// 2. Actionable with specific file/module references
1472/// 3. Copy-paste ready for AI refactoring prompts
1473pub 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
1478/// Generate AI-friendly output with custom thresholds
1479pub 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    // Summary line
1496    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    // List issues in a structured format
1511    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    // Circular dependencies (critical for AI to understand)
1547    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    // Temporal coupling (important for AI to understand implicit dependencies)
1566    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    // only show ai refactor advice if there is something to refactor
1588    if !report.issues.is_empty() || circular.total_cycles > 0 {
1589        writeln!(
1590            writer,
1591            "────────────────────────────────────────────────────────────"
1592        )?;
1593        writeln!(writer)?;
1594
1595        // AI prompt suggestion
1596        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}