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 JsonOutput {
914 pub summary: JsonSummary,
915 pub hotspots: Vec<Hotspot>,
916 pub issues: Vec<JsonIssue>,
917 pub circular_dependencies: Vec<Vec<String>>,
918 pub modules: Vec<JsonModule>,
919}
920
921#[derive(Debug, Clone, Serialize)]
923pub struct JsonSummary {
924 pub health_grade: String,
925 pub health_score: f64,
926 pub total_modules: usize,
927 pub total_couplings: usize,
928 pub internal_couplings: usize,
929 pub external_couplings: usize,
930 pub critical_issues: usize,
931 pub high_issues: usize,
932 pub medium_issues: usize,
933}
934
935#[derive(Debug, Clone, Serialize)]
937pub struct JsonIssue {
938 pub issue_type: String,
939 pub severity: String,
940 pub source: String,
941 pub target: String,
942 pub description: String,
943 pub suggestion: String,
944 pub balance_score: f64,
945}
946
947#[derive(Debug, Clone, Serialize)]
949pub struct JsonModule {
950 pub name: String,
951 pub file_path: Option<String>,
952 pub couplings_out: usize,
953 pub couplings_in: usize,
954 pub balance_score: f64,
955 pub in_cycle: bool,
956}
957
958pub fn generate_json_output<W: Write>(
960 metrics: &ProjectMetrics,
961 thresholds: &IssueThresholds,
962 writer: &mut W,
963) -> io::Result<()> {
964 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
965 let circular_deps = metrics.detect_circular_dependencies();
966 let cycle_modules: HashSet<String> = circular_deps.iter().flatten().cloned().collect();
967 let hotspots = calculate_hotspots(metrics, thresholds, 10);
968
969 let mut couplings_out: HashMap<String, usize> = HashMap::new();
971 let mut couplings_in: HashMap<String, usize> = HashMap::new();
972 let mut balance_scores: HashMap<String, Vec<f64>> = HashMap::new();
973 let mut internal_count = 0;
974
975 for coupling in &metrics.couplings {
976 if coupling.distance != Distance::DifferentCrate {
977 internal_count += 1;
978 *couplings_out.entry(coupling.source.clone()).or_default() += 1;
979 *couplings_in.entry(coupling.target.clone()).or_default() += 1;
980 let score = BalanceScore::calculate(coupling);
981 balance_scores
982 .entry(coupling.source.clone())
983 .or_default()
984 .push(score.score);
985 }
986 }
987
988 let external_count = metrics.couplings.len() - internal_count;
989
990 let critical = *report
991 .issues_by_severity
992 .get(&Severity::Critical)
993 .unwrap_or(&0);
994 let high = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
995 let medium = *report
996 .issues_by_severity
997 .get(&Severity::Medium)
998 .unwrap_or(&0);
999
1000 let output = JsonOutput {
1001 summary: JsonSummary {
1002 health_grade: format!("{:?}", report.health_grade),
1003 health_score: report.average_score,
1004 total_modules: metrics.modules.len(),
1005 total_couplings: metrics.couplings.len(),
1006 internal_couplings: internal_count,
1007 external_couplings: external_count,
1008 critical_issues: critical,
1009 high_issues: high,
1010 medium_issues: medium,
1011 },
1012 hotspots,
1013 issues: report
1014 .issues
1015 .iter()
1016 .map(|i| JsonIssue {
1017 issue_type: format!("{}", i.issue_type),
1018 severity: format!("{}", i.severity),
1019 source: i.source.clone(),
1020 target: i.target.clone(),
1021 description: i.description.clone(),
1022 suggestion: format!("{}", i.refactoring),
1023 balance_score: i.balance_score,
1024 })
1025 .collect(),
1026 circular_dependencies: circular_deps,
1027 modules: metrics
1028 .modules
1029 .iter()
1030 .map(|(name, module)| {
1031 let avg_score = balance_scores
1032 .get(name)
1033 .map(|scores| scores.iter().sum::<f64>() / scores.len() as f64)
1034 .unwrap_or(1.0);
1035 JsonModule {
1036 name: name.clone(),
1037 file_path: Some(module.path.display().to_string()),
1038 couplings_out: couplings_out.get(name).copied().unwrap_or(0),
1039 couplings_in: couplings_in.get(name).copied().unwrap_or(0),
1040 balance_score: avg_score,
1041 in_cycle: cycle_modules.contains(name),
1042 }
1043 })
1044 .collect(),
1045 };
1046
1047 let json = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1048 writeln!(writer, "{}", json)?;
1049
1050 Ok(())
1051}
1052
1053pub fn parse_grade(s: &str) -> Option<HealthGrade> {
1059 match s.to_uppercase().as_str() {
1060 "S" => Some(HealthGrade::S),
1061 "A" => Some(HealthGrade::A),
1062 "B" => Some(HealthGrade::B),
1063 "C" => Some(HealthGrade::C),
1064 "D" => Some(HealthGrade::D),
1065 "F" => Some(HealthGrade::F),
1066 _ => None,
1067 }
1068}
1069
1070pub fn parse_severity(s: &str) -> Option<Severity> {
1072 match s.to_lowercase().as_str() {
1073 "critical" => Some(Severity::Critical),
1074 "high" => Some(Severity::High),
1075 "medium" => Some(Severity::Medium),
1076 "low" => Some(Severity::Low),
1077 _ => None,
1078 }
1079}
1080
1081#[derive(Debug, Clone)]
1087pub struct TraceResult {
1088 pub item_name: String,
1090 pub module: String,
1092 pub file_path: String,
1094 pub depends_on: Vec<TraceDependency>,
1096 pub depended_by: Vec<TraceDependency>,
1098 pub recommendation: Option<String>,
1100}
1101
1102#[derive(Debug, Clone)]
1104pub struct TraceDependency {
1105 pub item: String,
1107 pub module: String,
1109 pub dep_type: String,
1111 pub strength: String,
1113 pub file_path: Option<String>,
1115 pub line: usize,
1117}
1118
1119pub fn generate_trace_output<W: Write>(
1121 metrics: &ProjectMetrics,
1122 item_name: &str,
1123 writer: &mut W,
1124) -> io::Result<bool> {
1125 use crate::analyzer::ItemDepType;
1126
1127 let mut found_in_modules: Vec<(&str, &crate::metrics::ModuleMetrics)> = Vec::new();
1129 let mut outgoing: Vec<TraceDependency> = Vec::new();
1130 let mut incoming: Vec<TraceDependency> = Vec::new();
1131
1132 for (module_name, module) in &metrics.modules {
1134 let defines_function = module.function_definitions.contains_key(item_name);
1136 let defines_type = module.type_definitions.contains_key(item_name);
1137
1138 if defines_function || defines_type {
1139 found_in_modules.push((module_name, module));
1140 }
1141
1142 for dep in &module.item_dependencies {
1144 if dep.source_item.contains(item_name) || dep.source_item.ends_with(item_name) {
1145 let strength = match dep.dep_type {
1146 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1147 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1148 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1149 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1150 };
1151 outgoing.push(TraceDependency {
1152 item: dep.target.clone(),
1153 module: dep
1154 .target_module
1155 .clone()
1156 .unwrap_or_else(|| "unknown".to_string()),
1157 dep_type: format!("{:?}", dep.dep_type),
1158 strength: strength.to_string(),
1159 file_path: Some(module.path.display().to_string()),
1160 line: dep.line,
1161 });
1162 }
1163
1164 if dep.target.contains(item_name) || dep.target.ends_with(item_name) {
1166 let strength = match dep.dep_type {
1167 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
1168 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
1169 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
1170 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
1171 };
1172 incoming.push(TraceDependency {
1173 item: dep.source_item.clone(),
1174 module: module_name.clone(),
1175 dep_type: format!("{:?}", dep.dep_type),
1176 strength: strength.to_string(),
1177 file_path: Some(module.path.display().to_string()),
1178 line: dep.line,
1179 });
1180 }
1181 }
1182 }
1183
1184 if found_in_modules.is_empty() && outgoing.is_empty() && incoming.is_empty() {
1186 writeln!(writer, "Item '{}' not found.", item_name)?;
1187 writeln!(writer)?;
1188 writeln!(
1189 writer,
1190 "Hint: Try searching with a partial name or check module names:"
1191 )?;
1192
1193 let mut suggestions: Vec<String> = Vec::new();
1195 for (module_name, module) in &metrics.modules {
1196 for func_name in module.function_definitions.keys() {
1197 if func_name.to_lowercase().contains(&item_name.to_lowercase()) {
1198 suggestions.push(format!(" - {} (function in {})", func_name, module_name));
1199 }
1200 }
1201 for type_name in module.type_definitions.keys() {
1202 if type_name.to_lowercase().contains(&item_name.to_lowercase()) {
1203 suggestions.push(format!(" - {} (type in {})", type_name, module_name));
1204 }
1205 }
1206 }
1207
1208 if suggestions.is_empty() {
1209 writeln!(writer, " No similar items found.")?;
1210 } else {
1211 for s in suggestions.iter().take(10) {
1212 writeln!(writer, "{}", s)?;
1213 }
1214 if suggestions.len() > 10 {
1215 writeln!(writer, " ... and {} more", suggestions.len() - 10)?;
1216 }
1217 }
1218
1219 return Ok(false);
1220 }
1221
1222 writeln!(writer, "Dependency Trace: {}", item_name)?;
1224 writeln!(writer, "{}", "═".repeat(50))?;
1225 writeln!(writer)?;
1226
1227 if !found_in_modules.is_empty() {
1229 writeln!(writer, "📍 Defined in:")?;
1230 for (module_name, module) in &found_in_modules {
1231 let item_type = if module.function_definitions.contains_key(item_name) {
1232 "function"
1233 } else {
1234 "type"
1235 };
1236 writeln!(
1237 writer,
1238 " {} ({}) - {}",
1239 module_name,
1240 item_type,
1241 module.path.display()
1242 )?;
1243 }
1244 writeln!(writer)?;
1245 }
1246
1247 writeln!(writer, "📤 Depends on ({} items):", outgoing.len())?;
1249 if outgoing.is_empty() {
1250 writeln!(writer, " (none)")?;
1251 } else {
1252 let mut by_target: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1254 for dep in &outgoing {
1255 by_target.entry(dep.item.clone()).or_default().push(dep);
1256 }
1257
1258 for (target, deps) in by_target.iter().take(15) {
1259 let first = deps[0];
1260 let strength_icon = match first.strength.as_str() {
1261 "Intrusive" => "🔴",
1262 "Functional" => "🟠",
1263 "Model" => "🟡",
1264 "Contract" => "🟢",
1265 _ => "⚪",
1266 };
1267 writeln!(
1268 writer,
1269 " {} {} ({}) - line {}",
1270 strength_icon, target, first.strength, first.line
1271 )?;
1272 }
1273 if by_target.len() > 15 {
1274 writeln!(writer, " ... and {} more", by_target.len() - 15)?;
1275 }
1276 }
1277 writeln!(writer)?;
1278
1279 writeln!(writer, "📥 Depended by ({} items):", incoming.len())?;
1281 if incoming.is_empty() {
1282 writeln!(writer, " (none)")?;
1283 } else {
1284 let mut by_source: HashMap<String, Vec<&TraceDependency>> = HashMap::new();
1286 for dep in &incoming {
1287 by_source.entry(dep.item.clone()).or_default().push(dep);
1288 }
1289
1290 for (source, deps) in by_source.iter().take(15) {
1291 let first = deps[0];
1292 let strength_icon = match first.strength.as_str() {
1293 "Intrusive" => "🔴",
1294 "Functional" => "🟠",
1295 "Model" => "🟡",
1296 "Contract" => "🟢",
1297 _ => "⚪",
1298 };
1299 writeln!(
1300 writer,
1301 " {} {} ({}) - {}:{}",
1302 strength_icon,
1303 source,
1304 first.strength,
1305 first.file_path.as_deref().unwrap_or("?"),
1306 first.line
1307 )?;
1308 }
1309 if by_source.len() > 15 {
1310 writeln!(writer, " ... and {} more", by_source.len() - 15)?;
1311 }
1312 }
1313 writeln!(writer)?;
1314
1315 writeln!(writer, "💡 Design Analysis:")?;
1317
1318 let intrusive_out = outgoing
1319 .iter()
1320 .filter(|d| d.strength == "Intrusive")
1321 .count();
1322 let intrusive_in = incoming
1323 .iter()
1324 .filter(|d| d.strength == "Intrusive")
1325 .count();
1326 let total_deps = outgoing.len() + incoming.len();
1327
1328 if total_deps == 0 {
1329 writeln!(writer, " ✅ This item has no tracked dependencies.")?;
1330 } else if intrusive_out > 3 {
1331 writeln!(
1332 writer,
1333 " ⚠️ High intrusive outgoing coupling ({} items)",
1334 intrusive_out
1335 )?;
1336 writeln!(
1337 writer,
1338 " → Consider: Extract interface/trait to reduce direct access"
1339 )?;
1340 writeln!(
1341 writer,
1342 " → Khononov: Strong coupling should be CLOSE (same module)"
1343 )?;
1344 } else if intrusive_in > 5 {
1345 writeln!(
1346 writer,
1347 " ⚠️ High intrusive incoming coupling ({} items depend on internals)",
1348 intrusive_in
1349 )?;
1350 writeln!(
1351 writer,
1352 " → Consider: This item is a hotspot - changes will cascade"
1353 )?;
1354 writeln!(
1355 writer,
1356 " → Khononov: Add stable interface to protect dependents"
1357 )?;
1358 } else if outgoing.len() > 10 {
1359 writeln!(
1360 writer,
1361 " ⚠️ High efferent coupling ({} dependencies)",
1362 outgoing.len()
1363 )?;
1364 writeln!(
1365 writer,
1366 " → Consider: Split into smaller functions with focused responsibilities"
1367 )?;
1368 } else if incoming.len() > 10 {
1369 writeln!(
1370 writer,
1371 " ⚠️ High afferent coupling ({} dependents)",
1372 incoming.len()
1373 )?;
1374 writeln!(
1375 writer,
1376 " → Consider: This is a core component - keep it stable"
1377 )?;
1378 } else {
1379 writeln!(writer, " ✅ Coupling appears balanced.")?;
1380 }
1381
1382 writeln!(writer)?;
1383
1384 writeln!(writer, "🔄 Change Impact:")?;
1386 writeln!(
1387 writer,
1388 " If you modify '{}', you may need to update:",
1389 item_name
1390 )?;
1391 let affected_modules: HashSet<_> = incoming.iter().map(|d| d.module.clone()).collect();
1392 if affected_modules.is_empty() {
1393 writeln!(writer, " (no other modules directly affected)")?;
1394 } else {
1395 for module in affected_modules.iter().take(10) {
1396 writeln!(writer, " • {}", module)?;
1397 }
1398 if affected_modules.len() > 10 {
1399 writeln!(
1400 writer,
1401 " ... and {} more modules",
1402 affected_modules.len() - 10
1403 )?;
1404 }
1405 }
1406
1407 Ok(true)
1408}
1409
1410#[cfg(test)]
1411mod tests {
1412 use super::*;
1413
1414 #[test]
1415 fn test_parse_grade() {
1416 assert_eq!(parse_grade("S"), Some(HealthGrade::S));
1417 assert_eq!(parse_grade("A"), Some(HealthGrade::A));
1418 assert_eq!(parse_grade("b"), Some(HealthGrade::B));
1419 assert_eq!(parse_grade("C"), Some(HealthGrade::C));
1420 assert_eq!(parse_grade("X"), None);
1421 }
1422
1423 #[test]
1424 fn test_parse_severity() {
1425 assert_eq!(parse_severity("critical"), Some(Severity::Critical));
1426 assert_eq!(parse_severity("HIGH"), Some(Severity::High));
1427 assert_eq!(parse_severity("invalid"), None);
1428 }
1429
1430 #[test]
1431 fn test_empty_metrics_hotspots() {
1432 let metrics = ProjectMetrics::new();
1433 let thresholds = IssueThresholds::default();
1434 let hotspots = calculate_hotspots(&metrics, &thresholds, 5);
1435 assert!(hotspots.is_empty());
1436 }
1437
1438 #[test]
1439 fn test_check_passes_on_empty() {
1440 let metrics = ProjectMetrics::new();
1441 let thresholds = IssueThresholds::default();
1442 let config = CheckConfig::default();
1443 let result = run_check(&metrics, &thresholds, &config);
1444 assert!(result.passed);
1445 }
1446}