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};
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::IssueThresholds;
19use crate::balance::severity::Severity;
20use crate::diff::BaselineDiff;
21use crate::external::ExternalDependencyReport;
22use crate::history::HistoryReport;
23#[cfg(test)]
24use crate::metrics::dimensions::Distance;
25use crate::metrics::project::ProjectMetrics;
26
27mod hotspots;
28mod impact;
29mod json;
30pub use hotspots::{
31    Hotspot, HotspotIssue, IssueExplanation, calculate_hotspots, generate_hotspots_output,
32    get_issue_explanation,
33};
34pub use impact::{
35    CascadingImpact, DependencyInfo, ImpactAnalysis, StrengthCount, analyze_impact,
36    analyze_impact_with_depth, generate_impact_output, generate_impact_output_with_depth,
37};
38#[cfg(test)]
39use impact::{ModuleLookup, find_module};
40use json::json_external_dependencies;
41pub use json::*;
42
43// ============================================================================
44// Check/Gate: CI/CD Quality Gate
45// ============================================================================
46
47/// Quality check configuration
48#[derive(Debug, Clone)]
49pub struct CheckConfig {
50    /// Minimum acceptable grade (A, B, C, D, F)
51    pub min_grade: Option<HealthGrade>,
52    /// Maximum allowed critical issues
53    pub max_critical: Option<usize>,
54    /// Maximum allowed representative cycle paths
55    pub max_circular: Option<usize>,
56    /// Fail on any issue of this severity or higher
57    pub fail_on: Option<Severity>,
58}
59
60impl Default for CheckConfig {
61    fn default() -> Self {
62        Self {
63            min_grade: Some(HealthGrade::C),
64            max_critical: Some(0),
65            max_circular: Some(0),
66            fail_on: None,
67        }
68    }
69}
70
71/// Check result with details
72#[derive(Debug, Clone, Serialize)]
73pub struct CheckResult {
74    pub passed: bool,
75    pub grade: String,
76    pub score: f64,
77    pub critical_count: usize,
78    pub high_count: usize,
79    pub medium_count: usize,
80    pub circular_count: usize,
81    pub failures: Vec<String>,
82}
83
84/// Run quality check and return result
85pub fn run_check(
86    metrics: &ProjectMetrics,
87    thresholds: &IssueThresholds,
88    config: &CheckConfig,
89) -> CheckResult {
90    let report = analyze_project_balance_with_thresholds(metrics, thresholds);
91    let circular_deps = metrics.detect_circular_dependencies();
92
93    let critical_count = *report
94        .issues_by_severity
95        .get(&Severity::Critical)
96        .unwrap_or(&0);
97    let high_count = *report.issues_by_severity.get(&Severity::High).unwrap_or(&0);
98    let medium_count = *report
99        .issues_by_severity
100        .get(&Severity::Medium)
101        .unwrap_or(&0);
102    let circular_count = circular_deps.len();
103
104    let mut failures: Vec<String> = Vec::new();
105    let mut passed = true;
106
107    // Check minimum grade
108    if let Some(min_grade) = &config.min_grade {
109        // Note: S is treated as equal to A for comparison purposes
110        // (S is a warning about over-optimization, not a higher grade)
111        let grade_order = |g: &HealthGrade| match g {
112            HealthGrade::S => 5, // Same as A
113            HealthGrade::A => 5,
114            HealthGrade::B => 4,
115            HealthGrade::C => 3,
116            HealthGrade::D => 2,
117            HealthGrade::F => 1,
118        };
119        if grade_order(&report.health_grade) < grade_order(min_grade) {
120            passed = false;
121            failures.push(format!(
122                "Grade {:?} is below minimum {:?}",
123                report.health_grade, min_grade
124            ));
125        }
126    }
127
128    // Check critical issues
129    if let Some(max) = config.max_critical
130        && critical_count > max
131    {
132        passed = false;
133        failures.push(format!("{} critical issues (max: {})", critical_count, max));
134    }
135
136    // Check representative cycle paths
137    if let Some(max) = config.max_circular
138        && circular_count > max
139    {
140        passed = false;
141        failures.push(format!(
142            "{} representative cycle paths (max: {})",
143            circular_count, max
144        ));
145    }
146
147    // Check fail_on severity
148    if let Some(fail_severity) = &config.fail_on {
149        let count = match fail_severity {
150            Severity::Critical => critical_count,
151            Severity::High => critical_count + high_count,
152            Severity::Medium => critical_count + high_count + medium_count,
153            Severity::Low => report.issues.len(),
154        };
155        if count > 0 {
156            passed = false;
157            failures.push(format!(
158                "{} issues at {:?} severity or higher",
159                count, fail_severity
160            ));
161        }
162    }
163
164    CheckResult {
165        passed,
166        grade: report.health_grade.letter().to_string(),
167        score: report.average_score,
168        critical_count,
169        high_count,
170        medium_count,
171        circular_count,
172        failures,
173    }
174}
175
176/// Generate check output and return exit code (0 = pass, 1 = fail)
177pub fn generate_check_output<W: Write>(
178    metrics: &ProjectMetrics,
179    thresholds: &IssueThresholds,
180    config: &CheckConfig,
181    writer: &mut W,
182) -> io::Result<i32> {
183    let result = run_check(metrics, thresholds, config);
184
185    writeln!(writer, "Coupling Quality Gate")?;
186    writeln!(
187        writer,
188        "═══════════════════════════════════════════════════════════"
189    )?;
190
191    let status = if result.passed {
192        "✅ PASSED"
193    } else {
194        "❌ FAILED"
195    };
196    writeln!(
197        writer,
198        "Grade: {} ({:.0}%)  {}",
199        result.grade,
200        result.score * 100.0,
201        status
202    )?;
203
204    writeln!(writer)?;
205    writeln!(writer, "Metrics:")?;
206    writeln!(writer, "  Critical issues: {}", result.critical_count)?;
207    writeln!(writer, "  High issues: {}", result.high_count)?;
208    writeln!(writer, "  Medium issues: {}", result.medium_count)?;
209    writeln!(
210        writer,
211        "  Representative cycle paths: {}",
212        result.circular_count
213    )?;
214
215    if !result.passed {
216        writeln!(writer)?;
217        writeln!(writer, "Blocking Issues:")?;
218        for failure in &result.failures {
219            writeln!(writer, "  - {}", failure)?;
220        }
221    }
222
223    Ok(if result.passed { 0 } else { 1 })
224}
225
226/// Generate a readable baseline diff report.
227pub fn generate_baseline_diff_output<W: Write>(
228    diff: &BaselineDiff,
229    baseline_ref: &str,
230    writer: &mut W,
231) -> io::Result<()> {
232    writeln!(writer, "Coupling Baseline Diff")?;
233    writeln!(
234        writer,
235        "═══════════════════════════════════════════════════════════"
236    )?;
237    writeln!(writer, "Baseline: {}", baseline_ref)?;
238    writeln!(
239        writer,
240        "Grade: {} -> {}",
241        diff.baseline_grade.letter(),
242        diff.current_grade.letter()
243    )?;
244    writeln!(writer, "Score delta: {:+.3}", diff.score_delta)?;
245    writeln!(writer)?;
246    writeln!(writer, "Issues:")?;
247    writeln!(writer, "  New: {}", diff.new_issues.len())?;
248    writeln!(writer, "  Resolved: {}", diff.resolved_issues.len())?;
249    writeln!(writer, "  Worsened: {}", diff.worsened_issues.len())?;
250    writeln!(writer, "  Unchanged: {}", diff.unchanged)?;
251
252    write_issue_section(writer, "New Issues", &diff.new_issues)?;
253    write_issue_section(writer, "Resolved Issues", &diff.resolved_issues)?;
254    write_issue_section(writer, "Worsened Issues", &diff.worsened_issues)?;
255
256    Ok(())
257}
258
259/// Generate ratchet gate output and return exit code (0 = pass, 1 = fail).
260pub fn generate_ratchet_check_output<W: Write>(
261    diff: &BaselineDiff,
262    baseline_ref: &str,
263    fail_on: Severity,
264    writer: &mut W,
265) -> io::Result<i32> {
266    let failures = diff.ratchet_failures(fail_on);
267    let passed = failures.is_empty();
268
269    writeln!(writer, "Coupling Ratchet Gate")?;
270    writeln!(
271        writer,
272        "═══════════════════════════════════════════════════════════"
273    )?;
274    writeln!(writer, "Baseline: {}", baseline_ref)?;
275    writeln!(
276        writer,
277        "Grade: {} -> {}",
278        diff.baseline_grade.letter(),
279        diff.current_grade.letter()
280    )?;
281    writeln!(writer, "Score delta: {:+.3}", diff.score_delta)?;
282    writeln!(
283        writer,
284        "New issues: {} (fail-on: {} or higher)",
285        diff.new_issues.len(),
286        fail_on
287    )?;
288    writeln!(writer, "Worsened issues: {}", diff.worsened_issues.len())?;
289    writeln!(
290        writer,
291        "Status: {}",
292        if passed { "PASSED" } else { "FAILED" }
293    )?;
294
295    if !passed {
296        writeln!(writer)?;
297        writeln!(writer, "Blocking New/Worsened Issues:")?;
298        for issue in failures {
299            write_issue_line(writer, issue)?;
300        }
301    }
302
303    Ok(if passed { 0 } else { 1 })
304}
305
306fn write_issue_section<W: Write>(
307    writer: &mut W,
308    title: &str,
309    issues: &[CouplingIssue],
310) -> io::Result<()> {
311    writeln!(writer)?;
312    writeln!(writer, "{}:", title)?;
313    if issues.is_empty() {
314        writeln!(writer, "  (none)")?;
315        return Ok(());
316    }
317
318    for issue in issues {
319        write_issue_line(writer, issue)?;
320    }
321    Ok(())
322}
323
324fn write_issue_line<W: Write>(writer: &mut W, issue: &CouplingIssue) -> io::Result<()> {
325    writeln!(
326        writer,
327        "  - {} {}: {} -> {}",
328        issue.severity, issue.issue_type, issue.source, issue.target
329    )
330}
331
332// ============================================================================
333// External Dependencies: Third-party coupling exposure
334// ============================================================================
335
336/// Render external dependency coupling as text or JSON.
337pub fn generate_external_dependencies_output<W: Write>(
338    report: &ExternalDependencyReport,
339    json: bool,
340    japanese: bool,
341    writer: &mut W,
342) -> io::Result<()> {
343    if json {
344        let output = JsonExternalDependenciesOutput {
345            external_dependencies: json_external_dependencies(report),
346        };
347        let text = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
348        writeln!(writer, "{}", text)?;
349        return Ok(());
350    }
351
352    if japanese {
353        writeln!(writer, "外部依存の結合")?;
354    } else {
355        writeln!(writer, "External Dependency Coupling")?;
356    }
357    writeln!(
358        writer,
359        "═══════════════════════════════════════════════════════════"
360    )?;
361    let total_references = report
362        .dependencies
363        .iter()
364        .map(|dependency| dependency.total_references)
365        .sum::<usize>();
366    if japanese {
367        writeln!(
368            writer,
369            "外部クレート: {}  直接参照: {}",
370            report.dependencies.len(),
371            total_references
372        )?;
373    } else {
374        writeln!(
375            writer,
376            "External crates: {}  Direct references: {}",
377            report.dependencies.len(),
378            total_references
379        )?;
380    }
381
382    if report.dependencies.is_empty() {
383        writeln!(writer)?;
384        if japanese {
385            writeln!(writer, "外部クレートへの結合は検出されませんでした。")?;
386        } else {
387            writeln!(writer, "No external crate couplings detected.")?;
388        }
389        return Ok(());
390    }
391
392    writeln!(writer)?;
393    if japanese {
394        writeln!(writer, "利用モジュール数が多いクレート:")?;
395    } else {
396        writeln!(writer, "Top Crates by Breadth:")?;
397    }
398    for (index, dependency) in report.dependencies.iter().take(10).enumerate() {
399        let version = if dependency.versions.is_empty() {
400            if japanese {
401                "バージョン: 不明".to_string()
402            } else {
403                "version: unknown".to_string()
404            }
405        } else if japanese {
406            format!("バージョン: {}", dependency.versions.join(", "))
407        } else {
408            format!("version: {}", dependency.versions.join(", "))
409        };
410        if japanese {
411            writeln!(
412                writer,
413                "{}. {} ({}; {} モジュール, {} 参照, 主な強度: {})",
414                index + 1,
415                dependency.crate_name,
416                version,
417                dependency.breadth,
418                dependency.total_references,
419                dependency.dominant_strength
420            )?;
421        } else {
422            writeln!(
423                writer,
424                "{}. {} ({}; {} modules, {} references, dominant: {})",
425                index + 1,
426                dependency.crate_name,
427                version,
428                dependency.breadth,
429                dependency.total_references,
430                dependency.dominant_strength
431            )?;
432        }
433        let sample_modules = dependency
434            .source_modules
435            .iter()
436            .take(5)
437            .cloned()
438            .collect::<Vec<_>>()
439            .join(", ");
440        if !sample_modules.is_empty() {
441            if japanese {
442                writeln!(writer, "   モジュール: {}", sample_modules)?;
443            } else {
444                writeln!(writer, "   modules: {}", sample_modules)?;
445            }
446        }
447    }
448
449    writeln!(writer)?;
450    if japanese {
451        writeln!(writer, "分散した外部結合の警告:")?;
452    } else {
453        writeln!(writer, "Scattered Coupling Flags:")?;
454    }
455    if report.scattered_couplings.is_empty() {
456        if japanese {
457            writeln!(writer, "  (なし)")?;
458        } else {
459            writeln!(writer, "  (none)")?;
460        }
461    } else {
462        for issue in &report.scattered_couplings {
463            let source = if japanese {
464                issue_source_japanese(issue)
465            } else {
466                issue.source.clone()
467            };
468            writeln!(
469                writer,
470                "  - {}: {} -> {}",
471                severity_label(issue.severity, japanese),
472                source,
473                issue.target
474            )?;
475            if japanese {
476                writeln!(writer, "    {}", issue_instance_description_japanese(issue))?;
477                writeln!(writer, "    修正: {}", issue_refactoring_japanese(issue))?;
478            } else {
479                writeln!(writer, "    {}", issue.description)?;
480                writeln!(writer, "    Fix: {}", issue.refactoring)?;
481            }
482        }
483    }
484
485    Ok(())
486}
487
488fn severity_label(severity: Severity, japanese: bool) -> String {
489    if !japanese {
490        return severity.to_string();
491    }
492    match severity {
493        Severity::Critical => "緊急",
494        Severity::High => "高",
495        Severity::Medium => "中",
496        Severity::Low => "低",
497    }
498    .to_string()
499}
500
501fn issue_source_japanese(issue: &CouplingIssue) -> String {
502    if issue.issue_type == IssueType::ScatteredExternalCoupling
503        && issue.source.ends_with(" internal modules")
504    {
505        let count = issue.source.split_whitespace().next().unwrap_or_default();
506        return format!("{} 個の内部モジュール", count);
507    }
508    issue.source.clone()
509}
510
511fn issue_instance_description_japanese(issue: &CouplingIssue) -> String {
512    match issue.issue_type {
513        IssueType::ScatteredExternalCoupling => {
514            let source = issue_source_japanese(issue);
515            format!(
516                "{} は、{}から直接使われています。サードパーティ更新時のリスクがコードベース全体に広がっています。",
517                issue.target, source
518            )
519        }
520        IssueType::HiddenCoupling => {
521            "明示的なコード依存はありませんが、ファイルが頻繁に一緒に変更されています。暗黙の知識や不足した抽象化を示している可能性があります。"
522                .to_string()
523        }
524        IssueType::AccidentalVolatility => {
525            "安定しているはずのサブドメインが頻繁に変更されています。本質的な業務変化ではなく、設計や所有権の問題によるチャーンの可能性があります。"
526                .to_string()
527        }
528        _ => issue.description.clone(),
529    }
530}
531
532fn issue_refactoring_japanese(issue: &CouplingIssue) -> String {
533    match issue.issue_type {
534        IssueType::ScatteredExternalCoupling => {
535            let facade = issue.target.replace('-', "_");
536            format!(
537                "`{}_facade` モジュールを導入し、直接利用をそこに集約する",
538                facade
539            )
540        }
541        IssueType::HiddenCoupling => {
542            "共有されている知識を明示的な抽象化や境界に切り出す".to_string()
543        }
544        IssueType::AccidentalVolatility => {
545            "変更理由を分離し、安定サブドメインを高頻度変更から守る".to_string()
546        }
547        _ => {
548            let action = issue.refactoring.to_string();
549            if action == "Extract a shared abstraction or make the dependency explicit" {
550                "共有された抽象化を抽出するか、依存関係を明示する".to_string()
551            } else {
552                action
553            }
554        }
555    }
556}
557
558// ============================================================================
559// Parse helpers for CLI
560// ============================================================================
561
562/// Parse grade string to HealthGrade
563pub fn parse_grade(s: &str) -> Option<HealthGrade> {
564    match s.to_uppercase().as_str() {
565        "S" => Some(HealthGrade::S),
566        "A" => Some(HealthGrade::A),
567        "B" => Some(HealthGrade::B),
568        "C" => Some(HealthGrade::C),
569        "D" => Some(HealthGrade::D),
570        "F" => Some(HealthGrade::F),
571        _ => None,
572    }
573}
574
575/// Parse severity string to Severity
576pub fn parse_severity(s: &str) -> Option<Severity> {
577    match s.to_lowercase().as_str() {
578        "critical" => Some(Severity::Critical),
579        "high" => Some(Severity::High),
580        "medium" => Some(Severity::Medium),
581        "low" => Some(Severity::Low),
582        _ => None,
583    }
584}
585
586// ============================================================================
587// Trace: Function/Type-level Dependency Analysis
588// ============================================================================
589
590/// Trace result for a specific item (function/type)
591#[derive(Debug, Clone)]
592pub struct TraceResult {
593    /// Item name
594    pub item_name: String,
595    /// Module where the item is defined
596    pub module: String,
597    /// File path
598    pub file_path: String,
599    /// What this item depends on (outgoing)
600    pub depends_on: Vec<TraceDependency>,
601    /// What depends on this item (incoming)
602    pub depended_by: Vec<TraceDependency>,
603    /// Design recommendation based on coupling analysis
604    pub recommendation: Option<String>,
605}
606
607/// A traced dependency
608#[derive(Debug, Clone)]
609pub struct TraceDependency {
610    /// Source or target item name
611    pub item: String,
612    /// Module name
613    pub module: String,
614    /// Type of dependency (FunctionCall, FieldAccess, etc.)
615    pub dep_type: String,
616    /// Integration strength
617    pub strength: String,
618    /// File path
619    pub file_path: Option<String>,
620    /// Line number
621    pub line: usize,
622}
623
624/// Generate trace output for a specific function/type
625pub fn generate_trace_output<W: Write>(
626    metrics: &ProjectMetrics,
627    item_name: &str,
628    writer: &mut W,
629) -> io::Result<bool> {
630    use crate::analyzer::ItemDepType;
631
632    // Find all items matching the name
633    let mut found_in_modules: Vec<(&str, &crate::metrics::module::ModuleMetrics)> = Vec::new();
634    let mut outgoing: Vec<TraceDependency> = Vec::new();
635    let mut incoming: Vec<TraceDependency> = Vec::new();
636
637    // Search through all modules
638    for (module_name, module) in &metrics.modules {
639        // Check if this module defines the item (as function or type)
640        let defines_function = module.function_definitions.contains_key(item_name);
641        let defines_type = module.type_definitions.contains_key(item_name);
642
643        if defines_function || defines_type {
644            found_in_modules.push((module_name, module));
645        }
646
647        // Check item_dependencies for outgoing dependencies FROM this item
648        for dep in &module.item_dependencies {
649            if dep.source_item.contains(item_name) || dep.source_item.ends_with(item_name) {
650                let strength = match dep.dep_type {
651                    ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
652                    ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
653                    ItemDepType::TypeUsage | ItemDepType::Import => "Model",
654                    ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
655                };
656                outgoing.push(TraceDependency {
657                    item: dep.target.clone(),
658                    module: dep
659                        .target_module
660                        .clone()
661                        .unwrap_or_else(|| "unknown".to_string()),
662                    dep_type: format!("{:?}", dep.dep_type),
663                    strength: strength.to_string(),
664                    file_path: Some(module.path.display().to_string()),
665                    line: dep.line,
666                });
667            }
668
669            // Check for incoming dependencies TO this item
670            if dep.target.contains(item_name) || dep.target.ends_with(item_name) {
671                let strength = match dep.dep_type {
672                    ItemDepType::FieldAccess | ItemDepType::StructConstruction => "Intrusive",
673                    ItemDepType::FunctionCall | ItemDepType::MethodCall => "Functional",
674                    ItemDepType::TypeUsage | ItemDepType::Import => "Model",
675                    ItemDepType::TraitImpl | ItemDepType::TraitBound => "Contract",
676                };
677                incoming.push(TraceDependency {
678                    item: dep.source_item.clone(),
679                    module: module_name.clone(),
680                    dep_type: format!("{:?}", dep.dep_type),
681                    strength: strength.to_string(),
682                    file_path: Some(module.path.display().to_string()),
683                    line: dep.line,
684                });
685            }
686        }
687    }
688
689    found_in_modules.sort_by(|a, b| a.0.cmp(b.0));
690    outgoing.sort_by(|a, b| {
691        a.item
692            .cmp(&b.item)
693            .then_with(|| a.module.cmp(&b.module))
694            .then_with(|| a.file_path.cmp(&b.file_path))
695            .then_with(|| a.line.cmp(&b.line))
696            .then_with(|| a.dep_type.cmp(&b.dep_type))
697            .then_with(|| a.strength.cmp(&b.strength))
698    });
699    incoming.sort_by(|a, b| {
700        a.item
701            .cmp(&b.item)
702            .then_with(|| a.module.cmp(&b.module))
703            .then_with(|| a.file_path.cmp(&b.file_path))
704            .then_with(|| a.line.cmp(&b.line))
705            .then_with(|| a.dep_type.cmp(&b.dep_type))
706            .then_with(|| a.strength.cmp(&b.strength))
707    });
708
709    // If not found, try partial match
710    if found_in_modules.is_empty() && outgoing.is_empty() && incoming.is_empty() {
711        writeln!(writer, "Item '{}' not found.", item_name)?;
712        writeln!(writer)?;
713        writeln!(
714            writer,
715            "Hint: Try searching with a partial name or check module names:"
716        )?;
717
718        // Show available items that might match
719        let mut suggestions: Vec<String> = Vec::new();
720        for (module_name, module) in &metrics.modules {
721            for func_name in module.function_definitions.keys() {
722                if func_name.to_lowercase().contains(&item_name.to_lowercase()) {
723                    suggestions.push(format!("  - {} (function in {})", func_name, module_name));
724                }
725            }
726            for type_name in module.type_definitions.keys() {
727                if type_name.to_lowercase().contains(&item_name.to_lowercase()) {
728                    suggestions.push(format!("  - {} (type in {})", type_name, module_name));
729                }
730            }
731        }
732
733        if suggestions.is_empty() {
734            writeln!(writer, "  No similar items found.")?;
735        } else {
736            suggestions.sort();
737            suggestions.dedup();
738            for s in suggestions.iter().take(10) {
739                writeln!(writer, "{}", s)?;
740            }
741            if suggestions.len() > 10 {
742                writeln!(writer, "  ... and {} more", suggestions.len() - 10)?;
743            }
744        }
745
746        return Ok(false);
747    }
748
749    // Output header
750    writeln!(writer, "Dependency Trace: {}", item_name)?;
751    writeln!(writer, "{}", "═".repeat(50))?;
752    writeln!(writer)?;
753
754    // Show where the item is defined
755    if !found_in_modules.is_empty() {
756        writeln!(writer, "📍 Defined in:")?;
757        for (module_name, module) in &found_in_modules {
758            let item_type = if module.function_definitions.contains_key(item_name) {
759                "function"
760            } else {
761                "type"
762            };
763            writeln!(
764                writer,
765                "   {} ({}) - {}",
766                module_name,
767                item_type,
768                module.path.display()
769            )?;
770        }
771        writeln!(writer)?;
772    }
773
774    // Show outgoing dependencies (what this item depends on)
775    writeln!(writer, "📤 Depends on ({} items):", outgoing.len())?;
776    if outgoing.is_empty() {
777        writeln!(writer, "   (none)")?;
778    } else {
779        // Group by target
780        let mut by_target: BTreeMap<String, Vec<&TraceDependency>> = BTreeMap::new();
781        for dep in &outgoing {
782            by_target.entry(dep.item.clone()).or_default().push(dep);
783        }
784
785        for (target, deps) in by_target.iter().take(15) {
786            let first = deps[0];
787            let strength_icon = match first.strength.as_str() {
788                "Intrusive" => "🔴",
789                "Functional" => "🟠",
790                "Model" => "🟡",
791                "Contract" => "🟢",
792                _ => "⚪",
793            };
794            writeln!(
795                writer,
796                "   {} {} ({}) - line {}",
797                strength_icon, target, first.strength, first.line
798            )?;
799        }
800        if by_target.len() > 15 {
801            writeln!(writer, "   ... and {} more", by_target.len() - 15)?;
802        }
803    }
804    writeln!(writer)?;
805
806    // Show incoming dependencies (what depends on this item)
807    writeln!(writer, "📥 Depended by ({} items):", incoming.len())?;
808    if incoming.is_empty() {
809        writeln!(writer, "   (none)")?;
810    } else {
811        // Group by source
812        let mut by_source: BTreeMap<String, Vec<&TraceDependency>> = BTreeMap::new();
813        for dep in &incoming {
814            by_source.entry(dep.item.clone()).or_default().push(dep);
815        }
816
817        for (source, deps) in by_source.iter().take(15) {
818            let first = deps[0];
819            let strength_icon = match first.strength.as_str() {
820                "Intrusive" => "🔴",
821                "Functional" => "🟠",
822                "Model" => "🟡",
823                "Contract" => "🟢",
824                _ => "⚪",
825            };
826            writeln!(
827                writer,
828                "   {} {} ({}) - {}:{}",
829                strength_icon,
830                source,
831                first.strength,
832                first.file_path.as_deref().unwrap_or("?"),
833                first.line
834            )?;
835        }
836        if by_source.len() > 15 {
837            writeln!(writer, "   ... and {} more", by_source.len() - 15)?;
838        }
839    }
840    writeln!(writer)?;
841
842    // Design recommendation
843    writeln!(writer, "💡 Design Analysis:")?;
844
845    let intrusive_out = outgoing
846        .iter()
847        .filter(|d| d.strength == "Intrusive")
848        .count();
849    let intrusive_in = incoming
850        .iter()
851        .filter(|d| d.strength == "Intrusive")
852        .count();
853    let total_deps = outgoing.len() + incoming.len();
854
855    if total_deps == 0 {
856        writeln!(writer, "   ✅ This item has no tracked dependencies.")?;
857    } else if intrusive_out > 3 {
858        writeln!(
859            writer,
860            "   ⚠️  High intrusive outgoing coupling ({} items)",
861            intrusive_out
862        )?;
863        writeln!(
864            writer,
865            "   → Consider: Extract interface/trait to reduce direct access"
866        )?;
867        writeln!(
868            writer,
869            "   → Khononov: Strong coupling should be CLOSE (same module)"
870        )?;
871    } else if intrusive_in > 5 {
872        writeln!(
873            writer,
874            "   ⚠️  High intrusive incoming coupling ({} items depend on internals)",
875            intrusive_in
876        )?;
877        writeln!(
878            writer,
879            "   → Consider: This item is a hotspot - changes will cascade"
880        )?;
881        writeln!(
882            writer,
883            "   → Khononov: Add stable interface to protect dependents"
884        )?;
885    } else if outgoing.len() > 10 {
886        writeln!(
887            writer,
888            "   ⚠️  High efferent coupling ({} dependencies)",
889            outgoing.len()
890        )?;
891        writeln!(
892            writer,
893            "   → Consider: Split into smaller functions with focused responsibilities"
894        )?;
895    } else if incoming.len() > 10 {
896        writeln!(
897            writer,
898            "   ⚠️  High afferent coupling ({} dependents)",
899            incoming.len()
900        )?;
901        writeln!(
902            writer,
903            "   → Consider: This is a core component - keep it stable"
904        )?;
905    } else {
906        writeln!(writer, "   ✅ Coupling appears balanced.")?;
907    }
908
909    writeln!(writer)?;
910
911    // Change impact summary
912    writeln!(writer, "🔄 Change Impact:")?;
913    writeln!(
914        writer,
915        "   If you modify '{}', you may need to update:",
916        item_name
917    )?;
918    let affected_modules: BTreeSet<_> = incoming.iter().map(|d| d.module.clone()).collect();
919    if affected_modules.is_empty() {
920        writeln!(writer, "   (no other modules directly affected)")?;
921    } else {
922        for module in affected_modules.iter().take(10) {
923            writeln!(writer, "   • {}", module)?;
924        }
925        if affected_modules.len() > 10 {
926            writeln!(
927                writer,
928                "   ... and {} more modules",
929                affected_modules.len() - 10
930            )?;
931        }
932    }
933
934    Ok(true)
935}
936
937// ============================================================================
938// History: Time-Series Coupling Health
939// ============================================================================
940
941/// A single timeline point in JSON format.
942#[derive(Debug, Clone, Serialize)]
943pub struct JsonHistoryPoint {
944    pub commit: String,
945    pub date: String,
946    pub grade: char,
947    pub average_score: f64,
948    pub total_couplings: usize,
949    pub module_count: usize,
950    pub critical_issues: usize,
951    pub high_issues: usize,
952}
953
954/// A skipped revision in JSON format.
955#[derive(Debug, Clone, Serialize)]
956pub struct JsonSkippedRevision {
957    pub commit: String,
958    pub date: String,
959    pub reason: String,
960}
961
962/// Complete history timeline in JSON format.
963#[derive(Debug, Clone, Serialize)]
964pub struct JsonHistory {
965    pub months: usize,
966    pub points: Vec<JsonHistoryPoint>,
967    pub skipped: Vec<JsonSkippedRevision>,
968}
969
970/// Convert a history report into its shared JSON representation.
971pub fn history_report_to_json(report: &HistoryReport) -> JsonHistory {
972    JsonHistory {
973        months: report.months,
974        points: report
975            .points
976            .iter()
977            .map(|p| JsonHistoryPoint {
978                commit: p.commit.clone(),
979                date: p.date.clone(),
980                grade: p.grade.letter(),
981                average_score: p.average_score,
982                total_couplings: p.total_couplings,
983                module_count: p.module_count,
984                critical_issues: p.critical,
985                high_issues: p.high,
986            })
987            .collect(),
988        skipped: report
989            .skipped
990            .iter()
991            .map(|s| JsonSkippedRevision {
992                commit: s.commit.clone(),
993                date: s.date.clone(),
994                reason: s.reason.clone(),
995            })
996            .collect(),
997    }
998}
999
1000/// Render a history report as text or JSON.
1001pub fn generate_history_output<W: Write>(
1002    report: &HistoryReport,
1003    json: bool,
1004    requested_samples: usize,
1005    writer: &mut W,
1006) -> io::Result<()> {
1007    if json {
1008        let output = history_report_to_json(report);
1009        let text = serde_json::to_string_pretty(&output).map_err(io::Error::other)?;
1010        writeln!(writer, "{}", text)?;
1011        return Ok(());
1012    }
1013
1014    writeln!(
1015        writer,
1016        "Coupling History (last {} months, {} sample(s))\n",
1017        report.months,
1018        report.points.len()
1019    )?;
1020
1021    if report.points.is_empty() {
1022        writeln!(writer, "  No analyzable revisions in the requested window.")?;
1023    } else {
1024        writeln!(
1025            writer,
1026            "  date        commit   grade  avg     couplings  critical"
1027        )?;
1028        for p in &report.points {
1029            writeln!(
1030                writer,
1031                "  {:<11} {:<8} {:<6} {:<7.3} {:<10} {}",
1032                p.date,
1033                p.commit,
1034                p.grade.letter(),
1035                p.average_score,
1036                p.total_couplings,
1037                p.critical,
1038            )?;
1039        }
1040
1041        if let Some((first, last)) = report.endpoints() {
1042            let direction = describe_trend(first.average_score, last.average_score);
1043            writeln!(
1044                writer,
1045                "\nTrend: grade {} -> {}, avg {:.3} -> {:.3} ({})",
1046                first.grade.letter(),
1047                last.grade.letter(),
1048                first.average_score,
1049                last.average_score,
1050                direction,
1051            )?;
1052        }
1053    }
1054
1055    if report.points.len() < requested_samples {
1056        writeln!(
1057            writer,
1058            "\nNote: {} of {} requested samples (history/window-limited).",
1059            report.points.len(),
1060            requested_samples
1061        )?;
1062    }
1063
1064    if !report.skipped.is_empty() {
1065        writeln!(writer, "\nSkipped {} revision(s):", report.skipped.len())?;
1066        for s in &report.skipped {
1067            writeln!(writer, "  {} ({}): {}", s.commit, s.date, s.reason)?;
1068        }
1069    }
1070
1071    Ok(())
1072}
1073
1074/// Describe the direction of change between two scores.
1075fn describe_trend(from: f64, to: f64) -> &'static str {
1076    let delta = to - from;
1077    if delta > 0.01 {
1078        "improving"
1079    } else if delta < -0.01 {
1080        "regressing"
1081    } else {
1082        "stable"
1083    }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089    use std::path::PathBuf;
1090
1091    use crate::history::{HistoryPoint, HistoryReport};
1092    use crate::manifest::{ManifestContext, build_manifest};
1093
1094    fn sample_point(date: &str, grade: HealthGrade, score: f64) -> HistoryPoint {
1095        HistoryPoint {
1096            commit: "abc1234".to_string(),
1097            date: date.to_string(),
1098            grade,
1099            average_score: score,
1100            total_couplings: 100,
1101            module_count: 12,
1102            critical: 0,
1103            high: 1,
1104        }
1105    }
1106
1107    #[test]
1108    fn test_describe_trend() {
1109        assert_eq!(describe_trend(0.70, 0.85), "improving");
1110        assert_eq!(describe_trend(0.85, 0.70), "regressing");
1111        assert_eq!(describe_trend(0.80, 0.805), "stable");
1112    }
1113
1114    #[test]
1115    fn test_history_text_output_shows_trend() {
1116        let report = HistoryReport {
1117            months: 6,
1118            points: vec![
1119                sample_point("2026-01-01", HealthGrade::C, 0.60),
1120                sample_point("2026-05-01", HealthGrade::A, 0.85),
1121            ],
1122            skipped: vec![],
1123        };
1124        let mut buf = Vec::new();
1125        generate_history_output(&report, false, 2, &mut buf).unwrap();
1126        let text = String::from_utf8(buf).unwrap();
1127        assert!(text.contains("Coupling History (last 6 months, 2 sample(s))"));
1128        assert!(text.contains("Trend: grade C -> A"));
1129        assert!(text.contains("improving"));
1130    }
1131
1132    #[test]
1133    fn test_history_json_output_is_valid() {
1134        let report = HistoryReport {
1135            months: 12,
1136            points: vec![sample_point("2026-05-01", HealthGrade::B, 0.75)],
1137            skipped: vec![],
1138        };
1139        let mut buf = Vec::new();
1140        generate_history_output(&report, true, 1, &mut buf).unwrap();
1141        let text = String::from_utf8(buf).unwrap();
1142        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1143        assert_eq!(parsed["months"], 12);
1144        assert_eq!(parsed["points"][0]["grade"], "B");
1145        assert_eq!(parsed["points"][0]["module_count"], 12);
1146    }
1147
1148    #[test]
1149    fn test_history_empty_output() {
1150        let report = HistoryReport {
1151            months: 6,
1152            points: vec![],
1153            skipped: vec![],
1154        };
1155        let mut buf = Vec::new();
1156        generate_history_output(&report, false, 0, &mut buf).unwrap();
1157        let text = String::from_utf8(buf).unwrap();
1158        assert!(text.contains("No analyzable revisions"));
1159    }
1160
1161    #[test]
1162    fn test_history_text_output_notes_when_requested_samples_are_limited() {
1163        let report = HistoryReport {
1164            months: 6,
1165            points: vec![sample_point("2026-05-01", HealthGrade::B, 0.75)],
1166            skipped: vec![],
1167        };
1168        let mut buf = Vec::new();
1169        generate_history_output(&report, false, 3, &mut buf).unwrap();
1170        let text = String::from_utf8(buf).unwrap();
1171        assert!(text.contains("Note: 1 of 3 requested samples (history/window-limited)."));
1172    }
1173
1174    #[test]
1175    fn test_parse_grade() {
1176        assert_eq!(parse_grade("S"), Some(HealthGrade::S));
1177        assert_eq!(parse_grade("A"), Some(HealthGrade::A));
1178        assert_eq!(parse_grade("b"), Some(HealthGrade::B));
1179        assert_eq!(parse_grade("C"), Some(HealthGrade::C));
1180        assert_eq!(parse_grade("X"), None);
1181    }
1182
1183    #[test]
1184    fn test_parse_severity() {
1185        assert_eq!(parse_severity("critical"), Some(Severity::Critical));
1186        assert_eq!(parse_severity("HIGH"), Some(Severity::High));
1187        assert_eq!(parse_severity("invalid"), None);
1188    }
1189
1190    #[test]
1191    fn hotspots_resolve_qualified_aliases_to_source_files() {
1192        let mut metrics = ProjectMetrics::new();
1193        for name in ["consumer", "provider"] {
1194            metrics.add_module(crate::ModuleMetrics::new(
1195                format!("{name}.rs").into(),
1196                name.into(),
1197            ));
1198        }
1199        metrics.add_coupling(crate::CouplingMetrics::new(
1200            "app::consumer".into(),
1201            "app::provider".into(),
1202            crate::IntegrationStrength::Intrusive,
1203            Distance::DifferentModule,
1204            crate::Volatility::High,
1205        ));
1206        let hotspots = calculate_hotspots(&metrics, &IssueThresholds::default(), 10);
1207        let consumer = hotspots
1208            .iter()
1209            .find(|hotspot| hotspot.module == "consumer")
1210            .expect("qualified aliases must map to one module");
1211        assert_eq!(consumer.file_path.as_deref(), Some("consumer.rs"));
1212        assert!(
1213            !hotspots
1214                .iter()
1215                .any(|hotspot| hotspot.module == "app::consumer")
1216        );
1217    }
1218
1219    #[test]
1220    fn test_empty_metrics_hotspots() {
1221        let metrics = ProjectMetrics::new();
1222        let thresholds = IssueThresholds::default();
1223        let hotspots = calculate_hotspots(&metrics, &thresholds, 5);
1224        assert!(hotspots.is_empty());
1225    }
1226
1227    #[test]
1228    fn test_external_dependencies_json_output_shape() {
1229        use crate::external::{ExternalDependencyReport, ExternalDependencyUsage};
1230
1231        let dependencies = vec![ExternalDependencyUsage {
1232            crate_name: "reqwest".to_string(),
1233            versions: vec!["0.12.0".to_string()],
1234            breadth: 4,
1235            total_references: 8,
1236            dominant_strength: "Functional".to_string(),
1237            source_modules: vec![
1238                "api".to_string(),
1239                "client".to_string(),
1240                "sync".to_string(),
1241                "worker".to_string(),
1242            ],
1243        }];
1244        let scattered_couplings =
1245            crate::external::detect_scattered_external_coupling(&dependencies);
1246        let report = ExternalDependencyReport {
1247            dependencies,
1248            scattered_couplings,
1249        };
1250        let mut buf = Vec::new();
1251
1252        generate_external_dependencies_output(&report, true, false, &mut buf).unwrap();
1253
1254        let text = String::from_utf8(buf).unwrap();
1255        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1256        let deps = &parsed["external_dependencies"];
1257        assert_eq!(deps["total_crates"], 1);
1258        assert_eq!(deps["total_references"], 8);
1259        assert_eq!(deps["dependencies"][0]["crate_name"], "reqwest");
1260        assert_eq!(deps["dependencies"][0]["versions"][0], "0.12.0");
1261        assert_eq!(
1262            deps["scattered_couplings"][0]["issue_type"],
1263            "Scattered External Coupling"
1264        );
1265    }
1266
1267    #[test]
1268    fn test_check_passes_on_empty() {
1269        let metrics = ProjectMetrics::new();
1270        let thresholds = IssueThresholds::default();
1271        let config = CheckConfig::default();
1272        let result = run_check(&metrics, &thresholds, &config);
1273        assert!(result.passed);
1274    }
1275
1276    #[test]
1277    fn test_impact_analysis_orders_hash_map_derived_output() {
1278        use crate::metrics::coupling::CouplingMetrics;
1279        use crate::metrics::dimensions::IntegrationStrength;
1280        use crate::metrics::module::ModuleMetrics;
1281        use crate::volatility::Volatility;
1282
1283        let mut metrics = ProjectMetrics::new();
1284        for name in ["root", "zeta", "alpha", "middle"] {
1285            metrics.add_module(ModuleMetrics::new(
1286                PathBuf::from(format!("src/{name}.rs")),
1287                name.to_string(),
1288            ));
1289        }
1290        for (source, target, strength) in [
1291            ("root", "zeta", IntegrationStrength::Model),
1292            ("root", "alpha", IntegrationStrength::Functional),
1293            ("root", "alpha", IntegrationStrength::Model),
1294            ("middle", "root", IntegrationStrength::Contract),
1295            ("zeta", "middle", IntegrationStrength::Model),
1296            ("alpha", "middle", IntegrationStrength::Model),
1297        ] {
1298            metrics.add_coupling(CouplingMetrics::new(
1299                source.to_string(),
1300                target.to_string(),
1301                strength,
1302                Distance::DifferentModule,
1303                Volatility::Low,
1304            ));
1305        }
1306
1307        let analysis = analyze_impact(&metrics, "root").unwrap();
1308
1309        assert_eq!(
1310            analysis
1311                .dependencies
1312                .iter()
1313                .map(|dependency| dependency.module.as_str())
1314                .collect::<Vec<_>>(),
1315            ["alpha", "zeta"]
1316        );
1317        assert_eq!(
1318            analysis.dependencies[0]
1319                .strengths
1320                .iter()
1321                .map(|strength| strength.strength.as_str())
1322                .collect::<Vec<_>>(),
1323            ["Functional", "Model"]
1324        );
1325        assert_eq!(analysis.cascading_impact.second_order, ["alpha", "zeta"]);
1326    }
1327
1328    #[test]
1329    fn test_impact_module_lookup_rejects_substrings_and_reports_ambiguity() {
1330        use crate::metrics::module::ModuleMetrics;
1331
1332        let mut metrics = ProjectMetrics::new();
1333        for name in ["crate_a::config", "crate_b::config", "report"] {
1334            metrics.add_module(ModuleMetrics::new(
1335                PathBuf::from(format!("src/{}.rs", name.replace("::", "/"))),
1336                name.to_string(),
1337            ));
1338        }
1339        metrics.add_coupling(crate::metrics::coupling::CouplingMetrics::new(
1340            "TypeName::crate_c::config".to_string(),
1341            "target".to_string(),
1342            crate::metrics::dimensions::IntegrationStrength::Model,
1343            Distance::DifferentModule,
1344            crate::volatility::Volatility::Low,
1345        ));
1346
1347        assert_eq!(find_module(&metrics, "port"), ModuleLookup::NotFound);
1348        assert_eq!(
1349            find_module(&metrics, "config"),
1350            ModuleLookup::Ambiguous(vec![
1351                "crate_a::config".to_string(),
1352                "crate_b::config".to_string()
1353            ])
1354        );
1355
1356        let mut output = Vec::new();
1357        assert!(!generate_impact_output(&metrics, "config", &mut output).unwrap());
1358        let text = String::from_utf8(output).unwrap();
1359        assert!(text.contains("Module 'config' is ambiguous"));
1360        assert!(text.contains("crate_a::config"));
1361        assert!(text.contains("crate_b::config"));
1362    }
1363
1364    #[test]
1365    fn test_json_output_includes_analysis_manifest() {
1366        let metrics = ProjectMetrics::new();
1367        let thresholds = IssueThresholds::default();
1368        let manifest = build_manifest(&ManifestContext {
1369            git_used: false,
1370            tests_excluded: true,
1371            parse_failures: 0,
1372            skipped_crates: Vec::new(),
1373            boundary_skipped_files: 0,
1374            dead_config_patterns: Vec::new(),
1375        });
1376        let mut buf = Vec::new();
1377
1378        generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
1379
1380        let text = String::from_utf8(buf).unwrap();
1381        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1382        let blind_spots = parsed["analysis_manifest"]["blind_spots"]
1383            .as_array()
1384            .unwrap();
1385        let notes = parsed["analysis_manifest"]["notes"].as_array().unwrap();
1386
1387        assert!(blind_spots.iter().any(|spot| {
1388            spot["area"]
1389                .as_str()
1390                .is_some_and(|area| area == "dynamic-connascence")
1391        }));
1392        assert!(blind_spots.iter().any(|spot| {
1393            spot["area"]
1394                .as_str()
1395                .is_some_and(|area| area == "macro-and-cfg")
1396        }));
1397        assert!(notes.iter().any(|note| {
1398            note.as_str()
1399                .is_some_and(|note| note.contains("Git history was not analyzed"))
1400        }));
1401        assert!(notes.iter().any(|note| {
1402            note.as_str()
1403                .is_some_and(|note| note.contains("Test code was excluded"))
1404        }));
1405    }
1406
1407    #[test]
1408    fn test_json_output_includes_module_subdomain_when_present() {
1409        use crate::config::Subdomain;
1410        use crate::metrics::module::ModuleMetrics;
1411
1412        let mut metrics = ProjectMetrics::new();
1413        let mut module = ModuleMetrics::new(PathBuf::from("src/report.rs"), "report".to_string());
1414        module.subdomain = Some(Subdomain::Supporting);
1415        metrics.add_module(module);
1416
1417        let thresholds = IssueThresholds::default();
1418        let manifest = build_manifest(&ManifestContext::default());
1419        let mut buf = Vec::new();
1420
1421        generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
1422
1423        let text = String::from_utf8(buf).unwrap();
1424        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1425        let modules = parsed["modules"].as_array().unwrap();
1426        assert!(modules.iter().any(|module| {
1427            module["name"] == "report" && module["subdomain"].as_str() == Some("Supporting")
1428        }));
1429    }
1430
1431    #[test]
1432    fn test_json_output_includes_grade_rationale() {
1433        use crate::metrics::coupling::CouplingMetrics;
1434        use crate::metrics::dimensions::IntegrationStrength;
1435        use crate::volatility::Volatility;
1436
1437        let mut metrics = ProjectMetrics::new();
1438        metrics.add_coupling(CouplingMetrics::new(
1439            "caller".to_string(),
1440            "stable".to_string(),
1441            IntegrationStrength::Intrusive,
1442            Distance::DifferentModule,
1443            Volatility::High,
1444        ));
1445
1446        let thresholds = IssueThresholds::default();
1447        let manifest = build_manifest(&ManifestContext::default());
1448        let mut buf = Vec::new();
1449
1450        generate_json_output(&metrics, &thresholds, &manifest, &mut buf).unwrap();
1451
1452        let text = String::from_utf8(buf).unwrap();
1453        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
1454        let rationale = &parsed["grade_rationale"];
1455        assert!(rationale["summary"].as_str().unwrap().contains("Driven by"));
1456        assert_eq!(
1457            rationale["top_issue_types"][0]["issue_type"].as_str(),
1458            Some("Cascading Change Risk")
1459        );
1460        assert!(rationale["note"].as_str().is_some());
1461    }
1462
1463    #[test]
1464    fn test_json_output_is_deterministic() {
1465        use crate::metrics::coupling::CouplingMetrics;
1466        use crate::metrics::dimensions::IntegrationStrength;
1467        use crate::metrics::module::ModuleMetrics;
1468        use crate::volatility::{TemporalCoupling, Volatility};
1469
1470        let mut metrics = ProjectMetrics::new();
1471        for name in ["zeta", "gamma", "epsilon", "delta", "beta", "alpha"] {
1472            metrics.add_module(ModuleMetrics::new(
1473                PathBuf::from(format!("src/{name}.rs")),
1474                name.to_string(),
1475            ));
1476        }
1477        for (source, target) in [
1478            ("alpha", "beta"),
1479            ("alpha", "gamma"),
1480            ("beta", "alpha"),
1481            ("beta", "gamma"),
1482            ("gamma", "alpha"),
1483        ] {
1484            metrics.add_coupling(CouplingMetrics::new(
1485                source.to_string(),
1486                target.to_string(),
1487                IntegrationStrength::Model,
1488                Distance::DifferentModule,
1489                Volatility::Low,
1490            ));
1491        }
1492        metrics.temporal_couplings = vec![
1493            TemporalCoupling {
1494                file_a: "src/delta.rs".to_string(),
1495                file_b: "src/zeta.rs".to_string(),
1496                co_change_count: 5,
1497                coupling_ratio: 0.5,
1498            },
1499            TemporalCoupling {
1500                file_a: "src/delta.rs".to_string(),
1501                file_b: "src/epsilon.rs".to_string(),
1502                co_change_count: 5,
1503                coupling_ratio: 0.5,
1504            },
1505        ];
1506
1507        let thresholds = IssueThresholds::default();
1508        let manifest = build_manifest(&ManifestContext::default());
1509        let mut expected = Vec::new();
1510        generate_json_output(&metrics, &thresholds, &manifest, &mut expected).unwrap();
1511
1512        for _ in 0..32 {
1513            let mut actual = Vec::new();
1514            generate_json_output(&metrics, &thresholds, &manifest, &mut actual).unwrap();
1515            assert_eq!(actual, expected);
1516        }
1517
1518        let parsed: serde_json::Value = serde_json::from_slice(&expected).unwrap();
1519        let module_names: Vec<_> = parsed["modules"]
1520            .as_array()
1521            .unwrap()
1522            .iter()
1523            .map(|module| module["name"].as_str().unwrap())
1524            .collect();
1525        assert_eq!(
1526            module_names,
1527            ["alpha", "beta", "delta", "epsilon", "gamma", "zeta"]
1528        );
1529        assert_eq!(
1530            parsed["circular_dependencies"],
1531            serde_json::json!([
1532                ["alpha", "beta"],
1533                ["alpha", "beta", "gamma"],
1534                ["alpha", "gamma"]
1535            ])
1536        );
1537        assert_eq!(parsed["temporal_couplings"][0]["file_b"], "src/epsilon.rs");
1538        assert_eq!(parsed["temporal_couplings"][1]["file_b"], "src/zeta.rs");
1539    }
1540}