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