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