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 {
795 HealthGrade::A => 5,
796 HealthGrade::B => 4,
797 HealthGrade::C => 3,
798 HealthGrade::D => 2,
799 HealthGrade::F => 1,
800 };
801 if grade_order(&report.health_grade) < grade_order(min_grade) {
802 passed = false;
803 failures.push(format!(
804 "Grade {:?} is below minimum {:?}",
805 report.health_grade, min_grade
806 ));
807 }
808 }
809
810 if let Some(max) = config.max_critical
812 && critical_count > max
813 {
814 passed = false;
815 failures.push(format!("{} critical issues (max: {})", critical_count, max));
816 }
817
818 if let Some(max) = config.max_circular
820 && circular_count > max
821 {
822 passed = false;
823 failures.push(format!(
824 "{} circular dependencies (max: {})",
825 circular_count, max
826 ));
827 }
828
829 if let Some(fail_severity) = &config.fail_on {
831 let count = match fail_severity {
832 Severity::Critical => critical_count,
833 Severity::High => critical_count + high_count,
834 Severity::Medium => critical_count + high_count + medium_count,
835 Severity::Low => report.issues.len(),
836 };
837 if count > 0 {
838 passed = false;
839 failures.push(format!(
840 "{} issues at {:?} severity or higher",
841 count, fail_severity
842 ));
843 }
844 }
845
846 CheckResult {
847 passed,
848 grade: format!("{:?}", report.health_grade),
849 score: report.average_score,
850 critical_count,
851 high_count,
852 medium_count,
853 circular_count,
854 failures,
855 }
856}
857
858pub fn generate_check_output<W: Write>(
860 metrics: &ProjectMetrics,
861 thresholds: &IssueThresholds,
862 config: &CheckConfig,
863 writer: &mut W,
864) -> io::Result<i32> {
865 let result = run_check(metrics, thresholds, config);
866
867 writeln!(writer, "Coupling Quality Gate")?;
868 writeln!(
869 writer,
870 "═══════════════════════════════════════════════════════════"
871 )?;
872
873 let status = if result.passed {
874 "✅ PASSED"
875 } else {
876 "❌ FAILED"
877 };
878 writeln!(
879 writer,
880 "Grade: {} ({:.0}%) {}",
881 result.grade,
882 result.score * 100.0,
883 status
884 )?;
885
886 writeln!(writer)?;
887 writeln!(writer, "Metrics:")?;
888 writeln!(writer, " Critical issues: {}", result.critical_count)?;
889 writeln!(writer, " High issues: {}", result.high_count)?;
890 writeln!(writer, " Medium issues: {}", result.medium_count)?;
891 writeln!(writer, " Circular dependencies: {}", result.circular_count)?;
892
893 if !result.passed {
894 writeln!(writer)?;
895 writeln!(writer, "Blocking Issues:")?;
896 for failure in &result.failures {
897 writeln!(writer, " - {}", failure)?;
898 }
899 }
900
901 Ok(if result.passed { 0 } else { 1 })
902}
903
904#[derive(Debug, Clone, Serialize)]
910pub struct JsonOutput {
911 pub summary: JsonSummary,
912 pub hotspots: Vec<Hotspot>,
913 pub issues: Vec<JsonIssue>,
914 pub circular_dependencies: Vec<Vec<String>>,
915 pub modules: Vec<JsonModule>,
916}
917
918#[derive(Debug, Clone, Serialize)]
920pub struct JsonSummary {
921 pub health_grade: String,
922 pub health_score: f64,
923 pub total_modules: usize,
924 pub total_couplings: usize,
925 pub internal_couplings: usize,
926 pub external_couplings: usize,
927 pub critical_issues: usize,
928 pub high_issues: usize,
929 pub medium_issues: usize,
930}
931
932#[derive(Debug, Clone, Serialize)]
934pub struct JsonIssue {
935 pub issue_type: String,
936 pub severity: String,
937 pub source: String,
938 pub target: String,
939 pub description: String,
940 pub suggestion: String,
941 pub balance_score: f64,
942}
943
944#[derive(Debug, Clone, Serialize)]
946pub struct JsonModule {
947 pub name: String,
948 pub file_path: Option<String>,
949 pub couplings_out: usize,
950 pub couplings_in: usize,
951 pub balance_score: f64,
952 pub in_cycle: bool,
953}
954
955pub fn generate_json_output<W: Write>(
957 metrics: &ProjectMetrics,
958 thresholds: &IssueThresholds,
959 writer: &mut W,
960) -> io::Result<()> {
961 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
962 let circular_deps = metrics.detect_circular_dependencies();
963 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
964 let hotspots = calculate_hotspots(metrics, thresholds, 10);
965
966 let mut couplings_out: HashMap<String, usize> = HashMap::new();
968 let mut couplings_in: HashMap<String, usize> = HashMap::new();
969 let mut balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
970 let mut internal_count = 0;
971
972 for coupling in &metrics.couplings {
973 if coupling.distance != Distance::DifferentCrate {
974 internal_count += 1;
975 *couplings_out.entry(coupling.source.clone()).or_default() += 1;
976 *couplings_in.entry(coupling.target.clone()).or_default() += 1;
977 let score = BalanceScore::calculate(coupling);
978 balance_scores
979 .entry(coupling.source.clone())
980 .or_default()
981 .push(score.score);
982 }
983 }
984
985 let external_count = metrics.couplings.len() - internal_count;
986
987 let critical = *report
988 .issues_by_severity
989 .get(&Severity::Critical)
990 .unwrap_or(&0);
991 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
992 let medium = *report
993 .issues_by_severity
994 .get(&Severity::Medium)
995 .unwrap_or(&0);
996
997 let output = JsonOutput {
998 summary: JsonSummary {
999 health_grade: format!("{:?}", report.health_grade),
1000 health_score: report.average_score,
1001 total_modules: metrics.modules.len(),
1002 total_couplings: metrics.couplings.len(),
1003 internal_couplings: internal_count,
1004 external_couplings: external_count,
1005 critical_issues: critical,
1006 high_issues: high,
1007 medium_issues: medium,
1008 },
1009 hotspots,
1010 issues: report
1011 .issues
1012 .iter()
1013 .map(|i| JsonIssue {
1014 issue_type: format!("{}", i.issue_type),
1015 severity: format!("{}", i.severity),
1016 source: i.source.clone(),
1017 target: i.target.clone(),
1018 description: i.description.clone(),
1019 suggestion: format!("{}", i.refactoring),
1020 balance_score: i.balance_score,
1021 })
1022 .collect(),
1023 circular_dependencies: circular_deps,
1024 modules: metrics
1025 .modules
1026 .iter()
1027 .map(|(name, module)| {
1028 let avg_score = balance_scores
1029 .get(name)
1030 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
1031 .unwrap_or(1.0);
1032 JsonModule {
1033 name: name.clone(),
1034 file_path: Some(module.path.display().to_string()),
1035 couplings_out: couplings_out.get(name).copied().unwrap_or(0),
1036 couplings_in: couplings_in.get(name).copied().unwrap_or(0),
1037 balance_score: avg_score,
1038 in_cycle: cycle_modules.contains(name),
1039 }
1040 })
1041 .collect(),
1042 };
1043
1044 let json = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1045 writeln!(writer, "{}", json)?;
1046
1047 Ok(())
1048}
1049
1050pub fn parse_grade(s: &str) -> Option<HealthGrade> {
1056 match s.to_uppercase().as_str() {
1057 "A" => Some(HealthGrade::A),
1058 "B" => Some(HealthGrade::B),
1059 "C" => Some(HealthGrade::C),
1060 "D" => Some(HealthGrade::D),
1061 "F" => Some(HealthGrade::F),
1062 _ => None,
1063 }
1064}
1065
1066pub fn parse_severity(s: &str) -> Option<Severity> {
1068 match s.to_lowercase().as_str() {
1069 "critical" => Some(Severity::Critical),
1070 "high" => Some(Severity::High),
1071 "medium" => Some(Severity::Medium),
1072 "low" => Some(Severity::Low),
1073 _ => None,
1074 }
1075}
1076
1077#[derive(Debug, Clone)]
1083pub struct TraceResult {
1084 pub item_name: String,
1086 pub module: String,
1088 pub file_path: String,
1090 pub depends_on: Vec<TraceDependency>,
1092 pub depended_by: Vec<TraceDependency>,
1094 pub recommendation: Option<String>,
1096}
1097
1098#[derive(Debug, Clone)]
1100pub struct TraceDependency {
1101 pub item: String,
1103 pub module: String,
1105 pub dep_type: String,
1107 pub strength: String,
1109 pub file_path: Option<String>,
1111 pub line: usize,
1113}
1114
1115pub fn generate_trace_output<W: Write>(
1117 metrics: &ProjectMetrics,
1118 item_name: &str,
1119 writer: &mut W,
1120) -> io::Result<bool> {
1121 use crate::analyzer::ItemDepType;
1122
1123 let mut found_in_modules: Vec<(&str, &crate::metrics::ModuleMetrics)> = Vec::new();
1125 let mut outgoing: Vec<TraceDependency> = Vec::new();
1126 let mut incoming: Vec<TraceDependency> = Vec::new();
1127
1128 for (module_name, module) in &metrics.modules {
1130 let defines_function = module.function_definitions.contains_key(item_name);
1132 let defines_type = module.type_definitions.contains_key(item_name);
1133
1134 if defines_function || defines_type {
1135 found_in_modules.push((module_name, module));
1136 }
1137
1138 for dep in &module.item_dependencies {
1140 if dep.source_item.contains(item_name) || dep.source_item.ends_with(item_name) {
1141 let strength = match dep.dep_type {
1142 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1143 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1144 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1145 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1146 };
1147 outgoing.push(TraceDependency {
1148 item: dep.target.clone(),
1149 module: dep
1150 .target_module
1151 .clone()
1152 .unwrap_or_else(|| "unknown".to_string()),
1153 dep_type: format!("{:?}", dep.dep_type),
1154 strength: strength.to_string(),
1155 file_path: Some(module.path.display().to_string()),
1156 line: dep.line,
1157 });
1158 }
1159
1160 if dep.target.contains(item_name) || dep.target.ends_with(item_name) {
1162 let strength = match dep.dep_type {
1163 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1164 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1165 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1166 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1167 };
1168 incoming.push(TraceDependency {
1169 item: dep.source_item.clone(),
1170 module: module_name.clone(),
1171 dep_type: format!("{:?}", dep.dep_type),
1172 strength: strength.to_string(),
1173 file_path: Some(module.path.display().to_string()),
1174 line: dep.line,
1175 });
1176 }
1177 }
1178 }
1179
1180 if found_in_modules.is_empty() && outgoing.is_empty() && incoming.is_empty() {
1182 writeln!(writer, "Item '{}' not found.", item_name)?;
1183 writeln!(writer)?;
1184 writeln!(
1185 writer,
1186 "Hint: Try searching with a partial name or check module names:"
1187 )?;
1188
1189 let mut suggestions: Vec<String> = Vec::new();
1191 for (module_name, module) in &metrics.modules {
1192 for func_name in module.function_definitions.keys() {
1193 if func_name.to_lowercase().contains(&item_name.to_lowercase()) {
1194 suggestions.push(format!(" - {} (function in {})", func_name, module_name));
1195 }
1196 }
1197 for type_name in module.type_definitions.keys() {
1198 if type_name.to_lowercase().contains(&item_name.to_lowercase()) {
1199 suggestions.push(format!(" - {} (type in {})", type_name, module_name));
1200 }
1201 }
1202 }
1203
1204 if suggestions.is_empty() {
1205 writeln!(writer, " No similar items found.")?;
1206 } else {
1207 for s in suggestions.iter().take(10) {
1208 writeln!(writer, "{}", s)?;
1209 }
1210 if suggestions.len() > 10 {
1211 writeln!(writer, " ... and {} more", suggestions.len() - 10)?;
1212 }
1213 }
1214
1215 return Ok(false);
1216 }
1217
1218 writeln!(writer, "Dependency Trace: {}", item_name)?;
1220 writeln!(writer, "{}", "═".repeat(50))?;
1221 writeln!(writer)?;
1222
1223 if !found_in_modules.is_empty() {
1225 writeln!(writer, "📍 Defined in:")?;
1226 for (module_name, module) in &found_in_modules {
1227 let item_type = if module.function_definitions.contains_key(item_name) {
1228 "function"
1229 } else {
1230 "type"
1231 };
1232 writeln!(
1233 writer,
1234 " {} ({}) - {}",
1235 module_name,
1236 item_type,
1237 module.path.display()
1238 )?;
1239 }
1240 writeln!(writer)?;
1241 }
1242
1243 writeln!(writer, "📤 Depends on ({} items):", outgoing.len())?;
1245 if outgoing.is_empty() {
1246 writeln!(writer, " (none)")?;
1247 } else {
1248 let mut by_target: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1250 for dep in &outgoing {
1251 by_target.entry(dep.item.clone()).or_default().push(dep);
1252 }
1253
1254 for (target, deps) in by_target.iter().take(15) {
1255 let first = deps[0];
1256 let strength_icon = match first.strength.as_str() {
1257 "Intrusive" => "🔴",
1258 "Functional" => "🟠",
1259 "Model" => "🟡",
1260 "Contract" => "🟢",
1261 _ => "⚪",
1262 };
1263 writeln!(
1264 writer,
1265 " {} {} ({}) - line {}",
1266 strength_icon, target, first.strength, first.line
1267 )?;
1268 }
1269 if by_target.len() > 15 {
1270 writeln!(writer, " ... and {} more", by_target.len() - 15)?;
1271 }
1272 }
1273 writeln!(writer)?;
1274
1275 writeln!(writer, "📥 Depended by ({} items):", incoming.len())?;
1277 if incoming.is_empty() {
1278 writeln!(writer, " (none)")?;
1279 } else {
1280 let mut by_source: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1282 for dep in &incoming {
1283 by_source.entry(dep.item.clone()).or_default().push(dep);
1284 }
1285
1286 for (source, deps) in by_source.iter().take(15) {
1287 let first = deps[0];
1288 let strength_icon = match first.strength.as_str() {
1289 "Intrusive" => "🔴",
1290 "Functional" => "🟠",
1291 "Model" => "🟡",
1292 "Contract" => "🟢",
1293 _ => "⚪",
1294 };
1295 writeln!(
1296 writer,
1297 " {} {} ({}) - {}:{}",
1298 strength_icon,
1299 source,
1300 first.strength,
1301 first.file_path.as_deref().unwrap_or("?"),
1302 first.line
1303 )?;
1304 }
1305 if by_source.len() > 15 {
1306 writeln!(writer, " ... and {} more", by_source.len() - 15)?;
1307 }
1308 }
1309 writeln!(writer)?;
1310
1311 writeln!(writer, "💡 Design Analysis:")?;
1313
1314 let intrusive_out = outgoing
1315 .iter()
1316 .filter(|d| d.strength == "Intrusive")
1317 .count();
1318 let intrusive_in = incoming
1319 .iter()
1320 .filter(|d| d.strength == "Intrusive")
1321 .count();
1322 let total_deps = outgoing.len() + incoming.len();
1323
1324 if total_deps == 0 {
1325 writeln!(writer, " ✅ This item has no tracked dependencies.")?;
1326 } else if intrusive_out > 3 {
1327 writeln!(
1328 writer,
1329 " ⚠️ High intrusive outgoing coupling ({} items)",
1330 intrusive_out
1331 )?;
1332 writeln!(
1333 writer,
1334 " → Consider: Extract interface/trait to reduce direct access"
1335 )?;
1336 writeln!(
1337 writer,
1338 " → Khononov: Strong coupling should be CLOSE (same module)"
1339 )?;
1340 } else if intrusive_in > 5 {
1341 writeln!(
1342 writer,
1343 " ⚠️ High intrusive incoming coupling ({} items depend on internals)",
1344 intrusive_in
1345 )?;
1346 writeln!(
1347 writer,
1348 " → Consider: This item is a hotspot - changes will cascade"
1349 )?;
1350 writeln!(
1351 writer,
1352 " → Khononov: Add stable interface to protect dependents"
1353 )?;
1354 } else if outgoing.len() > 10 {
1355 writeln!(
1356 writer,
1357 " ⚠️ High efferent coupling ({} dependencies)",
1358 outgoing.len()
1359 )?;
1360 writeln!(
1361 writer,
1362 " → Consider: Split into smaller functions with focused responsibilities"
1363 )?;
1364 } else if incoming.len() > 10 {
1365 writeln!(
1366 writer,
1367 " ⚠️ High afferent coupling ({} dependents)",
1368 incoming.len()
1369 )?;
1370 writeln!(
1371 writer,
1372 " → Consider: This is a core component - keep it stable"
1373 )?;
1374 } else {
1375 writeln!(writer, " ✅ Coupling appears balanced.")?;
1376 }
1377
1378 writeln!(writer)?;
1379
1380 writeln!(writer, "🔄 Change Impact:")?;
1382 writeln!(
1383 writer,
1384 " If you modify '{}', you may need to update:",
1385 item_name
1386 )?;
1387 let affected_modules: HashSet<_> = incoming.iter().map(|d| d.module.clone()).collect();
1388 if affected_modules.is_empty() {
1389 writeln!(writer, " (no other modules directly affected)")?;
1390 } else {
1391 for module in affected_modules.iter().take(10) {
1392 writeln!(writer, " • {}", module)?;
1393 }
1394 if affected_modules.len() > 10 {
1395 writeln!(
1396 writer,
1397 " ... and {} more modules",
1398 affected_modules.len() - 10
1399 )?;
1400 }
1401 }
1402
1403 Ok(true)
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use super::*;
1409
1410 #[test]
1411 fn test_parse_grade() {
1412 assert_eq!(parse_grade("A"), Some(HealthGrade::A));
1413 assert_eq!(parse_grade("b"), Some(HealthGrade::B));
1414 assert_eq!(parse_grade("C"), Some(HealthGrade::C));
1415 assert_eq!(parse_grade("X"), None);
1416 }
1417
1418 #[test]
1419 fn test_parse_severity() {
1420 assert_eq!(parse_severity("critical"), Some(Severity::Critical));
1421 assert_eq!(parse_severity("HIGH"), Some(Severity::High));
1422 assert_eq!(parse_severity("invalid"), None);
1423 }
1424
1425 #[test]
1426 fn test_empty_metrics_hotspots() {
1427 let metrics = ProjectMetrics::new();
1428 let thresholds = IssueThresholds::default();
1429 let hotspots = calculate_hotspots(&metrics, &thresholds, 5);
1430 assert!(hotspots.is_empty());
1431 }
1432
1433 #[test]
1434 fn test_check_passes_on_empty() {
1435 let metrics = ProjectMetrics::new();
1436 let thresholds = IssueThresholds::default();
1437 let config = CheckConfig::default();
1438 let result = run_check(&metrics, &thresholds, &config);
1439 assert!(result.passed);
1440 }
1441}