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