cargo_coupling/
cli_output.rs

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