1use std::collections::{BTreeMap, BTreeSet};
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::IssueThresholds;
19use crate::balance::severity::Severity;
20use crate::diff::BaselineDiff;
21use crate::external::ExternalDependencyReport;
22use crate::history::HistoryReport;
23#[cfg(test)]
24use crate::metrics::dimensions::Distance;
25use crate::metrics::project::ProjectMetrics;
26
27mod hotspots;
28mod impact;
29mod json;
30pub use hotspots::{
31 Hotspot, HotspotIssue, IssueExplanation, calculate_hotspots, generate_hotspots_output,
32 get_issue_explanation,
33};
34pub use impact::{
35 CascadingImpact, DependencyInfo, ImpactAnalysis, StrengthCount, analyze_impact,
36 analyze_impact_with_depth, generate_impact_output, generate_impact_output_with_depth,
37};
38#[cfg(test)]
39use impact::{ModuleLookup, find_module};
40use json::json_external_dependencies;
41pub use json::*;
42
43#[derive(Debug, Clone)]
49pub struct CheckConfig {
50 pub min_grade: Option<HealthGrade>,
52 pub max_critical: Option<usize>,
54 pub max_circular: Option<usize>,
56 pub fail_on: Option<Severity>,
58}
59
60impl Default for CheckConfig {
61 fn default() -> Self {
62 Self {
63 min_grade: Some(HealthGrade::C),
64 max_critical: Some(0),
65 max_circular: Some(0),
66 fail_on: None,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Serialize)]
73pub struct CheckResult {
74 pub passed: bool,
75 pub grade: String,
76 pub score: f64,
77 pub critical_count: usize,
78 pub high_count: usize,
79 pub medium_count: usize,
80 pub circular_count: usize,
81 pub failures: Vec<String>,
82}
83
84pub fn run_check(
86 metrics: &ProjectMetrics,
87 thresholds: &IssueThresholds,
88 config: &CheckConfig,
89) -> CheckResult {
90 let report = analyze_project_balance_with_thresholds(metrics, thresholds);
91 let circular_deps = metrics.detect_circular_dependencies();
92
93 let critical_count = *report
94 .issues_by_severity
95 .get(&Severity::Critical)
96 .unwrap_or(&0);
97 let high_count = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
98 let medium_count = *report
99 .issues_by_severity
100 .get(&Severity::Medium)
101 .unwrap_or(&0);
102 let circular_count = circular_deps.len();
103
104 let mut failures: Vec<String> = Vec::new();
105 let mut passed = true;
106
107 if let Some(min_grade) = &config.min_grade {
109 let grade_order = |g: &HealthGrade| match g {
112 HealthGrade::S => 5, HealthGrade::A => 5,
114 HealthGrade::B => 4,
115 HealthGrade::C => 3,
116 HealthGrade::D => 2,
117 HealthGrade::F => 1,
118 };
119 if grade_order(&report.health_grade) < grade_order(min_grade) {
120 passed = false;
121 failures.push(format!(
122 "Grade {:?} is below minimum {:?}",
123 report.health_grade, min_grade
124 ));
125 }
126 }
127
128 if let Some(max) = config.max_critical
130 && critical_count > max
131 {
132 passed = false;
133 failures.push(format!("{} critical issues (max: {})", critical_count, max));
134 }
135
136 if let Some(max) = config.max_circular
138 && circular_count > max
139 {
140 passed = false;
141 failures.push(format!(
142 "{} representative cycle paths (max: {})",
143 circular_count, max
144 ));
145 }
146
147 if let Some(fail_severity) = &config.fail_on {
149 let count = match fail_severity {
150 Severity::Critical => critical_count,
151 Severity::High => critical_count + high_count,
152 Severity::Medium => critical_count + high_count + medium_count,
153 Severity::Low => report.issues.len(),
154 };
155 if count > 0 {
156 passed = false;
157 failures.push(format!(
158 "{} issues at {:?} severity or higher",
159 count, fail_severity
160 ));
161 }
162 }
163
164 CheckResult {
165 passed,
166 grade: report.health_grade.letter().to_string(),
167 score: report.average_score,
168 critical_count,
169 high_count,
170 medium_count,
171 circular_count,
172 failures,
173 }
174}
175
176pub fn generate_check_output<W: Write>(
178 metrics: &ProjectMetrics,
179 thresholds: &IssueThresholds,
180 config: &CheckConfig,
181 writer: &mut W,
182) -> io::Result<i32> {
183 let result = run_check(metrics, thresholds, config);
184
185 writeln!(writer, "Coupling Quality Gate")?;
186 writeln!(
187 writer,
188 "═══════════════════════════════════════════════════════════"
189 )?;
190
191 let status = if result.passed {
192 "✅ PASSED"
193 } else {
194 "❌ FAILED"
195 };
196 writeln!(
197 writer,
198 "Grade: {} ({:.0}%) {}",
199 result.grade,
200 result.score * 100.0,
201 status
202 )?;
203
204 writeln!(writer)?;
205 writeln!(writer, "Metrics:")?;
206 writeln!(writer, " Critical issues: {}", result.critical_count)?;
207 writeln!(writer, " High issues: {}", result.high_count)?;
208 writeln!(writer, " Medium issues: {}", result.medium_count)?;
209 writeln!(
210 writer,
211 " Representative cycle paths: {}",
212 result.circular_count
213 )?;
214
215 if !result.passed {
216 writeln!(writer)?;
217 writeln!(writer, "Blocking Issues:")?;
218 for failure in &result.failures {
219 writeln!(writer, " - {}", failure)?;
220 }
221 }
222
223 Ok(if result.passed { 0 } else { 1 })
224}
225
226pub fn generate_baseline_diff_output<W: Write>(
228 diff: &BaselineDiff,
229 baseline_ref: &str,
230 writer: &mut W,
231) -> io::Result<()> {
232 writeln!(writer, "Coupling Baseline Diff")?;
233 writeln!(
234 writer,
235 "═══════════════════════════════════════════════════════════"
236 )?;
237 writeln!(writer, "Baseline: {}", baseline_ref)?;
238 writeln!(
239 writer,
240 "Grade: {} -> {}",
241 diff.baseline_grade.letter(),
242 diff.current_grade.letter()
243 )?;
244 writeln!(writer, "Score delta: {:+.3}", diff.score_delta)?;
245 writeln!(writer)?;
246 writeln!(writer, "Issues:")?;
247 writeln!(writer, " New: {}", diff.new_issues.len())?;
248 writeln!(writer, " Resolved: {}", diff.resolved_issues.len())?;
249 writeln!(writer, " Worsened: {}", diff.worsened_issues.len())?;
250 writeln!(writer, " Unchanged: {}", diff.unchanged)?;
251
252 write_issue_section(writer, "New Issues", &diff.new_issues)?;
253 write_issue_section(writer, "Resolved Issues", &diff.resolved_issues)?;
254 write_issue_section(writer, "Worsened Issues", &diff.worsened_issues)?;
255
256 Ok(())
257}
258
259pub fn generate_ratchet_check_output<W: Write>(
261 diff: &BaselineDiff,
262 baseline_ref: &str,
263 fail_on: Severity,
264 writer: &mut W,
265) -> io::Result<i32> {
266 let failures = diff.ratchet_failures(fail_on);
267 let passed = failures.is_empty();
268
269 writeln!(writer, "Coupling Ratchet Gate")?;
270 writeln!(
271 writer,
272 "═══════════════════════════════════════════════════════════"
273 )?;
274 writeln!(writer, "Baseline: {}", baseline_ref)?;
275 writeln!(
276 writer,
277 "Grade: {} -> {}",
278 diff.baseline_grade.letter(),
279 diff.current_grade.letter()
280 )?;
281 writeln!(writer, "Score delta: {:+.3}", diff.score_delta)?;
282 writeln!(
283 writer,
284 "New issues: {} (fail-on: {} or higher)",
285 diff.new_issues.len(),
286 fail_on
287 )?;
288 writeln!(writer, "Worsened issues: {}", diff.worsened_issues.len())?;
289 writeln!(
290 writer,
291 "Status: {}",
292 if passed { "PASSED" } else { "FAILED" }
293 )?;
294
295 if !passed {
296 writeln!(writer)?;
297 writeln!(writer, "Blocking New/Worsened Issues:")?;
298 for issue in failures {
299 write_issue_line(writer, issue)?;
300 }
301 }
302
303 Ok(if passed { 0 } else { 1 })
304}
305
306fn write_issue_section<W: Write>(
307 writer: &mut W,
308 title: &str,
309 issues: &[CouplingIssue],
310) -> io::Result<()> {
311 writeln!(writer)?;
312 writeln!(writer, "{}:", title)?;
313 if issues.is_empty() {
314 writeln!(writer, " (none)")?;
315 return Ok(());
316 }
317
318 for issue in issues {
319 write_issue_line(writer, issue)?;
320 }
321 Ok(())
322}
323
324fn write_issue_line<W: Write>(writer: &mut W, issue: &CouplingIssue) -> io::Result<()> {
325 writeln!(
326 writer,
327 " - {} {}: {} -> {}",
328 issue.severity, issue.issue_type, issue.source, issue.target
329 )
330}
331
332pub fn generate_external_dependencies_output<W: Write>(
338 report: &ExternalDependencyReport,
339 json: bool,
340 japanese: bool,
341 writer: &mut W,
342) -> io::Result<()> {
343 if json {
344 let output = JsonExternalDependenciesOutput {
345 external_dependencies: json_external_dependencies(report),
346 };
347 let text = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
348 writeln!(writer, "{}", text)?;
349 return Ok(());
350 }
351
352 if japanese {
353 writeln!(writer, "外部依存の結合")?;
354 } else {
355 writeln!(writer, "External Dependency Coupling")?;
356 }
357 writeln!(
358 writer,
359 "═══════════════════════════════════════════════════════════"
360 )?;
361 let total_references = report
362 .dependencies
363 .iter()
364 .map(|dependency| dependency.total_references)
365 .sum::<usize>();
366 if japanese {
367 writeln!(
368 writer,
369 "外部クレート: {} 直接参照: {}",
370 report.dependencies.len(),
371 total_references
372 )?;
373 } else {
374 writeln!(
375 writer,
376 "External crates: {} Direct references: {}",
377 report.dependencies.len(),
378 total_references
379 )?;
380 }
381
382 if report.dependencies.is_empty() {
383 writeln!(writer)?;
384 if japanese {
385 writeln!(writer, "外部クレートへの結合は検出されませんでした。")?;
386 } else {
387 writeln!(writer, "No external crate couplings detected.")?;
388 }
389 return Ok(());
390 }
391
392 writeln!(writer)?;
393 if japanese {
394 writeln!(writer, "利用モジュール数が多いクレート:")?;
395 } else {
396 writeln!(writer, "Top Crates by Breadth:")?;
397 }
398 for (index, dependency) in report.dependencies.iter().take(10).enumerate() {
399 let version = if dependency.versions.is_empty() {
400 if japanese {
401 "バージョン: 不明".to_string()
402 } else {
403 "version: unknown".to_string()
404 }
405 } else if japanese {
406 format!("バージョン: {}", dependency.versions.join(", "))
407 } else {
408 format!("version: {}", dependency.versions.join(", "))
409 };
410 if japanese {
411 writeln!(
412 writer,
413 "{}. {} ({}; {} モジュール, {} 参照, 主な強度: {})",
414 index + 1,
415 dependency.crate_name,
416 version,
417 dependency.breadth,
418 dependency.total_references,
419 dependency.dominant_strength
420 )?;
421 } else {
422 writeln!(
423 writer,
424 "{}. {} ({}; {} modules, {} references, dominant: {})",
425 index + 1,
426 dependency.crate_name,
427 version,
428 dependency.breadth,
429 dependency.total_references,
430 dependency.dominant_strength
431 )?;
432 }
433 let sample_modules = dependency
434 .source_modules
435 .iter()
436 .take(5)
437 .cloned()
438 .collect::<Vec<_>>()
439 .join(", ");
440 if !sample_modules.is_empty() {
441 if japanese {
442 writeln!(writer, " モジュール: {}", sample_modules)?;
443 } else {
444 writeln!(writer, " modules: {}", sample_modules)?;
445 }
446 }
447 }
448
449 writeln!(writer)?;
450 if japanese {
451 writeln!(writer, "分散した外部結合の警告:")?;
452 } else {
453 writeln!(writer, "Scattered Coupling Flags:")?;
454 }
455 if report.scattered_couplings.is_empty() {
456 if japanese {
457 writeln!(writer, " (なし)")?;
458 } else {
459 writeln!(writer, " (none)")?;
460 }
461 } else {
462 for issue in &report.scattered_couplings {
463 let source = if japanese {
464 issue_source_japanese(issue)
465 } else {
466 issue.source.clone()
467 };
468 writeln!(
469 writer,
470 " - {}: {} -> {}",
471 severity_label(issue.severity, japanese),
472 source,
473 issue.target
474 )?;
475 if japanese {
476 writeln!(writer, " {}", issue_instance_description_japanese(issue))?;
477 writeln!(writer, " 修正: {}", issue_refactoring_japanese(issue))?;
478 } else {
479 writeln!(writer, " {}", issue.description)?;
480 writeln!(writer, " Fix: {}", issue.refactoring)?;
481 }
482 }
483 }
484
485 Ok(())
486}
487
488fn severity_label(severity: Severity, japanese: bool) -> String {
489 if !japanese {
490 return severity.to_string();
491 }
492 match severity {
493 Severity::Critical => "緊急",
494 Severity::High => "高",
495 Severity::Medium => "中",
496 Severity::Low => "低",
497 }
498 .to_string()
499}
500
501fn issue_source_japanese(issue: &CouplingIssue) -> String {
502 if issue.issue_type == IssueType::ScatteredExternalCoupling
503 && issue.source.ends_with(" internal modules")
504 {
505 let count = issue.source.split_whitespace().next().unwrap_or_default();
506 return format!("{} 個の内部モジュール", count);
507 }
508 issue.source.clone()
509}
510
511fn issue_instance_description_japanese(issue: &CouplingIssue) -> String {
512 match issue.issue_type {
513 IssueType::ScatteredExternalCoupling => {
514 let source = issue_source_japanese(issue);
515 format!(
516 "{} は、{}から直接使われています。サードパーティ更新時のリスクがコードベース全体に広がっています。",
517 issue.target, source
518 )
519 }
520 IssueType::HiddenCoupling => {
521 "明示的なコード依存はありませんが、ファイルが頻繁に一緒に変更されています。暗黙の知識や不足した抽象化を示している可能性があります。"
522 .to_string()
523 }
524 IssueType::AccidentalVolatility => {
525 "安定しているはずのサブドメインが頻繁に変更されています。本質的な業務変化ではなく、設計や所有権の問題によるチャーンの可能性があります。"
526 .to_string()
527 }
528 _ => issue.description.clone(),
529 }
530}
531
532fn issue_refactoring_japanese(issue: &CouplingIssue) -> String {
533 match issue.issue_type {
534 IssueType::ScatteredExternalCoupling => {
535 let facade = issue.target.replace('-', "_");
536 format!(
537 "`{}_facade` モジュールを導入し、直接利用をそこに集約する",
538 facade
539 )
540 }
541 IssueType::HiddenCoupling => {
542 "共有されている知識を明示的な抽象化や境界に切り出す".to_string()
543 }
544 IssueType::AccidentalVolatility => {
545 "変更理由を分離し、安定サブドメインを高頻度変更から守る".to_string()
546 }
547 _ => {
548 let action = issue.refactoring.to_string();
549 if action == "Extract a shared abstraction or make the dependency explicit" {
550 "共有された抽象化を抽出するか、依存関係を明示する".to_string()
551 } else {
552 action
553 }
554 }
555 }
556}
557
558pub fn parse_grade(s: &str) -> Option<HealthGrade> {
564 match s.to_uppercase().as_str() {
565 "S" => Some(HealthGrade::S),
566 "A" => Some(HealthGrade::A),
567 "B" => Some(HealthGrade::B),
568 "C" => Some(HealthGrade::C),
569 "D" => Some(HealthGrade::D),
570 "F" => Some(HealthGrade::F),
571 _ => None,
572 }
573}
574
575pub fn parse_severity(s: &str) -> Option<Severity> {
577 match s.to_lowercase().as_str() {
578 "critical" => Some(Severity::Critical),
579 "high" => Some(Severity::High),
580 "medium" => Some(Severity::Medium),
581 "low" => Some(Severity::Low),
582 _ => None,
583 }
584}
585
586#[derive(Debug, Clone)]
592pub struct TraceResult {
593 pub item_name: String,
595 pub module: String,
597 pub file_path: String,
599 pub depends_on: Vec<TraceDependency>,
601 pub depended_by: Vec<TraceDependency>,
603 pub recommendation: Option<String>,
605}
606
607#[derive(Debug, Clone)]
609pub struct TraceDependency {
610 pub item: String,
612 pub module: String,
614 pub dep_type: String,
616 pub strength: String,
618 pub file_path: Option<String>,
620 pub line: usize,
622}
623
624pub fn generate_trace_output<W: Write>(
626 metrics: &ProjectMetrics,
627 item_name: &str,
628 writer: &mut W,
629) -> io::Result<bool> {
630 use crate::analyzer::ItemDepType;
631
632 let mut found_in_modules: Vec<(&str, &crate::metrics::module::ModuleMetrics)> = Vec::new();
634 let mut outgoing: Vec<TraceDependency> = Vec::new();
635 let mut incoming: Vec<TraceDependency> = Vec::new();
636
637 for (module_name, module) in &metrics.modules {
639 let defines_function = module.function_definitions.contains_key(item_name);
641 let defines_type = module.type_definitions.contains_key(item_name);
642
643 if defines_function || defines_type {
644 found_in_modules.push((module_name, module));
645 }
646
647 for dep in &module.item_dependencies {
649 if dep.source_item.contains(item_name) || dep.source_item.ends_with(item_name) {
650 let strength = match dep.dep_type {
651 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
652 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
653 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
654 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
655 };
656 outgoing.push(TraceDependency {
657 item: dep.target.clone(),
658 module: dep
659 .target_module
660 .clone()
661 .unwrap_or_else(|| "unknown".to_string()),
662 dep_type: format!("{:?}", dep.dep_type),
663 strength: strength.to_string(),
664 file_path: Some(module.path.display().to_string()),
665 line: dep.line,
666 });
667 }
668
669 if dep.target.contains(item_name) || dep.target.ends_with(item_name) {
671 let strength = match dep.dep_type {
672 ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
673 ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
674 ItemDepType::TypeUsage | ItemDepType::Import => "Model",
675 ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
676 };
677 incoming.push(TraceDependency {
678 item: dep.source_item.clone(),
679 module: module_name.clone(),
680 dep_type: format!("{:?}", dep.dep_type),
681 strength: strength.to_string(),
682 file_path: Some(module.path.display().to_string()),
683 line: dep.line,
684 });
685 }
686 }
687 }
688
689 found_in_modules.sort_by(|a, b| a.0.cmp(b.0));
690 outgoing.sort_by(|a, b| {
691 a.item
692 .cmp(&b.item)
693 .then_with(|| a.module.cmp(&b.module))
694 .then_with(|| a.file_path.cmp(&b.file_path))
695 .then_with(|| a.line.cmp(&b.line))
696 .then_with(|| a.dep_type.cmp(&b.dep_type))
697 .then_with(|| a.strength.cmp(&b.strength))
698 });
699 incoming.sort_by(|a, b| {
700 a.item
701 .cmp(&b.item)
702 .then_with(|| a.module.cmp(&b.module))
703 .then_with(|| a.file_path.cmp(&b.file_path))
704 .then_with(|| a.line.cmp(&b.line))
705 .then_with(|| a.dep_type.cmp(&b.dep_type))
706 .then_with(|| a.strength.cmp(&b.strength))
707 });
708
709 if found_in_modules.is_empty() && outgoing.is_empty() && incoming.is_empty() {
711 writeln!(writer, "Item '{}' not found.", item_name)?;
712 writeln!(writer)?;
713 writeln!(
714 writer,
715 "Hint: Try searching with a partial name or check module names:"
716 )?;
717
718 let mut suggestions: Vec<String> = Vec::new();
720 for (module_name, module) in &metrics.modules {
721 for func_name in module.function_definitions.keys() {
722 if func_name.to_lowercase().contains(&item_name.to_lowercase()) {
723 suggestions.push(format!(" - {} (function in {})", func_name, module_name));
724 }
725 }
726 for type_name in module.type_definitions.keys() {
727 if type_name.to_lowercase().contains(&item_name.to_lowercase()) {
728 suggestions.push(format!(" - {} (type in {})", type_name, module_name));
729 }
730 }
731 }
732
733 if suggestions.is_empty() {
734 writeln!(writer, " No similar items found.")?;
735 } else {
736 suggestions.sort();
737 suggestions.dedup();
738 for s in suggestions.iter().take(10) {
739 writeln!(writer, "{}", s)?;
740 }
741 if suggestions.len() > 10 {
742 writeln!(writer, " ... and {} more", suggestions.len() - 10)?;
743 }
744 }
745
746 return Ok(false);
747 }
748
749 writeln!(writer, "Dependency Trace: {}", item_name)?;
751 writeln!(writer, "{}", "═".repeat(50))?;
752 writeln!(writer)?;
753
754 if !found_in_modules.is_empty() {
756 writeln!(writer, "📍 Defined in:")?;
757 for (module_name, module) in &found_in_modules {
758 let item_type = if module.function_definitions.contains_key(item_name) {
759 "function"
760 } else {
761 "type"
762 };
763 writeln!(
764 writer,
765 " {} ({}) - {}",
766 module_name,
767 item_type,
768 module.path.display()
769 )?;
770 }
771 writeln!(writer)?;
772 }
773
774 writeln!(writer, "📤 Depends on ({} items):", outgoing.len())?;
776 if outgoing.is_empty() {
777 writeln!(writer, " (none)")?;
778 } else {
779 let mut by_target: BTreeMap<String, Vec<&TraceDependency>> = BTreeMap::new();
781 for dep in &outgoing {
782 by_target.entry(dep.item.clone()).or_default().push(dep);
783 }
784
785 for (target, deps) in by_target.iter().take(15) {
786 let first = deps[0];
787 let strength_icon = match first.strength.as_str() {
788 "Intrusive" => "🔴",
789 "Functional" => "🟠",
790 "Model" => "🟡",
791 "Contract" => "🟢",
792 _ => "⚪",
793 };
794 writeln!(
795 writer,
796 " {} {} ({}) - line {}",
797 strength_icon, target, first.strength, first.line
798 )?;
799 }
800 if by_target.len() > 15 {
801 writeln!(writer, " ... and {} more", by_target.len() - 15)?;
802 }
803 }
804 writeln!(writer)?;
805
806 writeln!(writer, "📥 Depended by ({} items):", incoming.len())?;
808 if incoming.is_empty() {
809 writeln!(writer, " (none)")?;
810 } else {
811 let mut by_source: BTreeMap<String, Vec<&TraceDependency>> = BTreeMap::new();
813 for dep in &incoming {
814 by_source.entry(dep.item.clone()).or_default().push(dep);
815 }
816
817 for (source, deps) in by_source.iter().take(15) {
818 let first = deps[0];
819 let strength_icon = match first.strength.as_str() {
820 "Intrusive" => "🔴",
821 "Functional" => "🟠",
822 "Model" => "🟡",
823 "Contract" => "🟢",
824 _ => "⚪",
825 };
826 writeln!(
827 writer,
828 " {} {} ({}) - {}:{}",
829 strength_icon,
830 source,
831 first.strength,
832 first.file_path.as_deref().unwrap_or("?"),
833 first.line
834 )?;
835 }
836 if by_source.len() > 15 {
837 writeln!(writer, " ... and {} more", by_source.len() - 15)?;
838 }
839 }
840 writeln!(writer)?;
841
842 writeln!(writer, "💡 Design Analysis:")?;
844
845 let intrusive_out = outgoing
846 .iter()
847 .filter(|d| d.strength == "Intrusive")
848 .count();
849 let intrusive_in = incoming
850 .iter()
851 .filter(|d| d.strength == "Intrusive")
852 .count();
853 let total_deps = outgoing.len() + incoming.len();
854
855 if total_deps == 0 {
856 writeln!(writer, " ✅ This item has no tracked dependencies.")?;
857 } else if intrusive_out > 3 {
858 writeln!(
859 writer,
860 " ⚠️ High intrusive outgoing coupling ({} items)",
861 intrusive_out
862 )?;
863 writeln!(
864 writer,
865 " → Consider: Extract interface/trait to reduce direct access"
866 )?;
867 writeln!(
868 writer,
869 " → Khononov: Strong coupling should be CLOSE (same module)"
870 )?;
871 } else if intrusive_in > 5 {
872 writeln!(
873 writer,
874 " ⚠️ High intrusive incoming coupling ({} items depend on internals)",
875 intrusive_in
876 )?;
877 writeln!(
878 writer,
879 " → Consider: This item is a hotspot - changes will cascade"
880 )?;
881 writeln!(
882 writer,
883 " → Khononov: Add stable interface to protect dependents"
884 )?;
885 } else if outgoing.len() > 10 {
886 writeln!(
887 writer,
888 " ⚠️ High efferent coupling ({} dependencies)",
889 outgoing.len()
890 )?;
891 writeln!(
892 writer,
893 " → Consider: Split into smaller functions with focused responsibilities"
894 )?;
895 } else if incoming.len() > 10 {
896 writeln!(
897 writer,
898 " ⚠️ High afferent coupling ({} dependents)",
899 incoming.len()
900 )?;
901 writeln!(
902 writer,
903 " → Consider: This is a core component - keep it stable"
904 )?;
905 } else {
906 writeln!(writer, " ✅ Coupling appears balanced.")?;
907 }
908
909 writeln!(writer)?;
910
911 writeln!(writer, "🔄 Change Impact:")?;
913 writeln!(
914 writer,
915 " If you modify '{}', you may need to update:",
916 item_name
917 )?;
918 let affected_modules: BTreeSet<_> = incoming.iter().map(|d| d.module.clone()).collect();
919 if affected_modules.is_empty() {
920 writeln!(writer, " (no other modules directly affected)")?;
921 } else {
922 for module in affected_modules.iter().take(10) {
923 writeln!(writer, " • {}", module)?;
924 }
925 if affected_modules.len() > 10 {
926 writeln!(
927 writer,
928 " ... and {} more modules",
929 affected_modules.len() - 10
930 )?;
931 }
932 }
933
934 Ok(true)
935}
936
937#[derive(Debug, Clone, Serialize)]
943pub struct JsonHistoryPoint {
944 pub commit: String,
945 pub date: String,
946 pub grade: char,
947 pub average_score: f64,
948 pub total_couplings: usize,
949 pub module_count: usize,
950 pub critical_issues: usize,
951 pub high_issues: usize,
952}
953
954#[derive(Debug, Clone, Serialize)]
956pub struct JsonSkippedRevision {
957 pub commit: String,
958 pub date: String,
959 pub reason: String,
960}
961
962#[derive(Debug, Clone, Serialize)]
964pub struct JsonHistory {
965 pub months: usize,
966 pub points: Vec<JsonHistoryPoint>,
967 pub skipped: Vec<JsonSkippedRevision>,
968}
969
970pub fn history_report_to_json(report: &HistoryReport) -> JsonHistory {
972 JsonHistory {
973 months: report.months,
974 points: report
975 .points
976 .iter()
977 .map(|p| JsonHistoryPoint {
978 commit: p.commit.clone(),
979 date: p.date.clone(),
980 grade: p.grade.letter(),
981 average_score: p.average_score,
982 total_couplings: p.total_couplings,
983 module_count: p.module_count,
984 critical_issues: p.critical,
985 high_issues: p.high,
986 })
987 .collect(),
988 skipped: report
989 .skipped
990 .iter()
991 .map(|s| JsonSkippedRevision {
992 commit: s.commit.clone(),
993 date: s.date.clone(),
994 reason: s.reason.clone(),
995 })
996 .collect(),
997 }
998}
999
1000pub fn generate_history_output<W: Write>(
1002 report: &HistoryReport,
1003 json: bool,
1004 requested_samples: usize,
1005 writer: &mut W,
1006) -> io::Result<()> {
1007 if json {
1008 let output = history_report_to_json(report);
1009 let text = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1010 writeln!(writer, "{}", text)?;
1011 return Ok(());
1012 }
1013
1014 writeln!(
1015 writer,
1016 "Coupling History (last {} months, {} sample(s))\n",
1017 report.months,
1018 report.points.len()
1019 )?;
1020
1021 if report.points.is_empty() {
1022 writeln!(writer, " No analyzable revisions in the requested window.")?;
1023 } else {
1024 writeln!(
1025 writer,
1026 " date commit grade avg couplings critical"
1027 )?;
1028 for p in &report.points {
1029 writeln!(
1030 writer,
1031 " {:<11} {:<8} {:<6} {:<7.3} {:<10} {}",
1032 p.date,
1033 p.commit,
1034 p.grade.letter(),
1035 p.average_score,
1036 p.total_couplings,
1037 p.critical,
1038 )?;
1039 }
1040
1041 if let Some((first, last)) = report.endpoints() {
1042 let direction = describe_trend(first.average_score, last.average_score);
1043 writeln!(
1044 writer,
1045 "\nTrend: grade {} -> {}, avg {:.3} -> {:.3} ({})",
1046 first.grade.letter(),
1047 last.grade.letter(),
1048 first.average_score,
1049 last.average_score,
1050 direction,
1051 )?;
1052 }
1053 }
1054
1055 if report.points.len() < requested_samples {
1056 writeln!(
1057 writer,
1058 "\nNote: {} of {} requested samples (history/window-limited).",
1059 report.points.len(),
1060 requested_samples
1061 )?;
1062 }
1063
1064 if !report.skipped.is_empty() {
1065 writeln!(writer, "\nSkipped {} revision(s):", report.skipped.len())?;
1066 for s in &report.skipped {
1067 writeln!(writer, " {} ({}): {}", s.commit, s.date, s.reason)?;
1068 }
1069 }
1070
1071 Ok(())
1072}
1073
1074fn describe_trend(from: f64, to: f64) -> &'static str {
1076 let delta = to - from;
1077 if delta > 0.01 {
1078 "improving"
1079 } else if delta < -0.01 {
1080 "regressing"
1081 } else {
1082 "stable"
1083 }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089 use std::path::PathBuf;
1090
1091 use crate::history::{HistoryPoint, HistoryReport};
1092 use crate::manifest::{ManifestContext, build_manifest};
1093
1094 fn sample_point(date: &str, grade: HealthGrade, score: f64) -> HistoryPoint {
1095 HistoryPoint {
1096 commit: "abc1234".to_string(),
1097 date: date.to_string(),
1098 grade,
1099 average_score: score,
1100 total_couplings: 100,
1101 module_count: 12,
1102 critical: 0,
1103 high: 1,
1104 }
1105 }
1106
1107 #[test]
1108 fn test_describe_trend() {
1109 assert_eq!(describe_trend(0.70, 0.85), "improving");
1110 assert_eq!(describe_trend(0.85, 0.70), "regressing");
1111 assert_eq!(describe_trend(0.80, 0.805), "stable");
1112 }
1113
1114 #[test]
1115 fn test_history_text_output_shows_trend() {
1116 let report = HistoryReport {
1117 months: 6,
1118 points: vec![
1119 sample_point("2026-01-01", HealthGrade::C, 0.60),
1120 sample_point("2026-05-01", HealthGrade::A, 0.85),
1121 ],
1122 skipped: vec![],
1123 };
1124 let mut buf = Vec::new();
1125 generate_history_output(&report, false, 2, &mut buf).unwrap();
1126 let text = String::from_utf8(buf).unwrap();
1127 assert!(text.contains("Coupling History (last 6 months, 2 sample(s))"));
1128 assert!(text.contains("Trend: grade C -> A"));
1129 assert!(text.contains("improving"));
1130 }
1131
1132 #[test]
1133 fn test_history_json_output_is_valid() {
1134 let report = HistoryReport {
1135 months: 12,
1136 points: vec![sample_point("2026-05-01", HealthGrade::B, 0.75)],
1137 skipped: vec![],
1138 };
1139 let mut buf = Vec::new();
1140 generate_history_output(&report, true, 1, &mut buf).unwrap();
1141 let text = String::from_utf8(buf).unwrap();
1142 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1143 assert_eq!(parsed["months"], 12);
1144 assert_eq!(parsed["points"][0]["grade"], "B");
1145 assert_eq!(parsed["points"][0]["module_count"], 12);
1146 }
1147
1148 #[test]
1149 fn test_history_empty_output() {
1150 let report = HistoryReport {
1151 months: 6,
1152 points: vec![],
1153 skipped: vec![],
1154 };
1155 let mut buf = Vec::new();
1156 generate_history_output(&report, false, 0, &mut buf).unwrap();
1157 let text = String::from_utf8(buf).unwrap();
1158 assert!(text.contains("No analyzable revisions"));
1159 }
1160
1161 #[test]
1162 fn test_history_text_output_notes_when_requested_samples_are_limited() {
1163 let report = HistoryReport {
1164 months: 6,
1165 points: vec![sample_point("2026-05-01", HealthGrade::B, 0.75)],
1166 skipped: vec![],
1167 };
1168 let mut buf = Vec::new();
1169 generate_history_output(&report, false, 3, &mut buf).unwrap();
1170 let text = String::from_utf8(buf).unwrap();
1171 assert!(text.contains("Note: 1 of 3 requested samples (history/window-limited)."));
1172 }
1173
1174 #[test]
1175 fn test_parse_grade() {
1176 assert_eq!(parse_grade("S"), Some(HealthGrade::S));
1177 assert_eq!(parse_grade("A"), Some(HealthGrade::A));
1178 assert_eq!(parse_grade("b"), Some(HealthGrade::B));
1179 assert_eq!(parse_grade("C"), Some(HealthGrade::C));
1180 assert_eq!(parse_grade("X"), None);
1181 }
1182
1183 #[test]
1184 fn test_parse_severity() {
1185 assert_eq!(parse_severity("critical"), Some(Severity::Critical));
1186 assert_eq!(parse_severity("HIGH"), Some(Severity::High));
1187 assert_eq!(parse_severity("invalid"), None);
1188 }
1189
1190 #[test]
1191 fn hotspots_resolve_qualified_aliases_to_source_files() {
1192 let mut metrics = ProjectMetrics::new();
1193 for name in ["consumer", "provider"] {
1194 metrics.add_module(crate::ModuleMetrics::new(
1195 format!("{name}.rs").into(),
1196 name.into(),
1197 ));
1198 }
1199 metrics.add_coupling(crate::CouplingMetrics::new(
1200 "app::consumer".into(),
1201 "app::provider".into(),
1202 crate::IntegrationStrength::Intrusive,
1203 Distance::DifferentModule,
1204 crate::Volatility::High,
1205 ));
1206 let hotspots = calculate_hotspots(&metrics, &IssueThresholds::default(), 10);
1207 let consumer = hotspots
1208 .iter()
1209 .find(|hotspot| hotspot.module == "consumer")
1210 .expect("qualified aliases must map to one module");
1211 assert_eq!(consumer.file_path.as_deref(), Some("consumer.rs"));
1212 assert!(
1213 !hotspots
1214 .iter()
1215 .any(|hotspot| hotspot.module == "app::consumer")
1216 );
1217 }
1218
1219 #[test]
1220 fn test_empty_metrics_hotspots() {
1221 let metrics = ProjectMetrics::new();
1222 let thresholds = IssueThresholds::default();
1223 let hotspots = calculate_hotspots(&metrics, &thresholds, 5);
1224 assert!(hotspots.is_empty());
1225 }
1226
1227 #[test]
1228 fn test_external_dependencies_json_output_shape() {
1229 use crate::external::{ExternalDependencyReport, ExternalDependencyUsage};
1230
1231 let dependencies = vec![ExternalDependencyUsage {
1232 crate_name: "reqwest".to_string(),
1233 versions: vec!["0.12.0".to_string()],
1234 breadth: 4,
1235 total_references: 8,
1236 dominant_strength: "Functional".to_string(),
1237 source_modules: vec![
1238 "api".to_string(),
1239 "client".to_string(),
1240 "sync".to_string(),
1241 "worker".to_string(),
1242 ],
1243 }];
1244 let scattered_couplings =
1245 crate::external::detect_scattered_external_coupling(&dependencies);
1246 let report = ExternalDependencyReport {
1247 dependencies,
1248 scattered_couplings,
1249 };
1250 let mut buf = Vec::new();
1251
1252 generate_external_dependencies_output(&report, true, false, &mut buf).unwrap();
1253
1254 let text = String::from_utf8(buf).unwrap();
1255 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1256 let deps = &parsed["external_dependencies"];
1257 assert_eq!(deps["total_crates"], 1);
1258 assert_eq!(deps["total_references"], 8);
1259 assert_eq!(deps["dependencies"][0]["crate_name"], "reqwest");
1260 assert_eq!(deps["dependencies"][0]["versions"][0], "0.12.0");
1261 assert_eq!(
1262 deps["scattered_couplings"][0]["issue_type"],
1263 "Scattered External Coupling"
1264 );
1265 }
1266
1267 #[test]
1268 fn test_check_passes_on_empty() {
1269 let metrics = ProjectMetrics::new();
1270 let thresholds = IssueThresholds::default();
1271 let config = CheckConfig::default();
1272 let result = run_check(&metrics, &thresholds, &config);
1273 assert!(result.passed);
1274 }
1275
1276 #[test]
1277 fn test_impact_analysis_orders_hash_map_derived_output() {
1278 use crate::metrics::coupling::CouplingMetrics;
1279 use crate::metrics::dimensions::IntegrationStrength;
1280 use crate::metrics::module::ModuleMetrics;
1281 use crate::volatility::Volatility;
1282
1283 let mut metrics = ProjectMetrics::new();
1284 for name in ["root", "zeta", "alpha", "middle"] {
1285 metrics.add_module(ModuleMetrics::new(
1286 PathBuf::from(format!("src/{name}.rs")),
1287 name.to_string(),
1288 ));
1289 }
1290 for (source, target, strength) in [
1291 ("root", "zeta", IntegrationStrength::Model),
1292 ("root", "alpha", IntegrationStrength::Functional),
1293 ("root", "alpha", IntegrationStrength::Model),
1294 ("middle", "root", IntegrationStrength::Contract),
1295 ("zeta", "middle", IntegrationStrength::Model),
1296 ("alpha", "middle", IntegrationStrength::Model),
1297 ] {
1298 metrics.add_coupling(CouplingMetrics::new(
1299 source.to_string(),
1300 target.to_string(),
1301 strength,
1302 Distance::DifferentModule,
1303 Volatility::Low,
1304 ));
1305 }
1306
1307 let analysis = analyze_impact(&metrics, "root").unwrap();
1308
1309 assert_eq!(
1310 analysis
1311 .dependencies
1312 .iter()
1313 .map(|dependency| dependency.module.as_str())
1314 .collect::<Vec<_>>(),
1315 ["alpha", "zeta"]
1316 );
1317 assert_eq!(
1318 analysis.dependencies[0]
1319 .strengths
1320 .iter()
1321 .map(|strength| strength.strength.as_str())
1322 .collect::<Vec<_>>(),
1323 ["Functional", "Model"]
1324 );
1325 assert_eq!(analysis.cascading_impact.second_order, ["alpha", "zeta"]);
1326 }
1327
1328 #[test]
1329 fn test_impact_module_lookup_rejects_substrings_and_reports_ambiguity() {
1330 use crate::metrics::module::ModuleMetrics;
1331
1332 let mut metrics = ProjectMetrics::new();
1333 for name in ["crate_a::config", "crate_b::config", "report"] {
1334 metrics.add_module(ModuleMetrics::new(
1335 PathBuf::from(format!("src/{}.rs", name.replace("::", "/"))),
1336 name.to_string(),
1337 ));
1338 }
1339 metrics.add_coupling(crate::metrics::coupling::CouplingMetrics::new(
1340 "TypeName::crate_c::config".to_string(),
1341 "target".to_string(),
1342 crate::metrics::dimensions::IntegrationStrength::Model,
1343 Distance::DifferentModule,
1344 crate::volatility::Volatility::Low,
1345 ));
1346
1347 assert_eq!(find_module(&metrics, "port"), ModuleLookup::NotFound);
1348 assert_eq!(
1349 find_module(&metrics, "config"),
1350 ModuleLookup::Ambiguous(vec![
1351 "crate_a::config".to_string(),
1352 "crate_b::config".to_string()
1353 ])
1354 );
1355
1356 let mut output = Vec::new();
1357 assert!(!generate_impact_output(&metrics, "config", &mut output).unwrap());
1358 let text = String::from_utf8(output).unwrap();
1359 assert!(text.contains("Module 'config' is ambiguous"));
1360 assert!(text.contains("crate_a::config"));
1361 assert!(text.contains("crate_b::config"));
1362 }
1363
1364 #[test]
1365 fn test_json_output_includes_analysis_manifest() {
1366 let metrics = ProjectMetrics::new();
1367 let thresholds = IssueThresholds::default();
1368 let manifest = build_manifest(&ManifestContext {
1369 git_used: false,
1370 tests_excluded: true,
1371 parse_failures: 0,
1372 skipped_crates: Vec::new(),
1373 boundary_skipped_files: 0,
1374 dead_config_patterns: Vec::new(),
1375 });
1376 let mut buf = Vec::new();
1377
1378 generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
1379
1380 let text = String::from_utf8(buf).unwrap();
1381 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1382 let blind_spots = parsed["analysis_manifest"]["blind_spots"]
1383 .as_array()
1384 .unwrap();
1385 let notes = parsed["analysis_manifest"]["notes"].as_array().unwrap();
1386
1387 assert!(blind_spots.iter().any(|spot| {
1388 spot["area"]
1389 .as_str()
1390 .is_some_and(|area| area == "dynamic-connascence")
1391 }));
1392 assert!(blind_spots.iter().any(|spot| {
1393 spot["area"]
1394 .as_str()
1395 .is_some_and(|area| area == "macro-and-cfg")
1396 }));
1397 assert!(notes.iter().any(|note| {
1398 note.as_str()
1399 .is_some_and(|note| note.contains("Git history was not analyzed"))
1400 }));
1401 assert!(notes.iter().any(|note| {
1402 note.as_str()
1403 .is_some_and(|note| note.contains("Test code was excluded"))
1404 }));
1405 }
1406
1407 #[test]
1408 fn test_json_output_includes_module_subdomain_when_present() {
1409 use crate::config::Subdomain;
1410 use crate::metrics::module::ModuleMetrics;
1411
1412 let mut metrics = ProjectMetrics::new();
1413 let mut module = ModuleMetrics::new(PathBuf::from("src/report.rs"), "report".to_string());
1414 module.subdomain = Some(Subdomain::Supporting);
1415 metrics.add_module(module);
1416
1417 let thresholds = IssueThresholds::default();
1418 let manifest = build_manifest(&ManifestContext::default());
1419 let mut buf = Vec::new();
1420
1421 generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
1422
1423 let text = String::from_utf8(buf).unwrap();
1424 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1425 let modules = parsed["modules"].as_array().unwrap();
1426 assert!(modules.iter().any(|module| {
1427 module["name"] == "report" && module["subdomain"].as_str() == Some("Supporting")
1428 }));
1429 }
1430
1431 #[test]
1432 fn test_json_output_includes_grade_rationale() {
1433 use crate::metrics::coupling::CouplingMetrics;
1434 use crate::metrics::dimensions::IntegrationStrength;
1435 use crate::volatility::Volatility;
1436
1437 let mut metrics = ProjectMetrics::new();
1438 metrics.add_coupling(CouplingMetrics::new(
1439 "caller".to_string(),
1440 "stable".to_string(),
1441 IntegrationStrength::Intrusive,
1442 Distance::DifferentModule,
1443 Volatility::High,
1444 ));
1445
1446 let thresholds = IssueThresholds::default();
1447 let manifest = build_manifest(&ManifestContext::default());
1448 let mut buf = Vec::new();
1449
1450 generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
1451
1452 let text = String::from_utf8(buf).unwrap();
1453 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1454 let rationale = &parsed["grade_rationale"];
1455 assert!(rationale["summary"].as_str().unwrap().contains("Driven by"));
1456 assert_eq!(
1457 rationale["top_issue_types"][0]["issue_type"].as_str(),
1458 Some("Cascading Change Risk")
1459 );
1460 assert!(rationale["note"].as_str().is_some());
1461 }
1462
1463 #[test]
1464 fn test_json_output_is_deterministic() {
1465 use crate::metrics::coupling::CouplingMetrics;
1466 use crate::metrics::dimensions::IntegrationStrength;
1467 use crate::metrics::module::ModuleMetrics;
1468 use crate::volatility::{TemporalCoupling, Volatility};
1469
1470 let mut metrics = ProjectMetrics::new();
1471 for name in ["zeta", "gamma", "epsilon", "delta", "beta", "alpha"] {
1472 metrics.add_module(ModuleMetrics::new(
1473 PathBuf::from(format!("src/{name}.rs")),
1474 name.to_string(),
1475 ));
1476 }
1477 for (source, target) in [
1478 ("alpha", "beta"),
1479 ("alpha", "gamma"),
1480 ("beta", "alpha"),
1481 ("beta", "gamma"),
1482 ("gamma", "alpha"),
1483 ] {
1484 metrics.add_coupling(CouplingMetrics::new(
1485 source.to_string(),
1486 target.to_string(),
1487 IntegrationStrength::Model,
1488 Distance::DifferentModule,
1489 Volatility::Low,
1490 ));
1491 }
1492 metrics.temporal_couplings = vec![
1493 TemporalCoupling {
1494 file_a: "src/delta.rs".to_string(),
1495 file_b: "src/zeta.rs".to_string(),
1496 co_change_count: 5,
1497 coupling_ratio: 0.5,
1498 },
1499 TemporalCoupling {
1500 file_a: "src/delta.rs".to_string(),
1501 file_b: "src/epsilon.rs".to_string(),
1502 co_change_count: 5,
1503 coupling_ratio: 0.5,
1504 },
1505 ];
1506
1507 let thresholds = IssueThresholds::default();
1508 let manifest = build_manifest(&ManifestContext::default());
1509 let mut expected = Vec::new();
1510 generate_json_output(&metrics, &thresholds, &manifest, &mut expected).unwrap();
1511
1512 for _ in 0..32 {
1513 let mut actual = Vec::new();
1514 generate_json_output(&metrics, &thresholds, &manifest, &mut actual).unwrap();
1515 assert_eq!(actual, expected);
1516 }
1517
1518 let parsed: serde_json::Value = serde_json::from_slice(&expected).unwrap();
1519 let module_names: Vec<_> = parsed["modules"]
1520 .as_array()
1521 .unwrap()
1522 .iter()
1523 .map(|module| module["name"].as_str().unwrap())
1524 .collect();
1525 assert_eq!(
1526 module_names,
1527 ["alpha", "beta", "delta", "epsilon", "gamma", "zeta"]
1528 );
1529 assert_eq!(
1530 parsed["circular_dependencies"],
1531 serde_json::json!([
1532 ["alpha", "beta"],
1533 ["alpha", "beta", "gamma"],
1534 ["alpha", "gamma"]
1535 ])
1536 );
1537 assert_eq!(parsed["temporal_couplings"][0]["file_b"], "src/epsilon.rs");
1538 assert_eq!(parsed["temporal_couplings"][1]["file_b"], "src/zeta.rs");
1539 }
1540}