1use std::collections::{HashMap, HashSet};
10use std::io::{self, Write};
11
12use serde::Serialize;
13
14use crate::balance::grade::HealthGrade;
15use crate::balance::issue::CouplingIssue;
16use crate::balance::issue_type::IssueType;
17use crate::balance::project::analyze_project_balance_with_thresholds;
18use crate::balance::score::{BalanceScore, IssueThresholds};
19use crate::balance::severity::Severity;
20use crate::diff::BaselineDiff;
21use crate::external::{
22 ExternalDependencyReport, ExternalDependencyUsage, analyze_external_dependencies,
23};
24use crate::history::HistoryReport;
25use crate::manifest::AnalysisManifest;
26use crate::metrics::dimensions::Distance;
27use crate::metrics::project::ProjectMetrics;
28use crate::volatility::Volatility;
29
30#[derive(Debug, Clone, Serialize)]
36pub struct Hotspot {
37 pub module: String,
39 pub score: u32,
41 pub issues: Vec<HotspotIssue>,
43 pub suggestion: String,
45 pub file_path: Option<String>,
47 pub in_cycle: bool,
49}
50
51#[derive(Debug, Clone, Serialize)]
53pub struct HotspotIssue {
54 pub severity: String,
55 pub issue_type: String,
56 pub description: String,
57}
58
59pub fn get_issue_explanation(issue_type: &str) -> IssueExplanation {
65 match issue_type {
66 "High Efferent Coupling" => IssueExplanation {
67 what_it_means: "This module depends on too many other modules",
68 why_its_bad: vec![
69 "Changes elsewhere may break this module",
70 "Testing requires many mocks/stubs",
71 "Hard to understand in isolation",
72 ],
73 how_to_fix: "Split into smaller modules with clear responsibilities",
74 example: Some("e.g., Split main.rs into cli.rs, config.rs, runner.rs"),
75 },
76 "High Afferent Coupling" => IssueExplanation {
77 what_it_means: "Too many other modules depend on this one",
78 why_its_bad: vec![
79 "Changes here may break many other modules",
80 "Fear of changing leads to technical debt",
81 "Wide blast radius for bugs",
82 ],
83 how_to_fix: "Define a stable interface (trait) to hide implementation details",
84 example: Some("e.g., pub struct -> pub trait + impl for abstraction"),
85 },
86 "Circular Dependency" | "CircularDependency" => IssueExplanation {
87 what_it_means: "Modules depend on each other in a cycle (A -> B -> A)",
88 why_its_bad: vec![
89 "Can't understand one without the other",
90 "Unit testing is difficult (need both)",
91 "May cause compilation order issues",
92 ],
93 how_to_fix: "Extract shared types to a common module, or use traits to invert dependencies",
94 example: Some("e.g., A -> B -> A becomes A -> Common <- B"),
95 },
96 "Global Complexity" => IssueExplanation {
97 what_it_means: "Strong coupling to a distant module",
98 why_its_bad: vec![
99 "Hard to trace code flow",
100 "Changes have unpredictable effects",
101 "Module is not self-contained",
102 ],
103 how_to_fix: "Move the dependency closer, or use an interface for loose coupling",
104 example: None,
105 },
106 "Cascading Change Risk" => IssueExplanation {
107 what_it_means: "Strongly coupled to a frequently-changing module",
108 why_its_bad: vec![
109 "Every change there requires changes here",
110 "Bugs propagate through the chain",
111 "Constant rework needed",
112 ],
113 how_to_fix: "Depend on a stable interface instead of implementation",
114 example: None,
115 },
116 "Scattered External Coupling" => IssueExplanation {
117 what_it_means: "A third-party crate is used directly from many modules",
118 why_its_bad: vec![
119 "Crate API changes have a wide edit surface",
120 "Upgrade risk is spread across unrelated modules",
121 "Harder to replace or mock the dependency",
122 ],
123 how_to_fix: "Introduce a facade or wrapper module around the crate",
124 example: Some("e.g., reqwest calls go through http_client.rs"),
125 },
126 "Inappropriate Intimacy" | "InappropriateIntimacy" => IssueExplanation {
127 what_it_means: "Directly accessing another module's internal details",
128 why_its_bad: vec![
129 "Breaks encapsulation",
130 "Internal changes affect external code",
131 "Unclear module boundaries",
132 ],
133 how_to_fix: "Access through public methods or traits instead",
134 example: Some("e.g., foo.internal_field -> foo.get_value()"),
135 },
136 _ => IssueExplanation {
137 what_it_means: "A coupling-related issue was detected",
138 why_its_bad: vec![
139 "May reduce code maintainability",
140 "May increase change impact",
141 ],
142 how_to_fix: "Review the module dependencies",
143 example: None,
144 },
145 }
146}
147
148pub struct IssueExplanation {
150 pub what_it_means: &'static str,
152 pub why_its_bad: Vec<&'static str>,
154 pub how_to_fix: &'static str,
156 pub example: Option<&'static str>,
158}
159
160pub fn calculate_hotspots(
162 metrics: &ProjectMetrics,
163 thresholds: &IssueThresholds,
164 limit: usize,
165) -> Vec<Hotspot> {
166 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
167 let circular_deps = metrics.detect_circular_dependencies();
168 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
169
170 let mut module_issues: HashMap<String, Vec<&CouplingIssue>> = HashMap::new();
172 for issue in &report.issues {
173 module_issues
174 .entry(issue.source.clone())
175 .or_default()
176 .push(issue);
177 }
178
179 let mut couplings_out: HashMap<String, usize> = HashMap::new();
181 let mut couplings_in: HashMap<String, usize> = HashMap::new();
182 for coupling in &metrics.couplings {
183 if coupling.distance != Distance::DifferentCrate {
184 *couplings_out.entry(coupling.source.clone()).or_default() += 1;
185 *couplings_in.entry(coupling.target.clone()).or_default() += 1;
186 }
187 }
188
189 let mut hotspots: Vec<Hotspot> = Vec::new();
191
192 for (module, issues) in &module_issues {
193 let mut score: u32 = 0;
194
195 for issue in issues {
197 score += match issue.severity {
198 Severity::Critical => 50,
199 Severity::High => 30,
200 Severity::Medium => 15,
201 Severity::Low => 5,
202 };
203 }
204
205 let in_cycle = cycle_modules.contains(module);
207 if in_cycle {
208 score += 40;
209 }
210
211 let out_count = couplings_out.get(module).copied().unwrap_or(0);
213 let in_count = couplings_in.get(module).copied().unwrap_or(0);
214 score += (out_count + in_count) as u32 * 2;
215
216 let primary_issue = issues.iter().max_by_key(|i| i.severity);
218 let suggestion = if in_cycle {
219 "Break circular dependency by extracting shared types or inverting with traits".into()
220 } else if let Some(issue) = primary_issue {
221 format!("{}", issue.refactoring)
222 } else {
223 "Review module coupling".into()
224 };
225
226 let file_path = metrics
228 .modules
229 .get(module)
230 .map(|m| m.path.display().to_string());
231
232 hotspots.push(Hotspot {
233 module: module.clone(),
234 score,
235 issues: issues
236 .iter()
237 .map(|i| HotspotIssue {
238 severity: format!("{}", i.severity),
239 issue_type: format!("{}", i.issue_type),
240 description: i.description.clone(),
241 })
242 .collect(),
243 suggestion,
244 file_path,
245 in_cycle,
246 });
247 }
248
249 for module in &cycle_modules {
251 if !module_issues.contains_key(module) {
252 let file_path = metrics
253 .modules
254 .get(module)
255 .map(|m| m.path.display().to_string());
256
257 hotspots.push(Hotspot {
258 module: module.clone(),
259 score: 40,
260 issues: vec![HotspotIssue {
261 severity: "Critical".into(),
262 issue_type: "CircularDependency".into(),
263 description: "Part of a circular dependency cycle".into(),
264 }],
265 suggestion:
266 "Break circular dependency by extracting shared types or inverting with traits"
267 .into(),
268 file_path,
269 in_cycle: true,
270 });
271 }
272 }
273
274 hotspots.sort_by_key(|h| std::cmp::Reverse(h.score));
276 hotspots.truncate(limit);
277
278 hotspots
279}
280
281pub fn generate_hotspots_output<W: Write>(
283 metrics: &ProjectMetrics,
284 thresholds: &IssueThresholds,
285 limit: usize,
286 verbose: bool,
287 writer: &mut W,
288) -> io::Result<()> {
289 let hotspots = calculate_hotspots(metrics, thresholds, limit);
290
291 writeln!(writer, "Top {} Refactoring Targets", limit)?;
292 writeln!(
293 writer,
294 "═══════════════════════════════════════════════════════════"
295 )?;
296
297 if hotspots.is_empty() {
298 writeln!(writer)?;
299 writeln!(writer, "✅ No significant hotspots detected.")?;
300 writeln!(writer, " Your codebase has good coupling balance.")?;
301 return Ok(());
302 }
303
304 writeln!(writer)?;
305
306 for (i, hotspot) in hotspots.iter().enumerate() {
307 writeln!(
309 writer,
310 "#{} {} (Score: {})",
311 i + 1,
312 hotspot.module,
313 hotspot.score
314 )?;
315
316 if let Some(path) = &hotspot.file_path {
318 writeln!(writer, " 📁 {}", path)?;
319 }
320
321 for issue in &hotspot.issues {
323 let icon = match issue.severity.as_str() {
324 "Critical" => "🔴",
325 "High" => "🟠",
326 "Medium" => "🟡",
327 _ => "⚪",
328 };
329 writeln!(
330 writer,
331 " {} {}: {}",
332 icon, issue.severity, issue.issue_type
333 )?;
334
335 if verbose {
337 let explanation = get_issue_explanation(&issue.issue_type);
338 writeln!(writer)?;
339 writeln!(writer, " 💡 What it means:")?;
340 writeln!(writer, " {}", explanation.what_it_means)?;
341 writeln!(writer)?;
342 writeln!(writer, " ⚠️ Why it's a problem:")?;
343 for reason in &explanation.why_its_bad {
344 writeln!(writer, " • {}", reason)?;
345 }
346 writeln!(writer)?;
347 writeln!(writer, " 🔧 How to fix:")?;
348 writeln!(writer, " {}", explanation.how_to_fix)?;
349 if let Some(example) = explanation.example {
350 writeln!(writer, " {}", example)?;
351 }
352 writeln!(writer)?;
353 }
354 }
355
356 if !verbose {
358 writeln!(writer, " → Fix: {}", hotspot.suggestion)?;
359 }
360 writeln!(writer)?;
361 }
362
363 Ok(())
364}
365
366#[derive(Debug, Clone, Serialize)]
372pub struct ImpactAnalysis {
373 pub module: String,
375 pub risk_score: u32,
377 pub risk_level: String,
379 pub dependencies: Vec<DependencyInfo>,
381 pub dependents: Vec<DependencyInfo>,
383 pub cascading_impact: CascadingImpact,
385 pub in_cycle: bool,
387 pub volatility: String,
389}
390
391#[derive(Debug, Clone, Serialize)]
393pub struct DependencyInfo {
394 pub module: String,
396 pub distance: String,
398 pub strengths: Vec<StrengthCount>,
400 pub total_count: usize,
402}
403
404#[derive(Debug, Clone, Serialize)]
406pub struct StrengthCount {
407 pub strength: String,
408 pub count: usize,
409}
410
411#[derive(Debug, Clone, Serialize)]
413pub struct CascadingImpact {
414 pub total_affected: usize,
416 pub percentage: f64,
418 pub second_order: Vec<String>,
420}
421
422pub fn analyze_impact(metrics: &ProjectMetrics, module_name: &str) -> Option<ImpactAnalysis> {
424 let module = find_module(metrics, module_name)?;
426
427 let circular_deps = metrics.detect_circular_dependencies();
428 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
429 let in_cycle = cycle_modules.contains(&module);
430
431 let mut dep_map: HashMap<String, (String, HashMap<String, usize>)> = HashMap::new();
433 let mut dependent_map: HashMap<String, (String, HashMap<String, usize>)> = HashMap::new();
434 let mut volatility_max = Volatility::Low;
435
436 for coupling in &metrics.couplings {
437 if coupling.distance == Distance::DifferentCrate {
438 continue; }
440
441 if coupling.source == module {
442 let entry = dep_map
443 .entry(coupling.target.clone())
444 .or_insert_with(|| (format!("{:?}", coupling.distance), HashMap::new()));
445 *entry
446 .1
447 .entry(format!("{:?}", coupling.strength))
448 .or_insert(0) += 1;
449 }
450
451 if coupling.target == module {
452 let entry = dependent_map
453 .entry(coupling.source.clone())
454 .or_insert_with(|| (format!("{:?}", coupling.distance), HashMap::new()));
455 *entry
456 .1
457 .entry(format!("{:?}", coupling.strength))
458 .or_insert(0) += 1;
459
460 if coupling.volatility > volatility_max {
462 volatility_max = coupling.volatility;
463 }
464 }
465 }
466
467 let dependencies: Vec<DependencyInfo> = dep_map
469 .into_iter()
470 .map(|(mod_name, (distance, strengths))| {
471 let total_count: usize = strengths.values().sum();
472 let mut strength_list: Vec<StrengthCount> = strengths
473 .into_iter()
474 .map(|(s, c)| StrengthCount {
475 strength: s,
476 count: c,
477 })
478 .collect();
479 strength_list.sort_by_key(|s| std::cmp::Reverse(s.count));
481 DependencyInfo {
482 module: mod_name,
483 distance,
484 strengths: strength_list,
485 total_count,
486 }
487 })
488 .collect();
489
490 let dependents: Vec<DependencyInfo> = dependent_map
491 .into_iter()
492 .map(|(mod_name, (distance, strengths))| {
493 let total_count: usize = strengths.values().sum();
494 let mut strength_list: Vec<StrengthCount> = strengths
495 .into_iter()
496 .map(|(s, c)| StrengthCount {
497 strength: s,
498 count: c,
499 })
500 .collect();
501 strength_list.sort_by_key(|s| std::cmp::Reverse(s.count));
502 DependencyInfo {
503 module: mod_name,
504 distance,
505 strengths: strength_list,
506 total_count,
507 }
508 })
509 .collect();
510
511 let mut second_order: HashSet<String> = HashSet::new();
513 let dependent_set: HashSet<String> = dependents.iter().map(|d| d.module.clone()).collect();
514
515 for coupling in &metrics.couplings {
516 if coupling.distance == Distance::DifferentCrate {
517 continue;
518 }
519 if dependent_set.contains(&coupling.target) && coupling.source != module {
520 second_order.insert(coupling.source.clone());
521 }
522 }
523 for dep in &dependent_set {
525 second_order.remove(dep);
526 }
527
528 let total_affected = dependents.len() + second_order.len();
529 let total_internal_modules = metrics.modules.len();
530 let percentage = if total_internal_modules > 0 {
531 (total_affected as f64 / total_internal_modules as f64) * 100.0
532 } else {
533 0.0
534 };
535
536 let mut risk_score: u32 = 0;
538 risk_score += (dependents.len() as u32) * 10; risk_score += (second_order.len() as u32) * 5; if in_cycle {
541 risk_score += 30;
542 }
543 match volatility_max {
544 Volatility::High => risk_score += 20,
545 Volatility::Medium => risk_score += 10,
546 Volatility::Low => {}
547 }
548 risk_score = risk_score.min(100);
549
550 let risk_level = if risk_score >= 70 {
551 "HIGH"
552 } else if risk_score >= 40 {
553 "MEDIUM"
554 } else {
555 "LOW"
556 }
557 .to_string();
558
559 let volatility = format!("{:?}", volatility_max);
560
561 Some(ImpactAnalysis {
562 module: module.clone(),
563 risk_score,
564 risk_level,
565 dependencies,
566 dependents,
567 cascading_impact: CascadingImpact {
568 total_affected,
569 percentage,
570 second_order: second_order.into_iter().collect(),
571 },
572 in_cycle,
573 volatility,
574 })
575}
576
577fn find_module(metrics: &ProjectMetrics, name: &str) -> Option<String> {
578 for coupling in &metrics.couplings {
581 if coupling.source == name {
583 return Some(coupling.source.clone());
584 }
585 if coupling.target == name {
586 return Some(coupling.target.clone());
587 }
588 }
589
590 for coupling in &metrics.couplings {
592 if coupling.source.ends_with(&format!("::{}", name)) {
593 return Some(coupling.source.clone());
594 }
595 if coupling.target.ends_with(&format!("::{}", name)) {
596 return Some(coupling.target.clone());
597 }
598 }
599
600 if metrics.modules.contains_key(name) {
602 return Some(name.to_string());
603 }
604
605 for module_name in metrics.modules.keys() {
607 if module_name.ends_with(name) || module_name.ends_with(&format!("::{}", name)) {
608 return Some(module_name.clone());
609 }
610 }
611
612 None
613}
614
615fn format_strengths(strengths: &[StrengthCount]) -> String {
617 if strengths.is_empty() {
618 return "unknown".to_string();
619 }
620 if strengths.len() == 1 && strengths[0].count == 1 {
621 return strengths[0].strength.clone();
622 }
623 strengths
624 .iter()
625 .map(|s| {
626 if s.count == 1 {
627 s.strength.clone()
628 } else {
629 format!("{}x {}", s.count, s.strength)
630 }
631 })
632 .collect::<Vec<_>>()
633 .join(", ")
634}
635
636pub fn generate_impact_output<W: Write>(
638 metrics: &ProjectMetrics,
639 module_name: &str,
640 writer: &mut W,
641) -> io::Result<bool> {
642 let analysis = match analyze_impact(metrics, module_name) {
643 Some(a) => a,
644 None => {
645 writeln!(writer, "❌ Module '{}' not found.", module_name)?;
646 writeln!(writer)?;
647 writeln!(writer, "Available modules:")?;
648 for (i, name) in metrics.modules.keys().take(10).enumerate() {
649 writeln!(writer, " {}. {}", i + 1, name)?;
650 }
651 if metrics.modules.len() > 10 {
652 writeln!(writer, " ... and {} more", metrics.modules.len() - 10)?;
653 }
654 return Ok(false);
655 }
656 };
657
658 writeln!(writer, "Impact Analysis: {}", analysis.module)?;
659 writeln!(
660 writer,
661 "═══════════════════════════════════════════════════════════"
662 )?;
663
664 let risk_icon = match analysis.risk_level.as_str() {
666 "HIGH" => "🔴",
667 "MEDIUM" => "🟡",
668 _ => "🟢",
669 };
670 writeln!(
671 writer,
672 "Risk Score: {} {} ({}/100)",
673 risk_icon, analysis.risk_level, analysis.risk_score
674 )?;
675
676 if analysis.in_cycle {
677 writeln!(writer, "⚠️ Part of a circular dependency cycle")?;
678 }
679
680 writeln!(writer)?;
681
682 let total_dep_couplings: usize = analysis.dependencies.iter().map(|d| d.total_count).sum();
684 writeln!(
685 writer,
686 "Direct Dependencies ({} modules, {} couplings):",
687 analysis.dependencies.len(),
688 total_dep_couplings
689 )?;
690 if analysis.dependencies.is_empty() {
691 writeln!(writer, " (none)")?;
692 } else {
693 for dep in &analysis.dependencies {
694 let strengths_str = format_strengths(&dep.strengths);
695 writeln!(
696 writer,
697 " → {} ({}, {})",
698 dep.module, strengths_str, dep.distance
699 )?;
700 }
701 }
702
703 writeln!(writer)?;
704
705 let total_dependent_couplings: usize = analysis.dependents.iter().map(|d| d.total_count).sum();
707 writeln!(
708 writer,
709 "Direct Dependents ({} modules, {} couplings):",
710 analysis.dependents.len(),
711 total_dependent_couplings
712 )?;
713 if analysis.dependents.is_empty() {
714 writeln!(writer, " (none)")?;
715 } else {
716 for dep in &analysis.dependents {
717 let strengths_str = format_strengths(&dep.strengths);
718 writeln!(writer, " ← {} ({})", dep.module, strengths_str)?;
719 }
720 }
721
722 writeln!(writer)?;
723
724 writeln!(writer, "Cascading Impact:")?;
726 writeln!(
727 writer,
728 " Total affected: {} modules ({:.1}% of codebase)",
729 analysis.cascading_impact.total_affected, analysis.cascading_impact.percentage
730 )?;
731
732 if !analysis.cascading_impact.second_order.is_empty() {
733 writeln!(writer, " 2nd-order affected:")?;
734 for module in analysis.cascading_impact.second_order.iter().take(5) {
735 writeln!(writer, " - {}", module)?;
736 }
737 if analysis.cascading_impact.second_order.len() > 5 {
738 writeln!(
739 writer,
740 " ... and {} more",
741 analysis.cascading_impact.second_order.len() - 5
742 )?;
743 }
744 }
745
746 Ok(true)
747}
748
749#[derive(Debug, Clone)]
755pub struct CheckConfig {
756 pub min_grade: Option<HealthGrade>,
758 pub max_critical: Option<usize>,
760 pub max_circular: Option<usize>,
762 pub fail_on: Option<Severity>,
764}
765
766impl Default for CheckConfig {
767 fn default() -> Self {
768 Self {
769 min_grade: Some(HealthGrade::C),
770 max_critical: Some(0),
771 max_circular: Some(0),
772 fail_on: None,
773 }
774 }
775}
776
777#[derive(Debug, Clone, Serialize)]
779pub struct CheckResult {
780 pub passed: bool,
781 pub grade: String,
782 pub score: f64,
783 pub critical_count: usize,
784 pub high_count: usize,
785 pub medium_count: usize,
786 pub circular_count: usize,
787 pub failures: Vec<String>,
788}
789
790pub fn run_check(
792 metrics: &ProjectMetrics,
793 thresholds: &IssueThresholds,
794 config: &CheckConfig,
795) -> CheckResult {
796 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
797 let circular_deps = metrics.detect_circular_dependencies();
798
799 let critical_count = *report
800 .issues_by_severity
801 .get(&Severity::Critical)
802 .unwrap_or(&0);
803 let high_count = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
804 let medium_count = *report
805 .issues_by_severity
806 .get(&Severity::Medium)
807 .unwrap_or(&0);
808 let circular_count = circular_deps.len();
809
810 let mut failures: Vec<String> = Vec::new();
811 let mut passed = true;
812
813 if let Some(min_grade) = &config.min_grade {
815 let grade_order = |g: &HealthGrade| match g {
818 HealthGrade::S => 5, HealthGrade::A => 5,
820 HealthGrade::B => 4,
821 HealthGrade::C => 3,
822 HealthGrade::D => 2,
823 HealthGrade::F => 1,
824 };
825 if grade_order(&report.health_grade) < grade_order(min_grade) {
826 passed = false;
827 failures.push(format!(
828 "Grade {:?} is below minimum {:?}",
829 report.health_grade, min_grade
830 ));
831 }
832 }
833
834 if let Some(max) = config.max_critical
836 && critical_count > max
837 {
838 passed = false;
839 failures.push(format!("{} critical issues (max: {})", critical_count, max));
840 }
841
842 if let Some(max) = config.max_circular
844 && circular_count > max
845 {
846 passed = false;
847 failures.push(format!(
848 "{} circular dependencies (max: {})",
849 circular_count, max
850 ));
851 }
852
853 if let Some(fail_severity) = &config.fail_on {
855 let count = match fail_severity {
856 Severity::Critical => critical_count,
857 Severity::High => critical_count + high_count,
858 Severity::Medium => critical_count + high_count + medium_count,
859 Severity::Low => report.issues.len(),
860 };
861 if count > 0 {
862 passed = false;
863 failures.push(format!(
864 "{} issues at {:?} severity or higher",
865 count, fail_severity
866 ));
867 }
868 }
869
870 CheckResult {
871 passed,
872 grade: report.health_grade.letter().to_string(),
873 score: report.average_score,
874 critical_count,
875 high_count,
876 medium_count,
877 circular_count,
878 failures,
879 }
880}
881
882pub fn generate_check_output<W: Write>(
884 metrics: &ProjectMetrics,
885 thresholds: &IssueThresholds,
886 config: &CheckConfig,
887 writer: &mut W,
888) -> io::Result<i32> {
889 let result = run_check(metrics, thresholds, config);
890
891 writeln!(writer, "Coupling Quality Gate")?;
892 writeln!(
893 writer,
894 "═══════════════════════════════════════════════════════════"
895 )?;
896
897 let status = if result.passed {
898 "✅ PASSED"
899 } else {
900 "❌ FAILED"
901 };
902 writeln!(
903 writer,
904 "Grade: {} ({:.0}%) {}",
905 result.grade,
906 result.score * 100.0,
907 status
908 )?;
909
910 writeln!(writer)?;
911 writeln!(writer, "Metrics:")?;
912 writeln!(writer, " Critical issues: {}", result.critical_count)?;
913 writeln!(writer, " High issues: {}", result.high_count)?;
914 writeln!(writer, " Medium issues: {}", result.medium_count)?;
915 writeln!(writer, " Circular dependencies: {}", result.circular_count)?;
916
917 if !result.passed {
918 writeln!(writer)?;
919 writeln!(writer, "Blocking Issues:")?;
920 for failure in &result.failures {
921 writeln!(writer, " - {}", failure)?;
922 }
923 }
924
925 Ok(if result.passed { 0 } else { 1 })
926}
927
928pub fn generate_baseline_diff_output<W: Write>(
930 diff: &BaselineDiff,
931 baseline_ref: &str,
932 writer: &mut W,
933) -> io::Result<()> {
934 writeln!(writer, "Coupling Baseline Diff")?;
935 writeln!(
936 writer,
937 "═══════════════════════════════════════════════════════════"
938 )?;
939 writeln!(writer, "Baseline: {}", baseline_ref)?;
940 writeln!(
941 writer,
942 "Grade: {} -> {}",
943 diff.baseline_grade.letter(),
944 diff.current_grade.letter()
945 )?;
946 writeln!(writer, "Score delta: {:+.3}", diff.score_delta)?;
947 writeln!(writer)?;
948 writeln!(writer, "Issues:")?;
949 writeln!(writer, " New: {}", diff.new_issues.len())?;
950 writeln!(writer, " Resolved: {}", diff.resolved_issues.len())?;
951 writeln!(writer, " Unchanged: {}", diff.unchanged)?;
952
953 write_issue_section(writer, "New Issues", &diff.new_issues)?;
954 write_issue_section(writer, "Resolved Issues", &diff.resolved_issues)?;
955
956 Ok(())
957}
958
959pub fn generate_ratchet_check_output<W: Write>(
961 diff: &BaselineDiff,
962 baseline_ref: &str,
963 fail_on: Severity,
964 writer: &mut W,
965) -> io::Result<i32> {
966 let failures = diff.ratchet_failures(fail_on);
967 let passed = failures.is_empty();
968
969 writeln!(writer, "Coupling Ratchet Gate")?;
970 writeln!(
971 writer,
972 "═══════════════════════════════════════════════════════════"
973 )?;
974 writeln!(writer, "Baseline: {}", baseline_ref)?;
975 writeln!(
976 writer,
977 "Grade: {} -> {}",
978 diff.baseline_grade.letter(),
979 diff.current_grade.letter()
980 )?;
981 writeln!(writer, "Score delta: {:+.3}", diff.score_delta)?;
982 writeln!(
983 writer,
984 "New issues: {} (fail-on: {} or higher)",
985 diff.new_issues.len(),
986 fail_on
987 )?;
988 writeln!(
989 writer,
990 "Status: {}",
991 if passed { "PASSED" } else { "FAILED" }
992 )?;
993
994 if !passed {
995 writeln!(writer)?;
996 writeln!(writer, "Blocking New Issues:")?;
997 for issue in failures {
998 write_issue_line(writer, issue)?;
999 }
1000 }
1001
1002 Ok(if passed { 0 } else { 1 })
1003}
1004
1005fn write_issue_section<W: Write>(
1006 writer: &mut W,
1007 title: &str,
1008 issues: &[CouplingIssue],
1009) -> io::Result<()> {
1010 writeln!(writer)?;
1011 writeln!(writer, "{}:", title)?;
1012 if issues.is_empty() {
1013 writeln!(writer, " (none)")?;
1014 return Ok(());
1015 }
1016
1017 for issue in issues {
1018 write_issue_line(writer, issue)?;
1019 }
1020 Ok(())
1021}
1022
1023fn write_issue_line<W: Write>(writer: &mut W, issue: &CouplingIssue) -> io::Result<()> {
1024 writeln!(
1025 writer,
1026 " - {} {}: {} -> {}",
1027 issue.severity, issue.issue_type, issue.source, issue.target
1028 )
1029}
1030
1031pub fn generate_external_dependencies_output<W: Write>(
1037 report: &ExternalDependencyReport,
1038 json: bool,
1039 japanese: bool,
1040 writer: &mut W,
1041) -> io::Result<()> {
1042 if json {
1043 let output = JsonExternalDependenciesOutput {
1044 external_dependencies: json_external_dependencies(report),
1045 };
1046 let text = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1047 writeln!(writer, "{}", text)?;
1048 return Ok(());
1049 }
1050
1051 if japanese {
1052 writeln!(writer, "外部依存の結合")?;
1053 } else {
1054 writeln!(writer, "External Dependency Coupling")?;
1055 }
1056 writeln!(
1057 writer,
1058 "═══════════════════════════════════════════════════════════"
1059 )?;
1060 let total_references = report
1061 .dependencies
1062 .iter()
1063 .map(|dependency| dependency.total_references)
1064 .sum::<usize>();
1065 if japanese {
1066 writeln!(
1067 writer,
1068 "外部クレート: {} 直接参照: {}",
1069 report.dependencies.len(),
1070 total_references
1071 )?;
1072 } else {
1073 writeln!(
1074 writer,
1075 "External crates: {} Direct references: {}",
1076 report.dependencies.len(),
1077 total_references
1078 )?;
1079 }
1080
1081 if report.dependencies.is_empty() {
1082 writeln!(writer)?;
1083 if japanese {
1084 writeln!(writer, "外部クレートへの結合は検出されませんでした。")?;
1085 } else {
1086 writeln!(writer, "No external crate couplings detected.")?;
1087 }
1088 return Ok(());
1089 }
1090
1091 writeln!(writer)?;
1092 if japanese {
1093 writeln!(writer, "利用モジュール数が多いクレート:")?;
1094 } else {
1095 writeln!(writer, "Top Crates by Breadth:")?;
1096 }
1097 for (index, dependency) in report.dependencies.iter().take(10).enumerate() {
1098 let version = if dependency.versions.is_empty() {
1099 if japanese {
1100 "バージョン: 不明".to_string()
1101 } else {
1102 "version: unknown".to_string()
1103 }
1104 } else if japanese {
1105 format!("バージョン: {}", dependency.versions.join(", "))
1106 } else {
1107 format!("version: {}", dependency.versions.join(", "))
1108 };
1109 if japanese {
1110 writeln!(
1111 writer,
1112 "{}. {} ({}; {} モジュール, {} 参照, 主な強度: {})",
1113 index + 1,
1114 dependency.crate_name,
1115 version,
1116 dependency.breadth,
1117 dependency.total_references,
1118 dependency.dominant_strength
1119 )?;
1120 } else {
1121 writeln!(
1122 writer,
1123 "{}. {} ({}; {} modules, {} references, dominant: {})",
1124 index + 1,
1125 dependency.crate_name,
1126 version,
1127 dependency.breadth,
1128 dependency.total_references,
1129 dependency.dominant_strength
1130 )?;
1131 }
1132 let sample_modules = dependency
1133 .source_modules
1134 .iter()
1135 .take(5)
1136 .cloned()
1137 .collect::<Vec<_>>()
1138 .join(", ");
1139 if !sample_modules.is_empty() {
1140 if japanese {
1141 writeln!(writer, " モジュール: {}", sample_modules)?;
1142 } else {
1143 writeln!(writer, " modules: {}", sample_modules)?;
1144 }
1145 }
1146 }
1147
1148 writeln!(writer)?;
1149 if japanese {
1150 writeln!(writer, "分散した外部結合の警告:")?;
1151 } else {
1152 writeln!(writer, "Scattered Coupling Flags:")?;
1153 }
1154 if report.scattered_couplings.is_empty() {
1155 if japanese {
1156 writeln!(writer, " (なし)")?;
1157 } else {
1158 writeln!(writer, " (none)")?;
1159 }
1160 } else {
1161 for issue in &report.scattered_couplings {
1162 let source = if japanese {
1163 issue_source_japanese(issue)
1164 } else {
1165 issue.source.clone()
1166 };
1167 writeln!(
1168 writer,
1169 " - {}: {} -> {}",
1170 severity_label(issue.severity, japanese),
1171 source,
1172 issue.target
1173 )?;
1174 if japanese {
1175 writeln!(writer, " {}", issue_instance_description_japanese(issue))?;
1176 writeln!(writer, " 修正: {}", issue_refactoring_japanese(issue))?;
1177 } else {
1178 writeln!(writer, " {}", issue.description)?;
1179 writeln!(writer, " Fix: {}", issue.refactoring)?;
1180 }
1181 }
1182 }
1183
1184 Ok(())
1185}
1186
1187fn severity_label(severity: Severity, japanese: bool) -> String {
1188 if !japanese {
1189 return severity.to_string();
1190 }
1191 match severity {
1192 Severity::Critical => "緊急",
1193 Severity::High => "高",
1194 Severity::Medium => "中",
1195 Severity::Low => "低",
1196 }
1197 .to_string()
1198}
1199
1200fn issue_source_japanese(issue: &CouplingIssue) -> String {
1201 if issue.issue_type == IssueType::ScatteredExternalCoupling
1202 && issue.source.ends_with(" internal modules")
1203 {
1204 let count = issue.source.split_whitespace().next().unwrap_or_default();
1205 return format!("{} 個の内部モジュール", count);
1206 }
1207 issue.source.clone()
1208}
1209
1210fn issue_instance_description_japanese(issue: &CouplingIssue) -> String {
1211 match issue.issue_type {
1212 IssueType::ScatteredExternalCoupling => {
1213 let source = issue_source_japanese(issue);
1214 format!(
1215 "{} は、{}から直接使われています。サードパーティ更新時のリスクがコードベース全体に広がっています。",
1216 issue.target, source
1217 )
1218 }
1219 IssueType::HiddenCoupling => {
1220 "明示的なコード依存はありませんが、ファイルが頻繁に一緒に変更されています。暗黙の知識や不足した抽象化を示している可能性があります。"
1221 .to_string()
1222 }
1223 IssueType::AccidentalVolatility => {
1224 "安定しているはずのサブドメインが頻繁に変更されています。本質的な業務変化ではなく、設計や所有権の問題によるチャーンの可能性があります。"
1225 .to_string()
1226 }
1227 _ => issue.description.clone(),
1228 }
1229}
1230
1231fn issue_refactoring_japanese(issue: &CouplingIssue) -> String {
1232 match issue.issue_type {
1233 IssueType::ScatteredExternalCoupling => {
1234 let facade = issue.target.replace('-', "_");
1235 format!(
1236 "`{}_facade` モジュールを導入し、直接利用をそこに集約する",
1237 facade
1238 )
1239 }
1240 IssueType::HiddenCoupling => {
1241 "共有されている知識を明示的な抽象化や境界に切り出す".to_string()
1242 }
1243 IssueType::AccidentalVolatility => {
1244 "変更理由を分離し、安定サブドメインを高頻度変更から守る".to_string()
1245 }
1246 _ => {
1247 let action = issue.refactoring.to_string();
1248 if action == "Extract a shared abstraction or make the dependency explicit" {
1249 "共有された抽象化を抽出するか、依存関係を明示する".to_string()
1250 } else {
1251 action
1252 }
1253 }
1254 }
1255}
1256
1257#[derive(Debug, Clone, Serialize)]
1263pub struct JsonTemporalCoupling {
1264 pub file_a: String,
1265 pub file_b: String,
1266 pub co_change_count: usize,
1267 pub coupling_ratio: f64,
1268 pub is_strong: bool,
1269}
1270
1271#[derive(Debug, Clone, Serialize)]
1273pub struct JsonOutput {
1274 pub summary: JsonSummary,
1275 pub grade_rationale: JsonGradeRationale,
1276 pub analysis_manifest: JsonAnalysisManifest,
1277 #[serde(skip_serializing_if = "Option::is_none")]
1278 pub diff: Option<JsonBaselineDiff>,
1279 pub external_dependencies: JsonExternalDependencies,
1280 pub hotspots: Vec<Hotspot>,
1281 pub issues: Vec<JsonIssue>,
1282 pub circular_dependencies: Vec<Vec<String>>,
1283 pub temporal_couplings: Vec<JsonTemporalCoupling>,
1284 pub modules: Vec<JsonModule>,
1285}
1286
1287#[derive(Debug, Clone, Serialize)]
1289pub struct JsonExternalDependenciesOutput {
1290 pub external_dependencies: JsonExternalDependencies,
1291}
1292
1293#[derive(Debug, Clone, Serialize)]
1295pub struct JsonExternalDependencies {
1296 pub total_crates: usize,
1297 pub total_references: usize,
1298 pub dependencies: Vec<ExternalDependencyUsage>,
1299 pub scattered_couplings: Vec<JsonIssue>,
1300}
1301
1302#[derive(Debug, Clone, Serialize)]
1304pub struct JsonSummary {
1305 pub health_grade: String,
1306 pub health_score: f64,
1307 pub total_modules: usize,
1308 pub total_couplings: usize,
1309 pub internal_couplings: usize,
1310 pub external_couplings: usize,
1311 pub critical_issues: usize,
1312 pub high_issues: usize,
1313 pub medium_issues: usize,
1314}
1315
1316#[derive(Debug, Clone, Serialize)]
1318pub struct JsonGradeRationale {
1319 pub summary: String,
1320 #[serde(skip_serializing_if = "Option::is_none")]
1321 pub dominant_dimension: Option<String>,
1322 pub top_issue_types: Vec<JsonIssueTypeContribution>,
1323 #[serde(skip_serializing_if = "Option::is_none")]
1324 pub note: Option<String>,
1325}
1326
1327#[derive(Debug, Clone, Serialize)]
1329pub struct JsonIssueTypeContribution {
1330 pub issue_type: String,
1331 pub count: usize,
1332 pub highest_severity: String,
1333}
1334
1335#[derive(Debug, Clone, Serialize)]
1337pub struct JsonAnalysisManifest {
1338 pub blind_spots: Vec<JsonBlindSpot>,
1339 pub notes: Vec<String>,
1340}
1341
1342#[derive(Debug, Clone, Serialize)]
1344pub struct JsonBlindSpot {
1345 pub area: String,
1346 pub description: String,
1347}
1348
1349#[derive(Debug, Clone, Serialize)]
1351pub struct JsonIssue {
1352 pub issue_type: String,
1353 pub severity: String,
1354 pub source: String,
1355 pub target: String,
1356 pub description: String,
1357 pub suggestion: String,
1358 pub balance_score: f64,
1359}
1360
1361#[derive(Debug, Clone, Serialize)]
1363pub struct JsonModule {
1364 pub name: String,
1365 pub file_path: Option<String>,
1366 #[serde(skip_serializing_if = "Option::is_none")]
1367 pub subdomain: Option<String>,
1368 pub couplings_out: usize,
1369 pub couplings_in: usize,
1370 pub balance_score: f64,
1371 pub in_cycle: bool,
1372}
1373
1374#[derive(Debug, Clone, Serialize)]
1376pub struct JsonBaselineDiff {
1377 pub new_issues: Vec<JsonIssue>,
1378 pub resolved_issues: Vec<JsonIssue>,
1379 pub unchanged: usize,
1380 pub score_delta: f64,
1381 pub grade_change: JsonGradeChange,
1382}
1383
1384#[derive(Debug, Clone, Serialize)]
1386pub struct JsonGradeChange {
1387 pub baseline: String,
1388 pub current: String,
1389}
1390
1391pub fn generate_json_output<W: Write>(
1393 metrics: &ProjectMetrics,
1394 thresholds: &IssueThresholds,
1395 manifest: &AnalysisManifest,
1396 writer: &mut W,
1397) -> io::Result<()> {
1398 generate_json_output_with_optional_diff(metrics, thresholds, manifest, None, writer)
1399}
1400
1401pub fn generate_json_output_with_diff<W: Write>(
1403 metrics: &ProjectMetrics,
1404 thresholds: &IssueThresholds,
1405 manifest: &AnalysisManifest,
1406 diff: &BaselineDiff,
1407 writer: &mut W,
1408) -> io::Result<()> {
1409 generate_json_output_with_optional_diff(metrics, thresholds, manifest, Some(diff), writer)
1410}
1411
1412fn generate_json_output_with_optional_diff<W: Write>(
1413 metrics: &ProjectMetrics,
1414 thresholds: &IssueThresholds,
1415 manifest: &AnalysisManifest,
1416 diff: Option<&BaselineDiff>,
1417 writer: &mut W,
1418) -> io::Result<()> {
1419 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
1420 let external_dependencies = analyze_external_dependencies(metrics, &HashMap::new());
1421 let circular_deps = metrics.detect_circular_dependencies();
1422 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
1423 let hotspots = calculate_hotspots(metrics, thresholds, 10);
1424
1425 let mut couplings_out: HashMap<String, usize> = HashMap::new();
1427 let mut couplings_in: HashMap<String, usize> = HashMap::new();
1428 let mut balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
1429 let mut internal_count = 0;
1430
1431 for coupling in &metrics.couplings {
1432 if coupling.distance != Distance::DifferentCrate {
1433 internal_count += 1;
1434 *couplings_out.entry(coupling.source.clone()).or_default() += 1;
1435 *couplings_in.entry(coupling.target.clone()).or_default() += 1;
1436 let score = BalanceScore::calculate(coupling);
1437 balance_scores
1438 .entry(coupling.source.clone())
1439 .or_default()
1440 .push(score.score);
1441 }
1442 }
1443
1444 let external_count = metrics.couplings.len() - internal_count;
1445
1446 let critical = *report
1447 .issues_by_severity
1448 .get(&Severity::Critical)
1449 .unwrap_or(&0);
1450 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
1451 let medium = *report
1452 .issues_by_severity
1453 .get(&Severity::Medium)
1454 .unwrap_or(&0);
1455
1456 let temporal_couplings: Vec<JsonTemporalCoupling> = metrics
1457 .temporal_couplings
1458 .iter()
1459 .take(20)
1460 .map(|tc| JsonTemporalCoupling {
1461 file_a: tc.file_a.clone(),
1462 file_b: tc.file_b.clone(),
1463 co_change_count: tc.co_change_count,
1464 coupling_ratio: tc.coupling_ratio,
1465 is_strong: tc.is_strong(),
1466 })
1467 .collect();
1468
1469 let output = JsonOutput {
1470 summary: JsonSummary {
1471 health_grade: report.health_grade.letter().to_string(),
1472 health_score: report.average_score,
1473 total_modules: metrics.modules.len(),
1474 total_couplings: metrics.couplings.len(),
1475 internal_couplings: internal_count,
1476 external_couplings: external_count,
1477 critical_issues: critical,
1478 high_issues: high,
1479 medium_issues: medium,
1480 },
1481 grade_rationale: JsonGradeRationale {
1482 summary: report.grade_rationale.summary.clone(),
1483 dominant_dimension: report
1484 .grade_rationale
1485 .dominant_dimension
1486 .map(|dimension| dimension.to_string()),
1487 top_issue_types: report
1488 .grade_rationale
1489 .top_issue_types
1490 .iter()
1491 .map(|item| JsonIssueTypeContribution {
1492 issue_type: item.issue_type.to_string(),
1493 count: item.count,
1494 highest_severity: item.highest_severity.to_string(),
1495 })
1496 .collect(),
1497 note: report.grade_rationale.volatility_note.clone(),
1498 },
1499 analysis_manifest: JsonAnalysisManifest {
1500 blind_spots: manifest
1501 .blind_spots
1502 .iter()
1503 .map(|blind_spot| JsonBlindSpot {
1504 area: blind_spot.area.to_string(),
1505 description: if thresholds.japanese {
1506 blind_spot.description_ja.to_string()
1507 } else {
1508 blind_spot.description.to_string()
1509 },
1510 })
1511 .collect(),
1512 notes: manifest.localized_notes(thresholds.japanese).to_vec(),
1513 },
1514 diff: diff.map(json_baseline_diff),
1515 external_dependencies: json_external_dependencies(&external_dependencies),
1516 hotspots,
1517 issues: report.issues.iter().map(json_issue).collect(),
1518 circular_dependencies: circular_deps,
1519 temporal_couplings,
1520 modules: metrics
1521 .modules
1522 .iter()
1523 .map(|(name, module)| {
1524 let avg_score = balance_scores
1525 .get(name)
1526 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
1527 .unwrap_or(1.0);
1528 JsonModule {
1529 name: name.clone(),
1530 file_path: Some(module.path.display().to_string()),
1531 subdomain: module.subdomain.map(|subdomain| subdomain.to_string()),
1532 couplings_out: couplings_out.get(name).copied().unwrap_or(0),
1533 couplings_in: couplings_in.get(name).copied().unwrap_or(0),
1534 balance_score: avg_score,
1535 in_cycle: cycle_modules.contains(name),
1536 }
1537 })
1538 .collect(),
1539 };
1540
1541 let json = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1542 writeln!(writer, "{}", json)?;
1543
1544 Ok(())
1545}
1546
1547fn json_baseline_diff(diff: &BaselineDiff) -> JsonBaselineDiff {
1548 JsonBaselineDiff {
1549 new_issues: diff.new_issues.iter().map(json_issue).collect(),
1550 resolved_issues: diff.resolved_issues.iter().map(json_issue).collect(),
1551 unchanged: diff.unchanged,
1552 score_delta: diff.score_delta,
1553 grade_change: JsonGradeChange {
1554 baseline: diff.baseline_grade.letter().to_string(),
1555 current: diff.current_grade.letter().to_string(),
1556 },
1557 }
1558}
1559
1560fn json_issue(issue: &CouplingIssue) -> JsonIssue {
1561 JsonIssue {
1562 issue_type: format!("{}", issue.issue_type),
1563 severity: format!("{}", issue.severity),
1564 source: issue.source.clone(),
1565 target: issue.target.clone(),
1566 description: issue.description.clone(),
1567 suggestion: format!("{}", issue.refactoring),
1568 balance_score: issue.balance_score,
1569 }
1570}
1571
1572fn json_external_dependencies(report: &ExternalDependencyReport) -> JsonExternalDependencies {
1573 JsonExternalDependencies {
1574 total_crates: report.dependencies.len(),
1575 total_references: report
1576 .dependencies
1577 .iter()
1578 .map(|dependency| dependency.total_references)
1579 .sum(),
1580 dependencies: report.dependencies.clone(),
1581 scattered_couplings: report.scattered_couplings.iter().map(json_issue).collect(),
1582 }
1583}
1584
1585pub fn parse_grade(s: &str) -> Option<HealthGrade> {
1591 match s.to_uppercase().as_str() {
1592 "S" => Some(HealthGrade::S),
1593 "A" => Some(HealthGrade::A),
1594 "B" => Some(HealthGrade::B),
1595 "C" => Some(HealthGrade::C),
1596 "D" => Some(HealthGrade::D),
1597 "F" => Some(HealthGrade::F),
1598 _ => None,
1599 }
1600}
1601
1602pub fn parse_severity(s: &str) -> Option<Severity> {
1604 match s.to_lowercase().as_str() {
1605 "critical" => Some(Severity::Critical),
1606 "high" => Some(Severity::High),
1607 "medium" => Some(Severity::Medium),
1608 "low" => Some(Severity::Low),
1609 _ => None,
1610 }
1611}
1612
1613#[derive(Debug, Clone)]
1619pub struct TraceResult {
1620 pub item_name: String,
1622 pub module: String,
1624 pub file_path: String,
1626 pub depends_on: Vec<TraceDependency>,
1628 pub depended_by: Vec<TraceDependency>,
1630 pub recommendation: Option<String>,
1632}
1633
1634#[derive(Debug, Clone)]
1636pub struct TraceDependency {
1637 pub item: String,
1639 pub module: String,
1641 pub dep_type: String,
1643 pub strength: String,
1645 pub file_path: Option<String>,
1647 pub line: usize,
1649}
1650
1651pub fn generate_trace_output<W: Write>(
1653 metrics: &ProjectMetrics,
1654 item_name: &str,
1655 writer: &mut W,
1656) -> io::Result<bool> {
1657 use crate::analyzer::ItemDepType;
1658
1659 let mut found_in_modules: Vec<(&str, &crate::metrics::module::ModuleMetrics)> = Vec::new();
1661 let mut outgoing: Vec<TraceDependency> = Vec::new();
1662 let mut incoming: Vec<TraceDependency> = Vec::new();
1663
1664 for (module_name, module) in &metrics.modules {
1666 let defines_function = module.function_definitions.contains_key(item_name);
1668 let defines_type = module.type_definitions.contains_key(item_name);
1669
1670 if defines_function || defines_type {
1671 found_in_modules.push((module_name, module));
1672 }
1673
1674 for dep in &module.item_dependencies {
1676 if dep.source_item.contains(item_name) || dep.source_item.ends_with(item_name) {
1677 let strength = match dep.dep_type {
1678 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1679 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1680 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1681 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1682 };
1683 outgoing.push(TraceDependency {
1684 item: dep.target.clone(),
1685 module: dep
1686 .target_module
1687 .clone()
1688 .unwrap_or_else(|| "unknown".to_string()),
1689 dep_type: format!("{:?}", dep.dep_type),
1690 strength: strength.to_string(),
1691 file_path: Some(module.path.display().to_string()),
1692 line: dep.line,
1693 });
1694 }
1695
1696 if dep.target.contains(item_name) || dep.target.ends_with(item_name) {
1698 let strength = match dep.dep_type {
1699 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1700 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1701 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1702 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1703 };
1704 incoming.push(TraceDependency {
1705 item: dep.source_item.clone(),
1706 module: module_name.clone(),
1707 dep_type: format!("{:?}", dep.dep_type),
1708 strength: strength.to_string(),
1709 file_path: Some(module.path.display().to_string()),
1710 line: dep.line,
1711 });
1712 }
1713 }
1714 }
1715
1716 if found_in_modules.is_empty() && outgoing.is_empty() && incoming.is_empty() {
1718 writeln!(writer, "Item '{}' not found.", item_name)?;
1719 writeln!(writer)?;
1720 writeln!(
1721 writer,
1722 "Hint: Try searching with a partial name or check module names:"
1723 )?;
1724
1725 let mut suggestions: Vec<String> = Vec::new();
1727 for (module_name, module) in &metrics.modules {
1728 for func_name in module.function_definitions.keys() {
1729 if func_name.to_lowercase().contains(&item_name.to_lowercase()) {
1730 suggestions.push(format!(" - {} (function in {})", func_name, module_name));
1731 }
1732 }
1733 for type_name in module.type_definitions.keys() {
1734 if type_name.to_lowercase().contains(&item_name.to_lowercase()) {
1735 suggestions.push(format!(" - {} (type in {})", type_name, module_name));
1736 }
1737 }
1738 }
1739
1740 if suggestions.is_empty() {
1741 writeln!(writer, " No similar items found.")?;
1742 } else {
1743 for s in suggestions.iter().take(10) {
1744 writeln!(writer, "{}", s)?;
1745 }
1746 if suggestions.len() > 10 {
1747 writeln!(writer, " ... and {} more", suggestions.len() - 10)?;
1748 }
1749 }
1750
1751 return Ok(false);
1752 }
1753
1754 writeln!(writer, "Dependency Trace: {}", item_name)?;
1756 writeln!(writer, "{}", "═".repeat(50))?;
1757 writeln!(writer)?;
1758
1759 if !found_in_modules.is_empty() {
1761 writeln!(writer, "📍 Defined in:")?;
1762 for (module_name, module) in &found_in_modules {
1763 let item_type = if module.function_definitions.contains_key(item_name) {
1764 "function"
1765 } else {
1766 "type"
1767 };
1768 writeln!(
1769 writer,
1770 " {} ({}) - {}",
1771 module_name,
1772 item_type,
1773 module.path.display()
1774 )?;
1775 }
1776 writeln!(writer)?;
1777 }
1778
1779 writeln!(writer, "📤 Depends on ({} items):", outgoing.len())?;
1781 if outgoing.is_empty() {
1782 writeln!(writer, " (none)")?;
1783 } else {
1784 let mut by_target: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1786 for dep in &outgoing {
1787 by_target.entry(dep.item.clone()).or_default().push(dep);
1788 }
1789
1790 for (target, deps) in by_target.iter().take(15) {
1791 let first = deps[0];
1792 let strength_icon = match first.strength.as_str() {
1793 "Intrusive" => "🔴",
1794 "Functional" => "🟠",
1795 "Model" => "🟡",
1796 "Contract" => "🟢",
1797 _ => "⚪",
1798 };
1799 writeln!(
1800 writer,
1801 " {} {} ({}) - line {}",
1802 strength_icon, target, first.strength, first.line
1803 )?;
1804 }
1805 if by_target.len() > 15 {
1806 writeln!(writer, " ... and {} more", by_target.len() - 15)?;
1807 }
1808 }
1809 writeln!(writer)?;
1810
1811 writeln!(writer, "📥 Depended by ({} items):", incoming.len())?;
1813 if incoming.is_empty() {
1814 writeln!(writer, " (none)")?;
1815 } else {
1816 let mut by_source: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1818 for dep in &incoming {
1819 by_source.entry(dep.item.clone()).or_default().push(dep);
1820 }
1821
1822 for (source, deps) in by_source.iter().take(15) {
1823 let first = deps[0];
1824 let strength_icon = match first.strength.as_str() {
1825 "Intrusive" => "🔴",
1826 "Functional" => "🟠",
1827 "Model" => "🟡",
1828 "Contract" => "🟢",
1829 _ => "⚪",
1830 };
1831 writeln!(
1832 writer,
1833 " {} {} ({}) - {}:{}",
1834 strength_icon,
1835 source,
1836 first.strength,
1837 first.file_path.as_deref().unwrap_or("?"),
1838 first.line
1839 )?;
1840 }
1841 if by_source.len() > 15 {
1842 writeln!(writer, " ... and {} more", by_source.len() - 15)?;
1843 }
1844 }
1845 writeln!(writer)?;
1846
1847 writeln!(writer, "💡 Design Analysis:")?;
1849
1850 let intrusive_out = outgoing
1851 .iter()
1852 .filter(|d| d.strength == "Intrusive")
1853 .count();
1854 let intrusive_in = incoming
1855 .iter()
1856 .filter(|d| d.strength == "Intrusive")
1857 .count();
1858 let total_deps = outgoing.len() + incoming.len();
1859
1860 if total_deps == 0 {
1861 writeln!(writer, " ✅ This item has no tracked dependencies.")?;
1862 } else if intrusive_out > 3 {
1863 writeln!(
1864 writer,
1865 " ⚠️ High intrusive outgoing coupling ({} items)",
1866 intrusive_out
1867 )?;
1868 writeln!(
1869 writer,
1870 " → Consider: Extract interface/trait to reduce direct access"
1871 )?;
1872 writeln!(
1873 writer,
1874 " → Khononov: Strong coupling should be CLOSE (same module)"
1875 )?;
1876 } else if intrusive_in > 5 {
1877 writeln!(
1878 writer,
1879 " ⚠️ High intrusive incoming coupling ({} items depend on internals)",
1880 intrusive_in
1881 )?;
1882 writeln!(
1883 writer,
1884 " → Consider: This item is a hotspot - changes will cascade"
1885 )?;
1886 writeln!(
1887 writer,
1888 " → Khononov: Add stable interface to protect dependents"
1889 )?;
1890 } else if outgoing.len() > 10 {
1891 writeln!(
1892 writer,
1893 " ⚠️ High efferent coupling ({} dependencies)",
1894 outgoing.len()
1895 )?;
1896 writeln!(
1897 writer,
1898 " → Consider: Split into smaller functions with focused responsibilities"
1899 )?;
1900 } else if incoming.len() > 10 {
1901 writeln!(
1902 writer,
1903 " ⚠️ High afferent coupling ({} dependents)",
1904 incoming.len()
1905 )?;
1906 writeln!(
1907 writer,
1908 " → Consider: This is a core component - keep it stable"
1909 )?;
1910 } else {
1911 writeln!(writer, " ✅ Coupling appears balanced.")?;
1912 }
1913
1914 writeln!(writer)?;
1915
1916 writeln!(writer, "🔄 Change Impact:")?;
1918 writeln!(
1919 writer,
1920 " If you modify '{}', you may need to update:",
1921 item_name
1922 )?;
1923 let affected_modules: HashSet<_> = incoming.iter().map(|d| d.module.clone()).collect();
1924 if affected_modules.is_empty() {
1925 writeln!(writer, " (no other modules directly affected)")?;
1926 } else {
1927 for module in affected_modules.iter().take(10) {
1928 writeln!(writer, " • {}", module)?;
1929 }
1930 if affected_modules.len() > 10 {
1931 writeln!(
1932 writer,
1933 " ... and {} more modules",
1934 affected_modules.len() - 10
1935 )?;
1936 }
1937 }
1938
1939 Ok(true)
1940}
1941
1942#[derive(Debug, Clone, Serialize)]
1948pub struct JsonHistoryPoint {
1949 pub commit: String,
1950 pub date: String,
1951 pub grade: char,
1952 pub average_score: f64,
1953 pub total_couplings: usize,
1954 pub module_count: usize,
1955 pub critical_issues: usize,
1956 pub high_issues: usize,
1957}
1958
1959#[derive(Debug, Clone, Serialize)]
1961pub struct JsonSkippedRevision {
1962 pub commit: String,
1963 pub date: String,
1964 pub reason: String,
1965}
1966
1967#[derive(Debug, Clone, Serialize)]
1969pub struct JsonHistory {
1970 pub months: usize,
1971 pub points: Vec<JsonHistoryPoint>,
1972 pub skipped: Vec<JsonSkippedRevision>,
1973}
1974
1975pub fn history_report_to_json(report: &HistoryReport) -> JsonHistory {
1977 JsonHistory {
1978 months: report.months,
1979 points: report
1980 .points
1981 .iter()
1982 .map(|p| JsonHistoryPoint {
1983 commit: p.commit.clone(),
1984 date: p.date.clone(),
1985 grade: p.grade.letter(),
1986 average_score: p.average_score,
1987 total_couplings: p.total_couplings,
1988 module_count: p.module_count,
1989 critical_issues: p.critical,
1990 high_issues: p.high,
1991 })
1992 .collect(),
1993 skipped: report
1994 .skipped
1995 .iter()
1996 .map(|s| JsonSkippedRevision {
1997 commit: s.commit.clone(),
1998 date: s.date.clone(),
1999 reason: s.reason.clone(),
2000 })
2001 .collect(),
2002 }
2003}
2004
2005pub fn generate_history_output<W: Write>(
2007 report: &HistoryReport,
2008 json: bool,
2009 requested_samples: usize,
2010 writer: &mut W,
2011) -> io::Result<()> {
2012 if json {
2013 let output = history_report_to_json(report);
2014 let text = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
2015 writeln!(writer, "{}", text)?;
2016 return Ok(());
2017 }
2018
2019 writeln!(
2020 writer,
2021 "Coupling History (last {} months, {} sample(s))\n",
2022 report.months,
2023 report.points.len()
2024 )?;
2025
2026 if report.points.is_empty() {
2027 writeln!(writer, " No analyzable revisions in the requested window.")?;
2028 } else {
2029 writeln!(
2030 writer,
2031 " date commit grade avg couplings critical"
2032 )?;
2033 for p in &report.points {
2034 writeln!(
2035 writer,
2036 " {:<11} {:<8} {:<6} {:<7.3} {:<10} {}",
2037 p.date,
2038 p.commit,
2039 p.grade.letter(),
2040 p.average_score,
2041 p.total_couplings,
2042 p.critical,
2043 )?;
2044 }
2045
2046 if let Some((first, last)) = report.endpoints() {
2047 let direction = describe_trend(first.average_score, last.average_score);
2048 writeln!(
2049 writer,
2050 "\nTrend: grade {} -> {}, avg {:.3} -> {:.3} ({})",
2051 first.grade.letter(),
2052 last.grade.letter(),
2053 first.average_score,
2054 last.average_score,
2055 direction,
2056 )?;
2057 }
2058 }
2059
2060 if report.points.len() < requested_samples {
2061 writeln!(
2062 writer,
2063 "\nNote: {} of {} requested samples (history/window-limited).",
2064 report.points.len(),
2065 requested_samples
2066 )?;
2067 }
2068
2069 if !report.skipped.is_empty() {
2070 writeln!(writer, "\nSkipped {} revision(s):", report.skipped.len())?;
2071 for s in &report.skipped {
2072 writeln!(writer, " {} ({}): {}", s.commit, s.date, s.reason)?;
2073 }
2074 }
2075
2076 Ok(())
2077}
2078
2079fn describe_trend(from: f64, to: f64) -> &'static str {
2081 let delta = to - from;
2082 if delta > 0.01 {
2083 "improving"
2084 } else if delta < -0.01 {
2085 "regressing"
2086 } else {
2087 "stable"
2088 }
2089}
2090
2091#[cfg(test)]
2092mod tests {
2093 use super::*;
2094 use std::path::PathBuf;
2095
2096 use crate::history::{HistoryPoint, HistoryReport};
2097 use crate::manifest::{ManifestContext, build_manifest};
2098
2099 fn sample_point(date: &str, grade: HealthGrade, score: f64) -> HistoryPoint {
2100 HistoryPoint {
2101 commit: "abc1234".to_string(),
2102 date: date.to_string(),
2103 grade,
2104 average_score: score,
2105 total_couplings: 100,
2106 module_count: 12,
2107 critical: 0,
2108 high: 1,
2109 }
2110 }
2111
2112 #[test]
2113 fn test_describe_trend() {
2114 assert_eq!(describe_trend(0.70, 0.85), "improving");
2115 assert_eq!(describe_trend(0.85, 0.70), "regressing");
2116 assert_eq!(describe_trend(0.80, 0.805), "stable");
2117 }
2118
2119 #[test]
2120 fn test_history_text_output_shows_trend() {
2121 let report = HistoryReport {
2122 months: 6,
2123 points: vec![
2124 sample_point("2026-01-01", HealthGrade::C, 0.60),
2125 sample_point("2026-05-01", HealthGrade::A, 0.85),
2126 ],
2127 skipped: vec![],
2128 };
2129 let mut buf = Vec::new();
2130 generate_history_output(&report, false, 2, &mut buf).unwrap();
2131 let text = String::from_utf8(buf).unwrap();
2132 assert!(text.contains("Coupling History (last 6 months, 2 sample(s))"));
2133 assert!(text.contains("Trend: grade C -> A"));
2134 assert!(text.contains("improving"));
2135 }
2136
2137 #[test]
2138 fn test_history_json_output_is_valid() {
2139 let report = HistoryReport {
2140 months: 12,
2141 points: vec![sample_point("2026-05-01", HealthGrade::B, 0.75)],
2142 skipped: vec![],
2143 };
2144 let mut buf = Vec::new();
2145 generate_history_output(&report, true, 1, &mut buf).unwrap();
2146 let text = String::from_utf8(buf).unwrap();
2147 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
2148 assert_eq!(parsed["months"], 12);
2149 assert_eq!(parsed["points"][0]["grade"], "B");
2150 assert_eq!(parsed["points"][0]["module_count"], 12);
2151 }
2152
2153 #[test]
2154 fn test_history_empty_output() {
2155 let report = HistoryReport {
2156 months: 6,
2157 points: vec![],
2158 skipped: vec![],
2159 };
2160 let mut buf = Vec::new();
2161 generate_history_output(&report, false, 0, &mut buf).unwrap();
2162 let text = String::from_utf8(buf).unwrap();
2163 assert!(text.contains("No analyzable revisions"));
2164 }
2165
2166 #[test]
2167 fn test_history_text_output_notes_when_requested_samples_are_limited() {
2168 let report = HistoryReport {
2169 months: 6,
2170 points: vec![sample_point("2026-05-01", HealthGrade::B, 0.75)],
2171 skipped: vec![],
2172 };
2173 let mut buf = Vec::new();
2174 generate_history_output(&report, false, 3, &mut buf).unwrap();
2175 let text = String::from_utf8(buf).unwrap();
2176 assert!(text.contains("Note: 1 of 3 requested samples (history/window-limited)."));
2177 }
2178
2179 #[test]
2180 fn test_parse_grade() {
2181 assert_eq!(parse_grade("S"), Some(HealthGrade::S));
2182 assert_eq!(parse_grade("A"), Some(HealthGrade::A));
2183 assert_eq!(parse_grade("b"), Some(HealthGrade::B));
2184 assert_eq!(parse_grade("C"), Some(HealthGrade::C));
2185 assert_eq!(parse_grade("X"), None);
2186 }
2187
2188 #[test]
2189 fn test_parse_severity() {
2190 assert_eq!(parse_severity("critical"), Some(Severity::Critical));
2191 assert_eq!(parse_severity("HIGH"), Some(Severity::High));
2192 assert_eq!(parse_severity("invalid"), None);
2193 }
2194
2195 #[test]
2196 fn test_empty_metrics_hotspots() {
2197 let metrics = ProjectMetrics::new();
2198 let thresholds = IssueThresholds::default();
2199 let hotspots = calculate_hotspots(&metrics, &thresholds, 5);
2200 assert!(hotspots.is_empty());
2201 }
2202
2203 #[test]
2204 fn test_external_dependencies_json_output_shape() {
2205 use crate::external::{ExternalDependencyReport, ExternalDependencyUsage};
2206
2207 let dependencies = vec![ExternalDependencyUsage {
2208 crate_name: "reqwest".to_string(),
2209 versions: vec!["0.12.0".to_string()],
2210 breadth: 4,
2211 total_references: 8,
2212 dominant_strength: "Functional".to_string(),
2213 source_modules: vec![
2214 "api".to_string(),
2215 "client".to_string(),
2216 "sync".to_string(),
2217 "worker".to_string(),
2218 ],
2219 }];
2220 let scattered_couplings =
2221 crate::external::detect_scattered_external_coupling(&dependencies);
2222 let report = ExternalDependencyReport {
2223 dependencies,
2224 scattered_couplings,
2225 };
2226 let mut buf = Vec::new();
2227
2228 generate_external_dependencies_output(&report, true, false, &mut buf).unwrap();
2229
2230 let text = String::from_utf8(buf).unwrap();
2231 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
2232 let deps = &parsed["external_dependencies"];
2233 assert_eq!(deps["total_crates"], 1);
2234 assert_eq!(deps["total_references"], 8);
2235 assert_eq!(deps["dependencies"][0]["crate_name"], "reqwest");
2236 assert_eq!(deps["dependencies"][0]["versions"][0], "0.12.0");
2237 assert_eq!(
2238 deps["scattered_couplings"][0]["issue_type"],
2239 "Scattered External Coupling"
2240 );
2241 }
2242
2243 #[test]
2244 fn test_check_passes_on_empty() {
2245 let metrics = ProjectMetrics::new();
2246 let thresholds = IssueThresholds::default();
2247 let config = CheckConfig::default();
2248 let result = run_check(&metrics, &thresholds, &config);
2249 assert!(result.passed);
2250 }
2251
2252 #[test]
2253 fn test_json_output_includes_analysis_manifest() {
2254 let metrics = ProjectMetrics::new();
2255 let thresholds = IssueThresholds::default();
2256 let manifest = build_manifest(&ManifestContext {
2257 git_used: false,
2258 tests_excluded: true,
2259 parse_failures: 0,
2260 skipped_crates: Vec::new(),
2261 boundary_skipped_files: 0,
2262 dead_config_patterns: Vec::new(),
2263 });
2264 let mut buf = Vec::new();
2265
2266 generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
2267
2268 let text = String::from_utf8(buf).unwrap();
2269 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
2270 let blind_spots = parsed["analysis_manifest"]["blind_spots"]
2271 .as_array()
2272 .unwrap();
2273 let notes = parsed["analysis_manifest"]["notes"].as_array().unwrap();
2274
2275 assert!(blind_spots.iter().any(|spot| {
2276 spot["area"]
2277 .as_str()
2278 .is_some_and(|area| area == "dynamic-connascence")
2279 }));
2280 assert!(blind_spots.iter().any(|spot| {
2281 spot["area"]
2282 .as_str()
2283 .is_some_and(|area| area == "macro-and-cfg")
2284 }));
2285 assert!(notes.iter().any(|note| {
2286 note.as_str()
2287 .is_some_and(|note| note.contains("Git history was not analyzed"))
2288 }));
2289 assert!(notes.iter().any(|note| {
2290 note.as_str()
2291 .is_some_and(|note| note.contains("Test code was excluded"))
2292 }));
2293 }
2294
2295 #[test]
2296 fn test_json_output_includes_module_subdomain_when_present() {
2297 use crate::config::Subdomain;
2298 use crate::metrics::module::ModuleMetrics;
2299
2300 let mut metrics = ProjectMetrics::new();
2301 let mut module = ModuleMetrics::new(PathBuf::from("src/report.rs"), "report".to_string());
2302 module.subdomain = Some(Subdomain::Supporting);
2303 metrics.add_module(module);
2304
2305 let thresholds = IssueThresholds::default();
2306 let manifest = build_manifest(&ManifestContext::default());
2307 let mut buf = Vec::new();
2308
2309 generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
2310
2311 let text = String::from_utf8(buf).unwrap();
2312 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
2313 let modules = parsed["modules"].as_array().unwrap();
2314 assert!(modules.iter().any(|module| {
2315 module["name"] == "report" && module["subdomain"].as_str() == Some("Supporting")
2316 }));
2317 }
2318
2319 #[test]
2320 fn test_json_output_includes_grade_rationale() {
2321 use crate::metrics::coupling::CouplingMetrics;
2322 use crate::metrics::dimensions::IntegrationStrength;
2323 use crate::volatility::Volatility;
2324
2325 let mut metrics = ProjectMetrics::new();
2326 metrics.add_coupling(CouplingMetrics::new(
2327 "caller".to_string(),
2328 "stable".to_string(),
2329 IntegrationStrength::Intrusive,
2330 Distance::DifferentModule,
2331 Volatility::High,
2332 ));
2333
2334 let thresholds = IssueThresholds::default();
2335 let manifest = build_manifest(&ManifestContext::default());
2336 let mut buf = Vec::new();
2337
2338 generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
2339
2340 let text = String::from_utf8(buf).unwrap();
2341 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
2342 let rationale = &parsed["grade_rationale"];
2343 assert!(rationale["summary"].as_str().unwrap().contains("Driven by"));
2344 assert_eq!(
2345 rationale["top_issue_types"][0]["issue_type"].as_str(),
2346 Some("Cascading Change Risk")
2347 );
2348 assert!(rationale["note"].as_str().is_some());
2349 }
2350}