1use std::collections::{HashMap, HashSet};
10use std::io::{self, Write};
11
12use serde::Serialize;
13
14use crate::balance::{
15 BalanceScore, HealthGrade, IssueThresholds, Severity, analyze_project_balance_with_thresholds,
16};
17use crate::metrics::{Distance, ProjectMetrics};
18
19#[derive(Debug, Clone, Serialize)]
25pub struct Hotspot {
26 pub module: String,
28 pub score: u32,
30 pub issues: Vec<HotspotIssue>,
32 pub suggestion: String,
34 pub file_path: Option<String>,
36 pub in_cycle: bool,
38}
39
40#[derive(Debug, Clone, Serialize)]
42pub struct HotspotIssue {
43 pub severity: String,
44 pub issue_type: String,
45 pub description: String,
46}
47
48pub fn get_issue_explanation(issue_type: &str) -> IssueExplanation {
54 match issue_type {
55 "High Efferent Coupling" => IssueExplanation {
56 what_it_means: "This module depends on too many other modules",
57 why_its_bad: vec![
58 "Changes elsewhere may break this module",
59 "Testing requires many mocks/stubs",
60 "Hard to understand in isolation",
61 ],
62 how_to_fix: "Split into smaller modules with clear responsibilities",
63 example: Some("e.g., Split main.rs into cli.rs, config.rs, runner.rs"),
64 },
65 "High Afferent Coupling" => IssueExplanation {
66 what_it_means: "Too many other modules depend on this one",
67 why_its_bad: vec![
68 "Changes here may break many other modules",
69 "Fear of changing leads to technical debt",
70 "Wide blast radius for bugs",
71 ],
72 how_to_fix: "Define a stable interface (trait) to hide implementation details",
73 example: Some("e.g., pub struct -> pub trait + impl for abstraction"),
74 },
75 "Circular Dependency" | "CircularDependency" => IssueExplanation {
76 what_it_means: "Modules depend on each other in a cycle (A -> B -> A)",
77 why_its_bad: vec![
78 "Can't understand one without the other",
79 "Unit testing is difficult (need both)",
80 "May cause compilation order issues",
81 ],
82 how_to_fix: "Extract shared types to a common module, or use traits to invert dependencies",
83 example: Some("e.g., A -> B -> A becomes A -> Common <- B"),
84 },
85 "Global Complexity" => IssueExplanation {
86 what_it_means: "Strong coupling to a distant module",
87 why_its_bad: vec![
88 "Hard to trace code flow",
89 "Changes have unpredictable effects",
90 "Module is not self-contained",
91 ],
92 how_to_fix: "Move the dependency closer, or use an interface for loose coupling",
93 example: None,
94 },
95 "Cascading Change Risk" => IssueExplanation {
96 what_it_means: "Strongly coupled to a frequently-changing module",
97 why_its_bad: vec![
98 "Every change there requires changes here",
99 "Bugs propagate through the chain",
100 "Constant rework needed",
101 ],
102 how_to_fix: "Depend on a stable interface instead of implementation",
103 example: None,
104 },
105 "Inappropriate Intimacy" | "InappropriateIntimacy" => IssueExplanation {
106 what_it_means: "Directly accessing another module's internal details",
107 why_its_bad: vec![
108 "Breaks encapsulation",
109 "Internal changes affect external code",
110 "Unclear module boundaries",
111 ],
112 how_to_fix: "Access through public methods or traits instead",
113 example: Some("e.g., foo.internal_field -> foo.get_value()"),
114 },
115 _ => IssueExplanation {
116 what_it_means: "A coupling-related issue was detected",
117 why_its_bad: vec![
118 "May reduce code maintainability",
119 "May increase change impact",
120 ],
121 how_to_fix: "Review the module dependencies",
122 example: None,
123 },
124 }
125}
126
127pub struct IssueExplanation {
129 pub what_it_means: &'static str,
131 pub why_its_bad: Vec<&'static str>,
133 pub how_to_fix: &'static str,
135 pub example: Option<&'static str>,
137}
138
139pub fn calculate_hotspots(
141 metrics: &ProjectMetrics,
142 thresholds: &IssueThresholds,
143 limit: usize,
144) -> Vec<Hotspot> {
145 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
146 let circular_deps = metrics.detect_circular_dependencies();
147 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
148
149 let mut module_issues: HashMap<String, Vec<&crate::balance::CouplingIssue>> = HashMap::new();
151 for issue in &report.issues {
152 module_issues
153 .entry(issue.source.clone())
154 .or_default()
155 .push(issue);
156 }
157
158 let mut couplings_out: HashMap<String, usize> = HashMap::new();
160 let mut couplings_in: HashMap<String, usize> = HashMap::new();
161 for coupling in &metrics.couplings {
162 if coupling.distance != Distance::DifferentCrate {
163 *couplings_out.entry(coupling.source.clone()).or_default() += 1;
164 *couplings_in.entry(coupling.target.clone()).or_default() += 1;
165 }
166 }
167
168 let mut hotspots: Vec<Hotspot> = Vec::new();
170
171 for (module, issues) in &module_issues {
172 let mut score: u32 = 0;
173
174 for issue in issues {
176 score += match issue.severity {
177 Severity::Critical => 50,
178 Severity::High => 30,
179 Severity::Medium => 15,
180 Severity::Low => 5,
181 };
182 }
183
184 let in_cycle = cycle_modules.contains(module);
186 if in_cycle {
187 score += 40;
188 }
189
190 let out_count = couplings_out.get(module).copied().unwrap_or(0);
192 let in_count = couplings_in.get(module).copied().unwrap_or(0);
193 score += (out_count + in_count) as u32 * 2;
194
195 let primary_issue = issues.iter().max_by_key(|i| i.severity);
197 let suggestion = if in_cycle {
198 "Break circular dependency by extracting shared types or inverting with traits".into()
199 } else if let Some(issue) = primary_issue {
200 format!("{}", issue.refactoring)
201 } else {
202 "Review module coupling".into()
203 };
204
205 let file_path = metrics
207 .modules
208 .get(module)
209 .map(|m| m.path.display().to_string());
210
211 hotspots.push(Hotspot {
212 module: module.clone(),
213 score,
214 issues: issues
215 .iter()
216 .map(|i| HotspotIssue {
217 severity: format!("{}", i.severity),
218 issue_type: format!("{}", i.issue_type),
219 description: i.description.clone(),
220 })
221 .collect(),
222 suggestion,
223 file_path,
224 in_cycle,
225 });
226 }
227
228 for module in &cycle_modules {
230 if !module_issues.contains_key(module) {
231 let file_path = metrics
232 .modules
233 .get(module)
234 .map(|m| m.path.display().to_string());
235
236 hotspots.push(Hotspot {
237 module: module.clone(),
238 score: 40,
239 issues: vec![HotspotIssue {
240 severity: "Critical".into(),
241 issue_type: "CircularDependency".into(),
242 description: "Part of a circular dependency cycle".into(),
243 }],
244 suggestion:
245 "Break circular dependency by extracting shared types or inverting with traits"
246 .into(),
247 file_path,
248 in_cycle: true,
249 });
250 }
251 }
252
253 hotspots.sort_by(|a, b| b.score.cmp(&a.score));
255 hotspots.truncate(limit);
256
257 hotspots
258}
259
260pub fn generate_hotspots_output<W: Write>(
262 metrics: &ProjectMetrics,
263 thresholds: &IssueThresholds,
264 limit: usize,
265 verbose: bool,
266 writer: &mut W,
267) -> io::Result<()> {
268 let hotspots = calculate_hotspots(metrics, thresholds, limit);
269
270 writeln!(writer, "Top {} Refactoring Targets", limit)?;
271 writeln!(
272 writer,
273 "═══════════════════════════════════════════════════════════"
274 )?;
275
276 if hotspots.is_empty() {
277 writeln!(writer)?;
278 writeln!(writer, "✅ No significant hotspots detected.")?;
279 writeln!(writer, " Your codebase has good coupling balance.")?;
280 return Ok(());
281 }
282
283 writeln!(writer)?;
284
285 for (i, hotspot) in hotspots.iter().enumerate() {
286 writeln!(
288 writer,
289 "#{} {} (Score: {})",
290 i + 1,
291 hotspot.module,
292 hotspot.score
293 )?;
294
295 if let Some(path) = &hotspot.file_path {
297 writeln!(writer, " 📁 {}", path)?;
298 }
299
300 for issue in &hotspot.issues {
302 let icon = match issue.severity.as_str() {
303 "Critical" => "🔴",
304 "High" => "🟠",
305 "Medium" => "🟡",
306 _ => "⚪",
307 };
308 writeln!(
309 writer,
310 " {} {}: {}",
311 icon, issue.severity, issue.issue_type
312 )?;
313
314 if verbose {
316 let explanation = get_issue_explanation(&issue.issue_type);
317 writeln!(writer)?;
318 writeln!(writer, " 💡 What it means:")?;
319 writeln!(writer, " {}", explanation.what_it_means)?;
320 writeln!(writer)?;
321 writeln!(writer, " ⚠️ Why it's a problem:")?;
322 for reason in &explanation.why_its_bad {
323 writeln!(writer, " • {}", reason)?;
324 }
325 writeln!(writer)?;
326 writeln!(writer, " 🔧 How to fix:")?;
327 writeln!(writer, " {}", explanation.how_to_fix)?;
328 if let Some(example) = explanation.example {
329 writeln!(writer, " {}", example)?;
330 }
331 writeln!(writer)?;
332 }
333 }
334
335 if !verbose {
337 writeln!(writer, " → Fix: {}", hotspot.suggestion)?;
338 }
339 writeln!(writer)?;
340 }
341
342 Ok(())
343}
344
345#[derive(Debug, Clone, Serialize)]
351pub struct ImpactAnalysis {
352 pub module: String,
354 pub risk_score: u32,
356 pub risk_level: String,
358 pub dependencies: Vec<DependencyInfo>,
360 pub dependents: Vec<DependencyInfo>,
362 pub cascading_impact: CascadingImpact,
364 pub in_cycle: bool,
366 pub volatility: String,
368}
369
370#[derive(Debug, Clone, Serialize)]
372pub struct DependencyInfo {
373 pub module: String,
375 pub distance: String,
377 pub strengths: Vec<StrengthCount>,
379 pub total_count: usize,
381}
382
383#[derive(Debug, Clone, Serialize)]
385pub struct StrengthCount {
386 pub strength: String,
387 pub count: usize,
388}
389
390#[derive(Debug, Clone, Serialize)]
392pub struct CascadingImpact {
393 pub total_affected: usize,
395 pub percentage: f64,
397 pub second_order: Vec<String>,
399}
400
401pub fn analyze_impact(metrics: &ProjectMetrics, module_name: &str) -> Option<ImpactAnalysis> {
403 let module = find_module(metrics, module_name)?;
405
406 let circular_deps = metrics.detect_circular_dependencies();
407 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
408 let in_cycle = cycle_modules.contains(&module);
409
410 let mut dep_map: HashMap<String, (String, HashMap<String, usize>)> = HashMap::new();
412 let mut dependent_map: HashMap<String, (String, HashMap<String, usize>)> = HashMap::new();
413 let mut volatility_max = crate::metrics::Volatility::Low;
414
415 for coupling in &metrics.couplings {
416 if coupling.distance == Distance::DifferentCrate {
417 continue; }
419
420 if coupling.source == module {
421 let entry = dep_map
422 .entry(coupling.target.clone())
423 .or_insert_with(|| (format!("{:?}", coupling.distance), HashMap::new()));
424 *entry
425 .1
426 .entry(format!("{:?}", coupling.strength))
427 .or_insert(0) += 1;
428 }
429
430 if coupling.target == module {
431 let entry = dependent_map
432 .entry(coupling.source.clone())
433 .or_insert_with(|| (format!("{:?}", coupling.distance), HashMap::new()));
434 *entry
435 .1
436 .entry(format!("{:?}", coupling.strength))
437 .or_insert(0) += 1;
438
439 if coupling.volatility > volatility_max {
441 volatility_max = coupling.volatility;
442 }
443 }
444 }
445
446 let dependencies: Vec<DependencyInfo> = dep_map
448 .into_iter()
449 .map(|(mod_name, (distance, strengths))| {
450 let total_count: usize = strengths.values().sum();
451 let mut strength_list: Vec<StrengthCount> = strengths
452 .into_iter()
453 .map(|(s, c)| StrengthCount {
454 strength: s,
455 count: c,
456 })
457 .collect();
458 strength_list.sort_by(|a, b| b.count.cmp(&a.count));
460 DependencyInfo {
461 module: mod_name,
462 distance,
463 strengths: strength_list,
464 total_count,
465 }
466 })
467 .collect();
468
469 let dependents: Vec<DependencyInfo> = dependent_map
470 .into_iter()
471 .map(|(mod_name, (distance, strengths))| {
472 let total_count: usize = strengths.values().sum();
473 let mut strength_list: Vec<StrengthCount> = strengths
474 .into_iter()
475 .map(|(s, c)| StrengthCount {
476 strength: s,
477 count: c,
478 })
479 .collect();
480 strength_list.sort_by(|a, b| b.count.cmp(&a.count));
481 DependencyInfo {
482 module: mod_name,
483 distance,
484 strengths: strength_list,
485 total_count,
486 }
487 })
488 .collect();
489
490 let mut second_order: HashSet<String> = HashSet::new();
492 let dependent_set: HashSet<String> = dependents.iter().map(|d| d.module.clone()).collect();
493
494 for coupling in &metrics.couplings {
495 if coupling.distance == Distance::DifferentCrate {
496 continue;
497 }
498 if dependent_set.contains(&coupling.target) && coupling.source != module {
499 second_order.insert(coupling.source.clone());
500 }
501 }
502 for dep in &dependent_set {
504 second_order.remove(dep);
505 }
506
507 let total_affected = dependents.len() + second_order.len();
508 let total_internal_modules = metrics.modules.len();
509 let percentage = if total_internal_modules > 0 {
510 (total_affected as f64 / total_internal_modules as f64) * 100.0
511 } else {
512 0.0
513 };
514
515 let mut risk_score: u32 = 0;
517 risk_score += (dependents.len() as u32) * 10; risk_score += (second_order.len() as u32) * 5; if in_cycle {
520 risk_score += 30;
521 }
522 match volatility_max {
523 crate::metrics::Volatility::High => risk_score += 20,
524 crate::metrics::Volatility::Medium => risk_score += 10,
525 crate::metrics::Volatility::Low => {}
526 }
527 risk_score = risk_score.min(100);
528
529 let risk_level = if risk_score >= 70 {
530 "HIGH"
531 } else if risk_score >= 40 {
532 "MEDIUM"
533 } else {
534 "LOW"
535 }
536 .to_string();
537
538 let volatility = format!("{:?}", volatility_max);
539
540 Some(ImpactAnalysis {
541 module: module.clone(),
542 risk_score,
543 risk_level,
544 dependencies,
545 dependents,
546 cascading_impact: CascadingImpact {
547 total_affected,
548 percentage,
549 second_order: second_order.into_iter().collect(),
550 },
551 in_cycle,
552 volatility,
553 })
554}
555
556fn find_module(metrics: &ProjectMetrics, name: &str) -> Option<String> {
557 for coupling in &metrics.couplings {
560 if coupling.source == name {
562 return Some(coupling.source.clone());
563 }
564 if coupling.target == name {
565 return Some(coupling.target.clone());
566 }
567 }
568
569 for coupling in &metrics.couplings {
571 if coupling.source.ends_with(&format!("::{}", name)) {
572 return Some(coupling.source.clone());
573 }
574 if coupling.target.ends_with(&format!("::{}", name)) {
575 return Some(coupling.target.clone());
576 }
577 }
578
579 if metrics.modules.contains_key(name) {
581 return Some(name.to_string());
582 }
583
584 for module_name in metrics.modules.keys() {
586 if module_name.ends_with(name) || module_name.ends_with(&format!("::{}", name)) {
587 return Some(module_name.clone());
588 }
589 }
590
591 None
592}
593
594fn format_strengths(strengths: &[StrengthCount]) -> String {
596 if strengths.is_empty() {
597 return "unknown".to_string();
598 }
599 if strengths.len() == 1 && strengths[0].count == 1 {
600 return strengths[0].strength.clone();
601 }
602 strengths
603 .iter()
604 .map(|s| {
605 if s.count == 1 {
606 s.strength.clone()
607 } else {
608 format!("{}x {}", s.count, s.strength)
609 }
610 })
611 .collect::<Vec<_>>()
612 .join(", ")
613}
614
615pub fn generate_impact_output<W: Write>(
617 metrics: &ProjectMetrics,
618 module_name: &str,
619 writer: &mut W,
620) -> io::Result<bool> {
621 let analysis = match analyze_impact(metrics, module_name) {
622 Some(a) => a,
623 None => {
624 writeln!(writer, "❌ Module '{}' not found.", module_name)?;
625 writeln!(writer)?;
626 writeln!(writer, "Available modules:")?;
627 for (i, name) in metrics.modules.keys().take(10).enumerate() {
628 writeln!(writer, " {}. {}", i + 1, name)?;
629 }
630 if metrics.modules.len() > 10 {
631 writeln!(writer, " ... and {} more", metrics.modules.len() - 10)?;
632 }
633 return Ok(false);
634 }
635 };
636
637 writeln!(writer, "Impact Analysis: {}", analysis.module)?;
638 writeln!(
639 writer,
640 "═══════════════════════════════════════════════════════════"
641 )?;
642
643 let risk_icon = match analysis.risk_level.as_str() {
645 "HIGH" => "🔴",
646 "MEDIUM" => "🟡",
647 _ => "🟢",
648 };
649 writeln!(
650 writer,
651 "Risk Score: {} {} ({}/100)",
652 risk_icon, analysis.risk_level, analysis.risk_score
653 )?;
654
655 if analysis.in_cycle {
656 writeln!(writer, "⚠️ Part of a circular dependency cycle")?;
657 }
658
659 writeln!(writer)?;
660
661 let total_dep_couplings: usize = analysis.dependencies.iter().map(|d| d.total_count).sum();
663 writeln!(
664 writer,
665 "Direct Dependencies ({} modules, {} couplings):",
666 analysis.dependencies.len(),
667 total_dep_couplings
668 )?;
669 if analysis.dependencies.is_empty() {
670 writeln!(writer, " (none)")?;
671 } else {
672 for dep in &analysis.dependencies {
673 let strengths_str = format_strengths(&dep.strengths);
674 writeln!(
675 writer,
676 " → {} ({}, {})",
677 dep.module, strengths_str, dep.distance
678 )?;
679 }
680 }
681
682 writeln!(writer)?;
683
684 let total_dependent_couplings: usize = analysis.dependents.iter().map(|d| d.total_count).sum();
686 writeln!(
687 writer,
688 "Direct Dependents ({} modules, {} couplings):",
689 analysis.dependents.len(),
690 total_dependent_couplings
691 )?;
692 if analysis.dependents.is_empty() {
693 writeln!(writer, " (none)")?;
694 } else {
695 for dep in &analysis.dependents {
696 let strengths_str = format_strengths(&dep.strengths);
697 writeln!(writer, " ← {} ({})", dep.module, strengths_str)?;
698 }
699 }
700
701 writeln!(writer)?;
702
703 writeln!(writer, "Cascading Impact:")?;
705 writeln!(
706 writer,
707 " Total affected: {} modules ({:.1}% of codebase)",
708 analysis.cascading_impact.total_affected, analysis.cascading_impact.percentage
709 )?;
710
711 if !analysis.cascading_impact.second_order.is_empty() {
712 writeln!(writer, " 2nd-order affected:")?;
713 for module in analysis.cascading_impact.second_order.iter().take(5) {
714 writeln!(writer, " - {}", module)?;
715 }
716 if analysis.cascading_impact.second_order.len() > 5 {
717 writeln!(
718 writer,
719 " ... and {} more",
720 analysis.cascading_impact.second_order.len() - 5
721 )?;
722 }
723 }
724
725 Ok(true)
726}
727
728#[derive(Debug, Clone)]
734pub struct CheckConfig {
735 pub min_grade: Option<HealthGrade>,
737 pub max_critical: Option<usize>,
739 pub max_circular: Option<usize>,
741 pub fail_on: Option<Severity>,
743}
744
745impl Default for CheckConfig {
746 fn default() -> Self {
747 Self {
748 min_grade: Some(HealthGrade::C),
749 max_critical: Some(0),
750 max_circular: Some(0),
751 fail_on: None,
752 }
753 }
754}
755
756#[derive(Debug, Clone, Serialize)]
758pub struct CheckResult {
759 pub passed: bool,
760 pub grade: String,
761 pub score: f64,
762 pub critical_count: usize,
763 pub high_count: usize,
764 pub medium_count: usize,
765 pub circular_count: usize,
766 pub failures: Vec<String>,
767}
768
769pub fn run_check(
771 metrics: &ProjectMetrics,
772 thresholds: &IssueThresholds,
773 config: &CheckConfig,
774) -> CheckResult {
775 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
776 let circular_deps = metrics.detect_circular_dependencies();
777
778 let critical_count = *report
779 .issues_by_severity
780 .get(&Severity::Critical)
781 .unwrap_or(&0);
782 let high_count = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
783 let medium_count = *report
784 .issues_by_severity
785 .get(&Severity::Medium)
786 .unwrap_or(&0);
787 let circular_count = circular_deps.len();
788
789 let mut failures: Vec<String> = Vec::new();
790 let mut passed = true;
791
792 if let Some(min_grade) = &config.min_grade {
794 let grade_order = |g: &HealthGrade| match g {
797 HealthGrade::S => 5, HealthGrade::A => 5,
799 HealthGrade::B => 4,
800 HealthGrade::C => 3,
801 HealthGrade::D => 2,
802 HealthGrade::F => 1,
803 };
804 if grade_order(&report.health_grade) < grade_order(min_grade) {
805 passed = false;
806 failures.push(format!(
807 "Grade {:?} is below minimum {:?}",
808 report.health_grade, min_grade
809 ));
810 }
811 }
812
813 if let Some(max) = config.max_critical
815 && critical_count > max
816 {
817 passed = false;
818 failures.push(format!("{} critical issues (max: {})", critical_count, max));
819 }
820
821 if let Some(max) = config.max_circular
823 && circular_count > max
824 {
825 passed = false;
826 failures.push(format!(
827 "{} circular dependencies (max: {})",
828 circular_count, max
829 ));
830 }
831
832 if let Some(fail_severity) = &config.fail_on {
834 let count = match fail_severity {
835 Severity::Critical => critical_count,
836 Severity::High => critical_count + high_count,
837 Severity::Medium => critical_count + high_count + medium_count,
838 Severity::Low => report.issues.len(),
839 };
840 if count > 0 {
841 passed = false;
842 failures.push(format!(
843 "{} issues at {:?} severity or higher",
844 count, fail_severity
845 ));
846 }
847 }
848
849 CheckResult {
850 passed,
851 grade: format!("{:?}", report.health_grade),
852 score: report.average_score,
853 critical_count,
854 high_count,
855 medium_count,
856 circular_count,
857 failures,
858 }
859}
860
861pub fn generate_check_output<W: Write>(
863 metrics: &ProjectMetrics,
864 thresholds: &IssueThresholds,
865 config: &CheckConfig,
866 writer: &mut W,
867) -> io::Result<i32> {
868 let result = run_check(metrics, thresholds, config);
869
870 writeln!(writer, "Coupling Quality Gate")?;
871 writeln!(
872 writer,
873 "═══════════════════════════════════════════════════════════"
874 )?;
875
876 let status = if result.passed {
877 "✅ PASSED"
878 } else {
879 "❌ FAILED"
880 };
881 writeln!(
882 writer,
883 "Grade: {} ({:.0}%) {}",
884 result.grade,
885 result.score * 100.0,
886 status
887 )?;
888
889 writeln!(writer)?;
890 writeln!(writer, "Metrics:")?;
891 writeln!(writer, " Critical issues: {}", result.critical_count)?;
892 writeln!(writer, " High issues: {}", result.high_count)?;
893 writeln!(writer, " Medium issues: {}", result.medium_count)?;
894 writeln!(writer, " Circular dependencies: {}", result.circular_count)?;
895
896 if !result.passed {
897 writeln!(writer)?;
898 writeln!(writer, "Blocking Issues:")?;
899 for failure in &result.failures {
900 writeln!(writer, " - {}", failure)?;
901 }
902 }
903
904 Ok(if result.passed { 0 } else { 1 })
905}
906
907#[derive(Debug, Clone, Serialize)]
913pub struct JsonTemporalCoupling {
914 pub file_a: String,
915 pub file_b: String,
916 pub co_change_count: usize,
917 pub coupling_ratio: f64,
918 pub is_strong: bool,
919}
920
921#[derive(Debug, Clone, Serialize)]
923pub struct JsonOutput {
924 pub summary: JsonSummary,
925 pub hotspots: Vec<Hotspot>,
926 pub issues: Vec<JsonIssue>,
927 pub circular_dependencies: Vec<Vec<String>>,
928 pub temporal_couplings: Vec<JsonTemporalCoupling>,
929 pub modules: Vec<JsonModule>,
930}
931
932#[derive(Debug, Clone, Serialize)]
934pub struct JsonSummary {
935 pub health_grade: String,
936 pub health_score: f64,
937 pub total_modules: usize,
938 pub total_couplings: usize,
939 pub internal_couplings: usize,
940 pub external_couplings: usize,
941 pub critical_issues: usize,
942 pub high_issues: usize,
943 pub medium_issues: usize,
944}
945
946#[derive(Debug, Clone, Serialize)]
948pub struct JsonIssue {
949 pub issue_type: String,
950 pub severity: String,
951 pub source: String,
952 pub target: String,
953 pub description: String,
954 pub suggestion: String,
955 pub balance_score: f64,
956}
957
958#[derive(Debug, Clone, Serialize)]
960pub struct JsonModule {
961 pub name: String,
962 pub file_path: Option<String>,
963 pub couplings_out: usize,
964 pub couplings_in: usize,
965 pub balance_score: f64,
966 pub in_cycle: bool,
967}
968
969pub fn generate_json_output<W: Write>(
971 metrics: &ProjectMetrics,
972 thresholds: &IssueThresholds,
973 writer: &mut W,
974) -> io::Result<()> {
975 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
976 let circular_deps = metrics.detect_circular_dependencies();
977 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
978 let hotspots = calculate_hotspots(metrics, thresholds, 10);
979
980 let mut couplings_out: HashMap<String, usize> = HashMap::new();
982 let mut couplings_in: HashMap<String, usize> = HashMap::new();
983 let mut balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
984 let mut internal_count = 0;
985
986 for coupling in &metrics.couplings {
987 if coupling.distance != Distance::DifferentCrate {
988 internal_count += 1;
989 *couplings_out.entry(coupling.source.clone()).or_default() += 1;
990 *couplings_in.entry(coupling.target.clone()).or_default() += 1;
991 let score = BalanceScore::calculate(coupling);
992 balance_scores
993 .entry(coupling.source.clone())
994 .or_default()
995 .push(score.score);
996 }
997 }
998
999 let external_count = metrics.couplings.len() - internal_count;
1000
1001 let critical = *report
1002 .issues_by_severity
1003 .get(&Severity::Critical)
1004 .unwrap_or(&0);
1005 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
1006 let medium = *report
1007 .issues_by_severity
1008 .get(&Severity::Medium)
1009 .unwrap_or(&0);
1010
1011 let temporal_couplings: Vec<JsonTemporalCoupling> = metrics
1012 .temporal_couplings
1013 .iter()
1014 .take(20)
1015 .map(|tc| JsonTemporalCoupling {
1016 file_a: tc.file_a.clone(),
1017 file_b: tc.file_b.clone(),
1018 co_change_count: tc.co_change_count,
1019 coupling_ratio: tc.coupling_ratio,
1020 is_strong: tc.is_strong(),
1021 })
1022 .collect();
1023
1024 let output = JsonOutput {
1025 summary: JsonSummary {
1026 health_grade: format!("{:?}", report.health_grade),
1027 health_score: report.average_score,
1028 total_modules: metrics.modules.len(),
1029 total_couplings: metrics.couplings.len(),
1030 internal_couplings: internal_count,
1031 external_couplings: external_count,
1032 critical_issues: critical,
1033 high_issues: high,
1034 medium_issues: medium,
1035 },
1036 hotspots,
1037 issues: report
1038 .issues
1039 .iter()
1040 .map(|i| JsonIssue {
1041 issue_type: format!("{}", i.issue_type),
1042 severity: format!("{}", i.severity),
1043 source: i.source.clone(),
1044 target: i.target.clone(),
1045 description: i.description.clone(),
1046 suggestion: format!("{}", i.refactoring),
1047 balance_score: i.balance_score,
1048 })
1049 .collect(),
1050 circular_dependencies: circular_deps,
1051 temporal_couplings,
1052 modules: metrics
1053 .modules
1054 .iter()
1055 .map(|(name, module)| {
1056 let avg_score = balance_scores
1057 .get(name)
1058 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
1059 .unwrap_or(1.0);
1060 JsonModule {
1061 name: name.clone(),
1062 file_path: Some(module.path.display().to_string()),
1063 couplings_out: couplings_out.get(name).copied().unwrap_or(0),
1064 couplings_in: couplings_in.get(name).copied().unwrap_or(0),
1065 balance_score: avg_score,
1066 in_cycle: cycle_modules.contains(name),
1067 }
1068 })
1069 .collect(),
1070 };
1071
1072 let json = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1073 writeln!(writer, "{}", json)?;
1074
1075 Ok(())
1076}
1077
1078pub fn parse_grade(s: &str) -> Option<HealthGrade> {
1084 match s.to_uppercase().as_str() {
1085 "S" => Some(HealthGrade::S),
1086 "A" => Some(HealthGrade::A),
1087 "B" => Some(HealthGrade::B),
1088 "C" => Some(HealthGrade::C),
1089 "D" => Some(HealthGrade::D),
1090 "F" => Some(HealthGrade::F),
1091 _ => None,
1092 }
1093}
1094
1095pub fn parse_severity(s: &str) -> Option<Severity> {
1097 match s.to_lowercase().as_str() {
1098 "critical" => Some(Severity::Critical),
1099 "high" => Some(Severity::High),
1100 "medium" => Some(Severity::Medium),
1101 "low" => Some(Severity::Low),
1102 _ => None,
1103 }
1104}
1105
1106#[derive(Debug, Clone)]
1112pub struct TraceResult {
1113 pub item_name: String,
1115 pub module: String,
1117 pub file_path: String,
1119 pub depends_on: Vec<TraceDependency>,
1121 pub depended_by: Vec<TraceDependency>,
1123 pub recommendation: Option<String>,
1125}
1126
1127#[derive(Debug, Clone)]
1129pub struct TraceDependency {
1130 pub item: String,
1132 pub module: String,
1134 pub dep_type: String,
1136 pub strength: String,
1138 pub file_path: Option<String>,
1140 pub line: usize,
1142}
1143
1144pub fn generate_trace_output<W: Write>(
1146 metrics: &ProjectMetrics,
1147 item_name: &str,
1148 writer: &mut W,
1149) -> io::Result<bool> {
1150 use crate::analyzer::ItemDepType;
1151
1152 let mut found_in_modules: Vec<(&str, &crate::metrics::ModuleMetrics)> = Vec::new();
1154 let mut outgoing: Vec<TraceDependency> = Vec::new();
1155 let mut incoming: Vec<TraceDependency> = Vec::new();
1156
1157 for (module_name, module) in &metrics.modules {
1159 let defines_function = module.function_definitions.contains_key(item_name);
1161 let defines_type = module.type_definitions.contains_key(item_name);
1162
1163 if defines_function || defines_type {
1164 found_in_modules.push((module_name, module));
1165 }
1166
1167 for dep in &module.item_dependencies {
1169 if dep.source_item.contains(item_name) || dep.source_item.ends_with(item_name) {
1170 let strength = match dep.dep_type {
1171 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1172 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1173 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1174 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1175 };
1176 outgoing.push(TraceDependency {
1177 item: dep.target.clone(),
1178 module: dep
1179 .target_module
1180 .clone()
1181 .unwrap_or_else(|| "unknown".to_string()),
1182 dep_type: format!("{:?}", dep.dep_type),
1183 strength: strength.to_string(),
1184 file_path: Some(module.path.display().to_string()),
1185 line: dep.line,
1186 });
1187 }
1188
1189 if dep.target.contains(item_name) || dep.target.ends_with(item_name) {
1191 let strength = match dep.dep_type {
1192 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1193 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1194 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1195 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1196 };
1197 incoming.push(TraceDependency {
1198 item: dep.source_item.clone(),
1199 module: module_name.clone(),
1200 dep_type: format!("{:?}", dep.dep_type),
1201 strength: strength.to_string(),
1202 file_path: Some(module.path.display().to_string()),
1203 line: dep.line,
1204 });
1205 }
1206 }
1207 }
1208
1209 if found_in_modules.is_empty() && outgoing.is_empty() && incoming.is_empty() {
1211 writeln!(writer, "Item '{}' not found.", item_name)?;
1212 writeln!(writer)?;
1213 writeln!(
1214 writer,
1215 "Hint: Try searching with a partial name or check module names:"
1216 )?;
1217
1218 let mut suggestions: Vec<String> = Vec::new();
1220 for (module_name, module) in &metrics.modules {
1221 for func_name in module.function_definitions.keys() {
1222 if func_name.to_lowercase().contains(&item_name.to_lowercase()) {
1223 suggestions.push(format!(" - {} (function in {})", func_name, module_name));
1224 }
1225 }
1226 for type_name in module.type_definitions.keys() {
1227 if type_name.to_lowercase().contains(&item_name.to_lowercase()) {
1228 suggestions.push(format!(" - {} (type in {})", type_name, module_name));
1229 }
1230 }
1231 }
1232
1233 if suggestions.is_empty() {
1234 writeln!(writer, " No similar items found.")?;
1235 } else {
1236 for s in suggestions.iter().take(10) {
1237 writeln!(writer, "{}", s)?;
1238 }
1239 if suggestions.len() > 10 {
1240 writeln!(writer, " ... and {} more", suggestions.len() - 10)?;
1241 }
1242 }
1243
1244 return Ok(false);
1245 }
1246
1247 writeln!(writer, "Dependency Trace: {}", item_name)?;
1249 writeln!(writer, "{}", "═".repeat(50))?;
1250 writeln!(writer)?;
1251
1252 if !found_in_modules.is_empty() {
1254 writeln!(writer, "📍 Defined in:")?;
1255 for (module_name, module) in &found_in_modules {
1256 let item_type = if module.function_definitions.contains_key(item_name) {
1257 "function"
1258 } else {
1259 "type"
1260 };
1261 writeln!(
1262 writer,
1263 " {} ({}) - {}",
1264 module_name,
1265 item_type,
1266 module.path.display()
1267 )?;
1268 }
1269 writeln!(writer)?;
1270 }
1271
1272 writeln!(writer, "📤 Depends on ({} items):", outgoing.len())?;
1274 if outgoing.is_empty() {
1275 writeln!(writer, " (none)")?;
1276 } else {
1277 let mut by_target: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1279 for dep in &outgoing {
1280 by_target.entry(dep.item.clone()).or_default().push(dep);
1281 }
1282
1283 for (target, deps) in by_target.iter().take(15) {
1284 let first = deps[0];
1285 let strength_icon = match first.strength.as_str() {
1286 "Intrusive" => "🔴",
1287 "Functional" => "🟠",
1288 "Model" => "🟡",
1289 "Contract" => "🟢",
1290 _ => "⚪",
1291 };
1292 writeln!(
1293 writer,
1294 " {} {} ({}) - line {}",
1295 strength_icon, target, first.strength, first.line
1296 )?;
1297 }
1298 if by_target.len() > 15 {
1299 writeln!(writer, " ... and {} more", by_target.len() - 15)?;
1300 }
1301 }
1302 writeln!(writer)?;
1303
1304 writeln!(writer, "📥 Depended by ({} items):", incoming.len())?;
1306 if incoming.is_empty() {
1307 writeln!(writer, " (none)")?;
1308 } else {
1309 let mut by_source: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1311 for dep in &incoming {
1312 by_source.entry(dep.item.clone()).or_default().push(dep);
1313 }
1314
1315 for (source, deps) in by_source.iter().take(15) {
1316 let first = deps[0];
1317 let strength_icon = match first.strength.as_str() {
1318 "Intrusive" => "🔴",
1319 "Functional" => "🟠",
1320 "Model" => "🟡",
1321 "Contract" => "🟢",
1322 _ => "⚪",
1323 };
1324 writeln!(
1325 writer,
1326 " {} {} ({}) - {}:{}",
1327 strength_icon,
1328 source,
1329 first.strength,
1330 first.file_path.as_deref().unwrap_or("?"),
1331 first.line
1332 )?;
1333 }
1334 if by_source.len() > 15 {
1335 writeln!(writer, " ... and {} more", by_source.len() - 15)?;
1336 }
1337 }
1338 writeln!(writer)?;
1339
1340 writeln!(writer, "💡 Design Analysis:")?;
1342
1343 let intrusive_out = outgoing
1344 .iter()
1345 .filter(|d| d.strength == "Intrusive")
1346 .count();
1347 let intrusive_in = incoming
1348 .iter()
1349 .filter(|d| d.strength == "Intrusive")
1350 .count();
1351 let total_deps = outgoing.len() + incoming.len();
1352
1353 if total_deps == 0 {
1354 writeln!(writer, " ✅ This item has no tracked dependencies.")?;
1355 } else if intrusive_out > 3 {
1356 writeln!(
1357 writer,
1358 " ⚠️ High intrusive outgoing coupling ({} items)",
1359 intrusive_out
1360 )?;
1361 writeln!(
1362 writer,
1363 " → Consider: Extract interface/trait to reduce direct access"
1364 )?;
1365 writeln!(
1366 writer,
1367 " → Khononov: Strong coupling should be CLOSE (same module)"
1368 )?;
1369 } else if intrusive_in > 5 {
1370 writeln!(
1371 writer,
1372 " ⚠️ High intrusive incoming coupling ({} items depend on internals)",
1373 intrusive_in
1374 )?;
1375 writeln!(
1376 writer,
1377 " → Consider: This item is a hotspot - changes will cascade"
1378 )?;
1379 writeln!(
1380 writer,
1381 " → Khononov: Add stable interface to protect dependents"
1382 )?;
1383 } else if outgoing.len() > 10 {
1384 writeln!(
1385 writer,
1386 " ⚠️ High efferent coupling ({} dependencies)",
1387 outgoing.len()
1388 )?;
1389 writeln!(
1390 writer,
1391 " → Consider: Split into smaller functions with focused responsibilities"
1392 )?;
1393 } else if incoming.len() > 10 {
1394 writeln!(
1395 writer,
1396 " ⚠️ High afferent coupling ({} dependents)",
1397 incoming.len()
1398 )?;
1399 writeln!(
1400 writer,
1401 " → Consider: This is a core component - keep it stable"
1402 )?;
1403 } else {
1404 writeln!(writer, " ✅ Coupling appears balanced.")?;
1405 }
1406
1407 writeln!(writer)?;
1408
1409 writeln!(writer, "🔄 Change Impact:")?;
1411 writeln!(
1412 writer,
1413 " If you modify '{}', you may need to update:",
1414 item_name
1415 )?;
1416 let affected_modules: HashSet<_> = incoming.iter().map(|d| d.module.clone()).collect();
1417 if affected_modules.is_empty() {
1418 writeln!(writer, " (no other modules directly affected)")?;
1419 } else {
1420 for module in affected_modules.iter().take(10) {
1421 writeln!(writer, " • {}", module)?;
1422 }
1423 if affected_modules.len() > 10 {
1424 writeln!(
1425 writer,
1426 " ... and {} more modules",
1427 affected_modules.len() - 10
1428 )?;
1429 }
1430 }
1431
1432 Ok(true)
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438
1439 #[test]
1440 fn test_parse_grade() {
1441 assert_eq!(parse_grade("S"), Some(HealthGrade::S));
1442 assert_eq!(parse_grade("A"), Some(HealthGrade::A));
1443 assert_eq!(parse_grade("b"), Some(HealthGrade::B));
1444 assert_eq!(parse_grade("C"), Some(HealthGrade::C));
1445 assert_eq!(parse_grade("X"), None);
1446 }
1447
1448 #[test]
1449 fn test_parse_severity() {
1450 assert_eq!(parse_severity("critical"), Some(Severity::Critical));
1451 assert_eq!(parse_severity("HIGH"), Some(Severity::High));
1452 assert_eq!(parse_severity("invalid"), None);
1453 }
1454
1455 #[test]
1456 fn test_empty_metrics_hotspots() {
1457 let metrics = ProjectMetrics::new();
1458 let thresholds = IssueThresholds::default();
1459 let hotspots = calculate_hotspots(&metrics, &thresholds, 5);
1460 assert!(hotspots.is_empty());
1461 }
1462
1463 #[test]
1464 fn test_check_passes_on_empty() {
1465 let metrics = ProjectMetrics::new();
1466 let thresholds = IssueThresholds::default();
1467 let config = CheckConfig::default();
1468 let result = run_check(&metrics, &thresholds, &config);
1469 assert!(result.passed);
1470 }
1471}