Skip to main content

fallow_cli/
health_types.rs

1//! Health / complexity analysis report types.
2//!
3//! Separated from the `health` command module so that report formatters
4//! (which are compiled as part of both the lib and bin targets) can
5//! reference these types without pulling in binary-only dependencies.
6
7/// Result of complexity analysis for reporting.
8#[derive(Debug, serde::Serialize)]
9pub struct HealthReport {
10    /// Functions exceeding thresholds.
11    pub findings: Vec<HealthFinding>,
12    /// Summary statistics.
13    pub summary: HealthSummary,
14    /// Project-wide vital signs (always computed from available data).
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub vital_signs: Option<VitalSigns>,
17    /// Project-wide health score (only populated with `--score`).
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub health_score: Option<HealthScore>,
20    /// Per-file health scores (only populated with `--file-scores` or `--hotspots`).
21    #[serde(skip_serializing_if = "Vec::is_empty")]
22    pub file_scores: Vec<FileHealthScore>,
23    /// Hotspot entries (only populated with `--hotspots`).
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub hotspots: Vec<HotspotEntry>,
26    /// Hotspot analysis summary (only set with `--hotspots`).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub hotspot_summary: Option<HotspotSummary>,
29    /// Ranked refactoring recommendations (only populated with `--targets`).
30    #[serde(skip_serializing_if = "Vec::is_empty")]
31    pub targets: Vec<RefactoringTarget>,
32    /// Adaptive thresholds used for target scoring (only set with `--targets`).
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub target_thresholds: Option<TargetThresholds>,
35}
36
37/// Project-level health score: a single 0–100 number with letter grade.
38///
39/// ## Score Formula
40///
41/// ```text
42/// score = 100
43///   - min(dead_file_pct × 0.2, 15)
44///   - min(dead_export_pct × 0.2, 15)
45///   - min(max(0, avg_cyclomatic − 1.5) × 5, 20)
46///   - min(max(0, p90_cyclomatic − 10), 10)
47///   - min(max(0, 70 − maintainability_avg) × 0.5, 15)
48///   - min(hotspot_count / total_files × 200, 10)
49///   - min(unused_dep_count, 10)
50///   - min(circular_dep_count, 10)
51/// ```
52///
53/// Missing metrics (from pipelines that didn't run) don't penalize — run
54/// `--score` (which forces full pipeline) for the most accurate result.
55///
56/// ## Letter Grades
57///
58/// A: score ≥ 85, B: 70–84, C: 55–69, D: 40–54, F: below 40.
59#[derive(Debug, Clone, serde::Serialize)]
60pub struct HealthScore {
61    /// Overall score (0–100, higher is better).
62    pub score: f64,
63    /// Letter grade: A, B, C, D, or F.
64    pub grade: &'static str,
65    /// Per-component penalty breakdown. Shows what drove the score down.
66    pub penalties: HealthScorePenalties,
67}
68
69/// Per-component penalty breakdown for the health score.
70///
71/// Each field shows how many points were subtracted for that component.
72/// `None` means the metric was not available (pipeline didn't run).
73#[derive(Debug, Clone, serde::Serialize)]
74pub struct HealthScorePenalties {
75    /// Points lost from dead files (max 15).
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub dead_files: Option<f64>,
78    /// Points lost from dead exports (max 15).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub dead_exports: Option<f64>,
81    /// Points lost from average cyclomatic complexity (max 20).
82    pub complexity: f64,
83    /// Points lost from p90 cyclomatic complexity (max 10).
84    pub p90_complexity: f64,
85    /// Points lost from low maintainability index (max 15).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub maintainability: Option<f64>,
88    /// Points lost from hotspot files (max 10).
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub hotspots: Option<f64>,
91    /// Points lost from unused dependencies (max 10).
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub unused_deps: Option<f64>,
94    /// Points lost from circular dependencies (max 10).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub circular_deps: Option<f64>,
97}
98
99/// Map a numeric score (0–100) to a letter grade.
100pub const fn letter_grade(score: f64) -> &'static str {
101    // Truncate to u32 so that 84.9 maps to B and 85.0 maps to A —
102    // fractional digits don't affect the grade bucket.
103    let s = score as u32;
104    if s >= 85 {
105        "A"
106    } else if s >= 70 {
107        "B"
108    } else if s >= 55 {
109        "C"
110    } else if s >= 40 {
111        "D"
112    } else {
113        "F"
114    }
115}
116
117/// Project-wide vital signs — a fixed set of metrics for trend tracking.
118///
119/// Metrics are `Option` when the data source was not available in the current run
120/// (e.g., `duplication_pct` is `None` unless the duplication pipeline was run,
121/// `hotspot_count` is `None` without git history).
122#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
123pub struct VitalSigns {
124    /// Percentage of files not reachable from any entry point.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub dead_file_pct: Option<f64>,
127    /// Percentage of exports never imported by other modules.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub dead_export_pct: Option<f64>,
130    /// Average cyclomatic complexity across all functions.
131    pub avg_cyclomatic: f64,
132    /// 90th percentile cyclomatic complexity.
133    pub p90_cyclomatic: u32,
134    /// Code duplication percentage (None if duplication pipeline was not run).
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub duplication_pct: Option<f64>,
137    /// Number of hotspot files (score >= 50). None if git history unavailable.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub hotspot_count: Option<u32>,
140    /// Average maintainability index across all scored files (0–100).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub maintainability_avg: Option<f64>,
143    /// Number of unused dependencies (dependencies + devDependencies + optional).
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub unused_dep_count: Option<u32>,
146    /// Number of circular dependency chains.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub circular_dep_count: Option<u32>,
149}
150
151/// Raw counts backing the vital signs percentages.
152///
153/// Stored alongside `VitalSigns` in snapshots so that Phase 2b trend reporting
154/// can decompose percentage changes into numerator vs denominator shifts.
155#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
156pub struct VitalSignsCounts {
157    pub total_files: usize,
158    pub total_exports: usize,
159    pub dead_files: usize,
160    pub dead_exports: usize,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub duplicated_lines: Option<usize>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub total_lines: Option<usize>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub files_scored: Option<usize>,
167    pub total_deps: usize,
168}
169
170/// A point-in-time snapshot of project vital signs, persisted to disk.
171#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
172pub struct VitalSignsSnapshot {
173    /// Schema version for snapshot format (independent of report schema_version).
174    pub snapshot_schema_version: u32,
175    /// Fallow version that produced this snapshot.
176    pub version: String,
177    /// ISO 8601 timestamp.
178    pub timestamp: String,
179    /// Git commit SHA at time of snapshot (None if not in a git repo).
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub git_sha: Option<String>,
182    /// Git branch name (None if not in a git repo or detached HEAD).
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub git_branch: Option<String>,
185    /// Whether the repository is a shallow clone.
186    #[serde(default)]
187    pub shallow_clone: bool,
188    /// The vital signs metrics.
189    pub vital_signs: VitalSigns,
190    /// Raw counts for trend decomposition.
191    pub counts: VitalSignsCounts,
192    /// Project health score (0–100). Added in schema v2.
193    #[serde(skip_serializing_if = "Option::is_none", default)]
194    pub score: Option<f64>,
195    /// Letter grade (A/B/C/D/F). Added in schema v2.
196    #[serde(skip_serializing_if = "Option::is_none", default)]
197    pub grade: Option<String>,
198}
199
200/// Current snapshot schema version. Independent of the report's SCHEMA_VERSION.
201/// v2: Added `score` and `grade` fields.
202pub const SNAPSHOT_SCHEMA_VERSION: u32 = 2;
203
204/// Hotspot score threshold for counting a file as a hotspot in vital signs.
205pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
206
207/// A single function that exceeds a complexity threshold.
208#[derive(Debug, serde::Serialize)]
209pub struct HealthFinding {
210    /// Absolute file path.
211    pub path: std::path::PathBuf,
212    /// Function name.
213    pub name: String,
214    /// 1-based line number.
215    pub line: u32,
216    /// 0-based column.
217    pub col: u32,
218    /// Cyclomatic complexity.
219    pub cyclomatic: u16,
220    /// Cognitive complexity.
221    pub cognitive: u16,
222    /// Number of lines in the function.
223    pub line_count: u32,
224    /// Which threshold was exceeded.
225    pub exceeded: ExceededThreshold,
226}
227
228/// Which complexity threshold was exceeded.
229#[derive(Debug, serde::Serialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ExceededThreshold {
232    /// Only cyclomatic exceeded.
233    Cyclomatic,
234    /// Only cognitive exceeded.
235    Cognitive,
236    /// Both thresholds exceeded.
237    Both,
238}
239
240/// Summary statistics for the health report.
241#[derive(Debug, serde::Serialize)]
242pub struct HealthSummary {
243    /// Number of files analyzed.
244    pub files_analyzed: usize,
245    /// Total number of functions found.
246    pub functions_analyzed: usize,
247    /// Number of functions above threshold.
248    pub functions_above_threshold: usize,
249    /// Configured cyclomatic threshold.
250    pub max_cyclomatic_threshold: u16,
251    /// Configured cognitive threshold.
252    pub max_cognitive_threshold: u16,
253    /// Number of files scored (only set with `--file-scores`).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub files_scored: Option<usize>,
256    /// Average maintainability index across all scored files (only set with `--file-scores`).
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub average_maintainability: Option<f64>,
259}
260
261/// Per-file health score combining complexity, coupling, and dead code metrics.
262///
263/// Files with zero functions (barrel files, re-export files) are excluded by default.
264///
265/// ## Maintainability Index Formula
266///
267/// ```text
268/// fan_out_penalty = min(ln(fan_out + 1) × 4, 15)
269/// maintainability = 100
270///     - (complexity_density × 30)
271///     - (dead_code_ratio × 20)
272///     - fan_out_penalty
273/// ```
274///
275/// Clamped to \[0, 100\]. Higher is better.
276///
277/// - **complexity_density**: total cyclomatic complexity / lines of code
278/// - **dead_code_ratio**: fraction of value exports (excluding type-only exports) with zero references (0.0–1.0)
279/// - **fan_out_penalty**: logarithmic scaling with cap at 15 points; reflects diminishing marginal risk of additional imports
280#[derive(Debug, Clone, serde::Serialize)]
281pub struct FileHealthScore {
282    /// File path (absolute; stripped to relative in output).
283    pub path: std::path::PathBuf,
284    /// Number of files that import this file.
285    pub fan_in: usize,
286    /// Number of files this file imports.
287    pub fan_out: usize,
288    /// Fraction of value exports with zero references (0.0–1.0). Files with no value exports get 0.0.
289    /// Type-only exports (interfaces, type aliases) are excluded from both numerator and denominator
290    /// to avoid inflating the ratio for well-typed codebases that export props types alongside components.
291    pub dead_code_ratio: f64,
292    /// Total cyclomatic complexity / lines of code.
293    pub complexity_density: f64,
294    /// Weighted composite score (0–100, higher is better).
295    pub maintainability_index: f64,
296    /// Sum of cyclomatic complexity across all functions.
297    pub total_cyclomatic: u32,
298    /// Sum of cognitive complexity across all functions.
299    pub total_cognitive: u32,
300    /// Number of functions in this file.
301    pub function_count: usize,
302    /// Total lines of code (from line_offsets).
303    pub lines: u32,
304}
305
306/// A hotspot: a file that is both complex and frequently changing.
307///
308/// ## Score Formula
309///
310/// ```text
311/// normalized_churn = weighted_commits / max_weighted_commits   (0..1)
312/// normalized_complexity = complexity_density / max_density      (0..1)
313/// score = normalized_churn × normalized_complexity × 100       (0..100)
314/// ```
315///
316/// Score uses within-project max normalization. Higher score = higher risk.
317/// Fan-in is shown separately as "blast radius" — not baked into the score.
318#[derive(Debug, Clone, serde::Serialize)]
319pub struct HotspotEntry {
320    /// File path (absolute; stripped to relative in output).
321    pub path: std::path::PathBuf,
322    /// Hotspot score (0–100). Higher means more risk.
323    pub score: f64,
324    /// Number of commits in the analysis window.
325    pub commits: u32,
326    /// Recency-weighted commit count (exponential decay, half-life 90 days).
327    pub weighted_commits: f64,
328    /// Total lines added across all commits.
329    pub lines_added: u32,
330    /// Total lines deleted across all commits.
331    pub lines_deleted: u32,
332    /// Cyclomatic complexity / lines of code.
333    pub complexity_density: f64,
334    /// Number of files that import this file (blast radius).
335    pub fan_in: usize,
336    /// Churn trend: accelerating, stable, or cooling.
337    pub trend: fallow_core::churn::ChurnTrend,
338}
339
340/// Summary statistics for hotspot analysis.
341#[derive(Debug, serde::Serialize)]
342pub struct HotspotSummary {
343    /// Analysis window display string (e.g., "6 months").
344    pub since: String,
345    /// Minimum commits threshold.
346    pub min_commits: u32,
347    /// Number of files with churn data meeting the threshold.
348    pub files_analyzed: usize,
349    /// Number of files excluded (below min_commits).
350    pub files_excluded: usize,
351    /// Whether the repository is a shallow clone.
352    pub shallow_clone: bool,
353}
354
355/// Adaptive thresholds used for refactoring target scoring.
356///
357/// Derived from the project's metric distribution (percentile-based with floors).
358/// Exposed in JSON output so consumers can interpret scores in context.
359#[derive(Debug, Clone, serde::Serialize)]
360#[allow(clippy::struct_field_names)] // triggered in bin but not lib — #[expect] would fail in lib
361pub struct TargetThresholds {
362    /// Fan-in saturation point for priority formula (p95, floor 5).
363    pub fan_in_p95: f64,
364    /// Fan-in moderate threshold for contributing factors (p75, floor 3).
365    pub fan_in_p75: f64,
366    /// Fan-out saturation point for priority formula (p95, floor 8).
367    pub fan_out_p95: f64,
368    /// Fan-out high threshold for rules and contributing factors (p90, floor 5).
369    pub fan_out_p90: usize,
370}
371
372/// Category of refactoring recommendation.
373#[derive(Debug, Clone, serde::Serialize)]
374#[serde(rename_all = "snake_case")]
375pub enum RecommendationCategory {
376    /// Actively-changing file with growing complexity — highest urgency.
377    UrgentChurnComplexity,
378    /// File participates in an import cycle with significant blast radius.
379    BreakCircularDependency,
380    /// High fan-in + high complexity — changes here ripple widely.
381    SplitHighImpact,
382    /// Majority of exports are unused — reduce surface area.
383    RemoveDeadCode,
384    /// Contains functions with very high cognitive complexity.
385    ExtractComplexFunctions,
386    /// Excessive imports reduce testability and increase coupling.
387    ExtractDependencies,
388}
389
390impl RecommendationCategory {
391    /// Human-readable label for terminal output.
392    pub const fn label(&self) -> &'static str {
393        match self {
394            Self::UrgentChurnComplexity => "churn+complexity",
395            Self::BreakCircularDependency => "circular dep",
396            Self::SplitHighImpact => "high impact",
397            Self::RemoveDeadCode => "dead code",
398            Self::ExtractComplexFunctions => "complexity",
399            Self::ExtractDependencies => "coupling",
400        }
401    }
402
403    /// Machine-parseable label for compact output (no spaces).
404    pub const fn compact_label(&self) -> &'static str {
405        match self {
406            Self::UrgentChurnComplexity => "churn_complexity",
407            Self::BreakCircularDependency => "circular_dep",
408            Self::SplitHighImpact => "high_impact",
409            Self::RemoveDeadCode => "dead_code",
410            Self::ExtractComplexFunctions => "complexity",
411            Self::ExtractDependencies => "coupling",
412        }
413    }
414}
415
416/// A contributing factor that triggered or strengthened a recommendation.
417#[derive(Debug, Clone, serde::Serialize)]
418pub struct ContributingFactor {
419    /// Metric name (matches JSON field names: `"fan_in"`, `"dead_code_ratio"`, etc.).
420    pub metric: &'static str,
421    /// Raw metric value for programmatic use.
422    pub value: f64,
423    /// Threshold that was exceeded.
424    pub threshold: f64,
425    /// Human-readable explanation.
426    pub detail: String,
427}
428
429/// A ranked refactoring recommendation for a file.
430///
431/// ## Priority Formula
432///
433/// ```text
434/// priority = min(density, 1) × 30 + hotspot_boost × 25 + dead_code × 20 + fan_in_norm × 15 + fan_out_norm × 10
435/// ```
436///
437/// Fan-in and fan-out normalization uses adaptive percentile-based thresholds
438/// (p95 of the project distribution, with floors) instead of fixed constants.
439///
440/// ## Efficiency (default sort)
441///
442/// ```text
443/// efficiency = priority / effort_numeric   (Low=1, Medium=2, High=3)
444/// ```
445///
446/// Surfaces quick wins: high-priority, low-effort targets rank first.
447/// Effort estimate for a refactoring target.
448#[derive(Debug, Clone, serde::Serialize)]
449#[serde(rename_all = "snake_case")]
450pub enum EffortEstimate {
451    /// Small file, few functions, low fan-in — quick to address.
452    Low,
453    /// Moderate size or coupling — needs planning.
454    Medium,
455    /// Large file, many functions, or high fan-in — significant effort.
456    High,
457}
458
459impl EffortEstimate {
460    /// Human-readable label for terminal output.
461    pub const fn label(&self) -> &'static str {
462        match self {
463            Self::Low => "low",
464            Self::Medium => "medium",
465            Self::High => "high",
466        }
467    }
468
469    /// Numeric value for arithmetic (efficiency = priority / effort).
470    pub const fn numeric(&self) -> f64 {
471        match self {
472            Self::Low => 1.0,
473            Self::Medium => 2.0,
474            Self::High => 3.0,
475        }
476    }
477}
478
479/// Confidence level for a refactoring recommendation.
480///
481/// Based on the data source reliability:
482/// - **High**: deterministic graph/AST analysis (dead code, circular deps, complexity)
483/// - **Medium**: heuristic thresholds (fan-in/fan-out coupling)
484/// - **Low**: depends on git history quality (churn-based recommendations)
485#[derive(Debug, Clone, serde::Serialize)]
486#[serde(rename_all = "snake_case")]
487pub enum Confidence {
488    /// Recommendation based on deterministic analysis (graph, AST).
489    High,
490    /// Recommendation based on heuristic thresholds.
491    Medium,
492    /// Recommendation depends on external data quality (git history).
493    Low,
494}
495
496impl Confidence {
497    /// Human-readable label for terminal output.
498    pub const fn label(&self) -> &'static str {
499        match self {
500            Self::High => "high",
501            Self::Medium => "medium",
502            Self::Low => "low",
503        }
504    }
505}
506
507/// Evidence linking a target back to specific analysis data.
508///
509/// Provides enough detail for an AI agent to act on a recommendation
510/// without a second tool call.
511#[derive(Debug, Clone, serde::Serialize)]
512pub struct TargetEvidence {
513    /// Names of unused exports (populated for `RemoveDeadCode` targets).
514    #[serde(skip_serializing_if = "Vec::is_empty")]
515    pub unused_exports: Vec<String>,
516    /// Complex functions with line numbers and cognitive scores (populated for `ExtractComplexFunctions`).
517    #[serde(skip_serializing_if = "Vec::is_empty")]
518    pub complex_functions: Vec<EvidenceFunction>,
519    /// Files forming the import cycle (populated for `BreakCircularDependency` targets).
520    #[serde(skip_serializing_if = "Vec::is_empty")]
521    pub cycle_path: Vec<String>,
522}
523
524/// A function referenced in target evidence.
525#[derive(Debug, Clone, serde::Serialize)]
526pub struct EvidenceFunction {
527    /// Function name.
528    pub name: String,
529    /// 1-based line number.
530    pub line: u32,
531    /// Cognitive complexity score.
532    pub cognitive: u16,
533}
534
535#[derive(Debug, Clone, serde::Serialize)]
536pub struct RefactoringTarget {
537    /// Absolute file path (stripped to relative in output).
538    pub path: std::path::PathBuf,
539    /// Priority score (0–100, higher = more urgent).
540    pub priority: f64,
541    /// Efficiency score (priority / effort). Higher = better quick-win value.
542    /// Surfaces low-effort, high-priority targets first.
543    pub efficiency: f64,
544    /// One-line actionable recommendation.
545    pub recommendation: String,
546    /// Recommendation category for tooling/filtering.
547    pub category: RecommendationCategory,
548    /// Estimated effort to address this target.
549    pub effort: EffortEstimate,
550    /// Confidence in this recommendation based on data source reliability.
551    pub confidence: Confidence,
552    /// Which metric values contributed to this recommendation.
553    #[serde(skip_serializing_if = "Vec::is_empty")]
554    pub factors: Vec<ContributingFactor>,
555    /// Structured evidence linking to specific analysis data.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub evidence: Option<TargetEvidence>,
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    // --- RecommendationCategory ---
565
566    #[test]
567    fn category_labels_are_non_empty() {
568        let categories = [
569            RecommendationCategory::UrgentChurnComplexity,
570            RecommendationCategory::BreakCircularDependency,
571            RecommendationCategory::SplitHighImpact,
572            RecommendationCategory::RemoveDeadCode,
573            RecommendationCategory::ExtractComplexFunctions,
574            RecommendationCategory::ExtractDependencies,
575        ];
576        for cat in &categories {
577            assert!(!cat.label().is_empty(), "{cat:?} should have a label");
578        }
579    }
580
581    #[test]
582    fn category_labels_are_unique() {
583        let categories = [
584            RecommendationCategory::UrgentChurnComplexity,
585            RecommendationCategory::BreakCircularDependency,
586            RecommendationCategory::SplitHighImpact,
587            RecommendationCategory::RemoveDeadCode,
588            RecommendationCategory::ExtractComplexFunctions,
589            RecommendationCategory::ExtractDependencies,
590        ];
591        let labels: Vec<&str> = categories
592            .iter()
593            .map(super::RecommendationCategory::label)
594            .collect();
595        let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
596        assert_eq!(labels.len(), unique.len(), "category labels must be unique");
597    }
598
599    // --- Serde serialization ---
600
601    #[test]
602    fn category_serializes_as_snake_case() {
603        let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
604        assert_eq!(json, r#""urgent_churn_complexity""#);
605
606        let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
607        assert_eq!(json, r#""break_circular_dependency""#);
608    }
609
610    #[test]
611    fn exceeded_threshold_serializes_as_snake_case() {
612        let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
613        assert_eq!(json, r#""both""#);
614
615        let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
616        assert_eq!(json, r#""cyclomatic""#);
617    }
618
619    #[test]
620    fn health_report_skips_empty_collections() {
621        let report = HealthReport {
622            findings: vec![],
623            summary: HealthSummary {
624                files_analyzed: 0,
625                functions_analyzed: 0,
626                functions_above_threshold: 0,
627                max_cyclomatic_threshold: 20,
628                max_cognitive_threshold: 15,
629                files_scored: None,
630                average_maintainability: None,
631            },
632            vital_signs: None,
633            health_score: None,
634            file_scores: vec![],
635            hotspots: vec![],
636            hotspot_summary: None,
637            targets: vec![],
638            target_thresholds: None,
639        };
640        let json = serde_json::to_string(&report).unwrap();
641        // Empty vecs should be omitted due to skip_serializing_if
642        assert!(!json.contains("file_scores"));
643        assert!(!json.contains("hotspots"));
644        assert!(!json.contains("hotspot_summary"));
645        assert!(!json.contains("targets"));
646        assert!(!json.contains("vital_signs"));
647        assert!(!json.contains("health_score"));
648    }
649
650    #[test]
651    fn vital_signs_serialization_roundtrip() {
652        let vs = VitalSigns {
653            dead_file_pct: Some(3.2),
654            dead_export_pct: Some(8.1),
655            avg_cyclomatic: 4.7,
656            p90_cyclomatic: 12,
657            duplication_pct: None,
658            hotspot_count: Some(5),
659            maintainability_avg: Some(72.4),
660            unused_dep_count: Some(4),
661            circular_dep_count: Some(2),
662        };
663        let json = serde_json::to_string(&vs).unwrap();
664        let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
665        assert!((deserialized.avg_cyclomatic - 4.7).abs() < f64::EPSILON);
666        assert_eq!(deserialized.p90_cyclomatic, 12);
667        assert_eq!(deserialized.hotspot_count, Some(5));
668        // duplication_pct should be absent in JSON and None after deser
669        assert!(!json.contains("duplication_pct"));
670        assert!(deserialized.duplication_pct.is_none());
671    }
672
673    #[test]
674    fn vital_signs_snapshot_roundtrip() {
675        let snapshot = VitalSignsSnapshot {
676            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
677            version: "1.8.1".into(),
678            timestamp: "2026-03-25T14:30:00Z".into(),
679            git_sha: Some("abc1234".into()),
680            git_branch: Some("main".into()),
681            shallow_clone: false,
682            vital_signs: VitalSigns {
683                dead_file_pct: Some(3.2),
684                dead_export_pct: Some(8.1),
685                avg_cyclomatic: 4.7,
686                p90_cyclomatic: 12,
687                duplication_pct: None,
688                hotspot_count: None,
689                maintainability_avg: Some(72.4),
690                unused_dep_count: Some(4),
691                circular_dep_count: Some(2),
692            },
693            counts: VitalSignsCounts {
694                total_files: 1200,
695                total_exports: 5400,
696                dead_files: 38,
697                dead_exports: 437,
698                duplicated_lines: None,
699                total_lines: None,
700                files_scored: Some(1150),
701                total_deps: 42,
702            },
703            score: Some(78.5),
704            grade: Some("B".into()),
705        };
706        let json = serde_json::to_string_pretty(&snapshot).unwrap();
707        let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
708        assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
709        assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
710        assert_eq!(rt.counts.total_files, 1200);
711        assert_eq!(rt.counts.dead_exports, 437);
712        assert_eq!(rt.score, Some(78.5));
713        assert_eq!(rt.grade.as_deref(), Some("B"));
714    }
715
716    #[test]
717    fn refactoring_target_skips_empty_factors() {
718        let target = RefactoringTarget {
719            path: std::path::PathBuf::from("/src/foo.ts"),
720            priority: 75.0,
721            efficiency: 75.0,
722            recommendation: "Test recommendation".into(),
723            category: RecommendationCategory::RemoveDeadCode,
724            effort: EffortEstimate::Low,
725            confidence: Confidence::High,
726            factors: vec![],
727            evidence: None,
728        };
729        let json = serde_json::to_string(&target).unwrap();
730        assert!(!json.contains("factors"));
731        assert!(!json.contains("evidence"));
732    }
733
734    #[test]
735    fn effort_numeric_values() {
736        assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
737        assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
738        assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
739    }
740
741    #[test]
742    fn confidence_labels_are_non_empty() {
743        let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
744        for level in &levels {
745            assert!(!level.label().is_empty(), "{level:?} should have a label");
746        }
747    }
748
749    #[test]
750    fn confidence_serializes_as_snake_case() {
751        let json = serde_json::to_string(&Confidence::High).unwrap();
752        assert_eq!(json, r#""high""#);
753        let json = serde_json::to_string(&Confidence::Medium).unwrap();
754        assert_eq!(json, r#""medium""#);
755        let json = serde_json::to_string(&Confidence::Low).unwrap();
756        assert_eq!(json, r#""low""#);
757    }
758
759    #[test]
760    fn contributing_factor_serializes_correctly() {
761        let factor = ContributingFactor {
762            metric: "fan_in",
763            value: 15.0,
764            threshold: 10.0,
765            detail: "15 files depend on this".into(),
766        };
767        let json = serde_json::to_string(&factor).unwrap();
768        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
769        assert_eq!(parsed["metric"], "fan_in");
770        assert_eq!(parsed["value"], 15.0);
771        assert_eq!(parsed["threshold"], 10.0);
772    }
773
774    // --- RecommendationCategory compact_labels ---
775
776    #[test]
777    fn category_compact_labels_are_non_empty() {
778        let categories = [
779            RecommendationCategory::UrgentChurnComplexity,
780            RecommendationCategory::BreakCircularDependency,
781            RecommendationCategory::SplitHighImpact,
782            RecommendationCategory::RemoveDeadCode,
783            RecommendationCategory::ExtractComplexFunctions,
784            RecommendationCategory::ExtractDependencies,
785        ];
786        for cat in &categories {
787            assert!(
788                !cat.compact_label().is_empty(),
789                "{cat:?} should have a compact_label"
790            );
791        }
792    }
793
794    #[test]
795    fn category_compact_labels_are_unique() {
796        let categories = [
797            RecommendationCategory::UrgentChurnComplexity,
798            RecommendationCategory::BreakCircularDependency,
799            RecommendationCategory::SplitHighImpact,
800            RecommendationCategory::RemoveDeadCode,
801            RecommendationCategory::ExtractComplexFunctions,
802            RecommendationCategory::ExtractDependencies,
803        ];
804        let labels: Vec<&str> = categories.iter().map(|c| c.compact_label()).collect();
805        let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
806        assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
807    }
808
809    #[test]
810    fn category_compact_labels_have_no_spaces() {
811        let categories = [
812            RecommendationCategory::UrgentChurnComplexity,
813            RecommendationCategory::BreakCircularDependency,
814            RecommendationCategory::SplitHighImpact,
815            RecommendationCategory::RemoveDeadCode,
816            RecommendationCategory::ExtractComplexFunctions,
817            RecommendationCategory::ExtractDependencies,
818        ];
819        for cat in &categories {
820            assert!(
821                !cat.compact_label().contains(' '),
822                "compact_label for {:?} should not contain spaces: '{}'",
823                cat,
824                cat.compact_label()
825            );
826        }
827    }
828
829    // --- EffortEstimate ---
830
831    #[test]
832    fn effort_labels_are_non_empty() {
833        let efforts = [
834            EffortEstimate::Low,
835            EffortEstimate::Medium,
836            EffortEstimate::High,
837        ];
838        for effort in &efforts {
839            assert!(!effort.label().is_empty(), "{effort:?} should have a label");
840        }
841    }
842
843    #[test]
844    fn effort_serializes_as_snake_case() {
845        assert_eq!(
846            serde_json::to_string(&EffortEstimate::Low).unwrap(),
847            r#""low""#
848        );
849        assert_eq!(
850            serde_json::to_string(&EffortEstimate::Medium).unwrap(),
851            r#""medium""#
852        );
853        assert_eq!(
854            serde_json::to_string(&EffortEstimate::High).unwrap(),
855            r#""high""#
856        );
857    }
858
859    // --- VitalSigns omits None fields ---
860
861    #[test]
862    fn vital_signs_all_none_optional_fields_omitted() {
863        let vs = VitalSigns {
864            dead_file_pct: None,
865            dead_export_pct: None,
866            avg_cyclomatic: 5.0,
867            p90_cyclomatic: 10,
868            duplication_pct: None,
869            hotspot_count: None,
870            maintainability_avg: None,
871            unused_dep_count: None,
872            circular_dep_count: None,
873        };
874        let json = serde_json::to_string(&vs).unwrap();
875        assert!(!json.contains("dead_file_pct"));
876        assert!(!json.contains("dead_export_pct"));
877        assert!(!json.contains("duplication_pct"));
878        assert!(!json.contains("hotspot_count"));
879        assert!(!json.contains("maintainability_avg"));
880        assert!(!json.contains("unused_dep_count"));
881        assert!(!json.contains("circular_dep_count"));
882        // Required fields always present
883        assert!(json.contains("avg_cyclomatic"));
884        assert!(json.contains("p90_cyclomatic"));
885    }
886
887    // --- ExceededThreshold ---
888
889    #[test]
890    fn exceeded_threshold_all_variants_serialize() {
891        for variant in [
892            ExceededThreshold::Cyclomatic,
893            ExceededThreshold::Cognitive,
894            ExceededThreshold::Both,
895        ] {
896            let json = serde_json::to_string(&variant).unwrap();
897            assert!(!json.is_empty());
898        }
899    }
900
901    // --- TargetEvidence ---
902
903    #[test]
904    fn target_evidence_skips_empty_fields() {
905        let evidence = TargetEvidence {
906            unused_exports: vec![],
907            complex_functions: vec![],
908            cycle_path: vec![],
909        };
910        let json = serde_json::to_string(&evidence).unwrap();
911        assert!(!json.contains("unused_exports"));
912        assert!(!json.contains("complex_functions"));
913        assert!(!json.contains("cycle_path"));
914    }
915
916    #[test]
917    fn target_evidence_with_data() {
918        let evidence = TargetEvidence {
919            unused_exports: vec!["foo".to_string(), "bar".to_string()],
920            complex_functions: vec![EvidenceFunction {
921                name: "processData".into(),
922                line: 42,
923                cognitive: 30,
924            }],
925            cycle_path: vec![],
926        };
927        let json = serde_json::to_string(&evidence).unwrap();
928        assert!(json.contains("unused_exports"));
929        assert!(json.contains("complex_functions"));
930        assert!(json.contains("processData"));
931        assert!(!json.contains("cycle_path"));
932    }
933
934    // --- VitalSignsSnapshot schema version ---
935
936    #[test]
937    fn snapshot_schema_version_is_two() {
938        assert_eq!(SNAPSHOT_SCHEMA_VERSION, 2);
939    }
940
941    #[test]
942    fn hotspot_score_threshold_is_50() {
943        assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
944    }
945
946    #[test]
947    fn snapshot_v1_deserializes_with_default_score_and_grade() {
948        // A v1 snapshot without score/grade fields must still deserialize
949        let json = r#"{
950            "snapshot_schema_version": 1,
951            "version": "1.5.0",
952            "timestamp": "2025-01-01T00:00:00Z",
953            "shallow_clone": false,
954            "vital_signs": {
955                "avg_cyclomatic": 2.0,
956                "p90_cyclomatic": 5
957            },
958            "counts": {
959                "total_files": 100,
960                "total_exports": 500,
961                "dead_files": 0,
962                "dead_exports": 0,
963                "total_deps": 20
964            }
965        }"#;
966        let snap: VitalSignsSnapshot = serde_json::from_str(json).unwrap();
967        assert!(snap.score.is_none());
968        assert!(snap.grade.is_none());
969        assert_eq!(snap.snapshot_schema_version, 1);
970    }
971
972    // --- letter_grade ---
973
974    #[test]
975    fn letter_grade_boundaries() {
976        assert_eq!(letter_grade(100.0), "A");
977        assert_eq!(letter_grade(85.0), "A");
978        assert_eq!(letter_grade(84.9), "B");
979        assert_eq!(letter_grade(70.0), "B");
980        assert_eq!(letter_grade(69.9), "C");
981        assert_eq!(letter_grade(55.0), "C");
982        assert_eq!(letter_grade(54.9), "D");
983        assert_eq!(letter_grade(40.0), "D");
984        assert_eq!(letter_grade(39.9), "F");
985        assert_eq!(letter_grade(0.0), "F");
986    }
987
988    // --- HealthScore ---
989
990    #[test]
991    fn health_score_serializes_correctly() {
992        let score = HealthScore {
993            score: 78.5,
994            grade: "B",
995            penalties: HealthScorePenalties {
996                dead_files: Some(3.1),
997                dead_exports: Some(6.0),
998                complexity: 0.0,
999                p90_complexity: 0.0,
1000                maintainability: None,
1001                hotspots: None,
1002                unused_deps: Some(5.0),
1003                circular_deps: Some(4.0),
1004            },
1005        };
1006        let json = serde_json::to_string(&score).unwrap();
1007        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1008        assert_eq!(parsed["score"], 78.5);
1009        assert_eq!(parsed["grade"], "B");
1010        assert_eq!(parsed["penalties"]["dead_files"], 3.1);
1011        // None fields should be absent
1012        assert!(!json.contains("maintainability"));
1013        assert!(!json.contains("hotspots"));
1014    }
1015
1016    #[test]
1017    fn health_score_none_skipped_in_report() {
1018        let report = HealthReport {
1019            findings: vec![],
1020            summary: HealthSummary {
1021                files_analyzed: 0,
1022                functions_analyzed: 0,
1023                functions_above_threshold: 0,
1024                max_cyclomatic_threshold: 20,
1025                max_cognitive_threshold: 15,
1026                files_scored: None,
1027                average_maintainability: None,
1028            },
1029            vital_signs: None,
1030            health_score: None,
1031            file_scores: vec![],
1032            hotspots: vec![],
1033            hotspot_summary: None,
1034            targets: vec![],
1035            target_thresholds: None,
1036        };
1037        let json = serde_json::to_string(&report).unwrap();
1038        assert!(!json.contains("health_score"));
1039    }
1040}