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