Skip to main content

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