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