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    /// Per-file health scores (only populated with `--file-scores` or `--hotspots`).
18    #[serde(skip_serializing_if = "Vec::is_empty")]
19    pub file_scores: Vec<FileHealthScore>,
20    /// Hotspot entries (only populated with `--hotspots`).
21    #[serde(skip_serializing_if = "Vec::is_empty")]
22    pub hotspots: Vec<HotspotEntry>,
23    /// Hotspot analysis summary (only set with `--hotspots`).
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub hotspot_summary: Option<HotspotSummary>,
26    /// Ranked refactoring recommendations (only populated with `--targets`).
27    #[serde(skip_serializing_if = "Vec::is_empty")]
28    pub targets: Vec<RefactoringTarget>,
29    /// Adaptive thresholds used for target scoring (only set with `--targets`).
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub target_thresholds: Option<TargetThresholds>,
32}
33
34/// Project-wide vital signs — a fixed set of metrics for trend tracking.
35///
36/// Metrics are `Option` when the data source was not available in the current run
37/// (e.g., `duplication_pct` is `None` unless the duplication pipeline was run,
38/// `hotspot_count` is `None` without git history).
39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
40pub struct VitalSigns {
41    /// Percentage of files not reachable from any entry point.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub dead_file_pct: Option<f64>,
44    /// Percentage of exports never imported by other modules.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub dead_export_pct: Option<f64>,
47    /// Average cyclomatic complexity across all functions.
48    pub avg_cyclomatic: f64,
49    /// 90th percentile cyclomatic complexity.
50    pub p90_cyclomatic: u32,
51    /// Code duplication percentage (None if duplication pipeline was not run).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub duplication_pct: Option<f64>,
54    /// Number of hotspot files (score >= 50). None if git history unavailable.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub hotspot_count: Option<u32>,
57    /// Average maintainability index across all scored files (0–100).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub maintainability_avg: Option<f64>,
60    /// Number of unused dependencies (dependencies + devDependencies + optional).
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub unused_dep_count: Option<u32>,
63    /// Number of circular dependency chains.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub circular_dep_count: Option<u32>,
66}
67
68/// Raw counts backing the vital signs percentages.
69///
70/// Stored alongside `VitalSigns` in snapshots so that Phase 2b trend reporting
71/// can decompose percentage changes into numerator vs denominator shifts.
72#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
73pub struct VitalSignsCounts {
74    pub total_files: usize,
75    pub total_exports: usize,
76    pub dead_files: usize,
77    pub dead_exports: usize,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub duplicated_lines: Option<usize>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub total_lines: Option<usize>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub files_scored: Option<usize>,
84    pub total_deps: usize,
85}
86
87/// A point-in-time snapshot of project vital signs, persisted to disk.
88#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
89pub struct VitalSignsSnapshot {
90    /// Schema version for snapshot format (independent of report schema_version).
91    pub snapshot_schema_version: u32,
92    /// Fallow version that produced this snapshot.
93    pub version: String,
94    /// ISO 8601 timestamp.
95    pub timestamp: String,
96    /// Git commit SHA at time of snapshot (None if not in a git repo).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub git_sha: Option<String>,
99    /// Git branch name (None if not in a git repo or detached HEAD).
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub git_branch: Option<String>,
102    /// Whether the repository is a shallow clone.
103    #[serde(default)]
104    pub shallow_clone: bool,
105    /// The vital signs metrics.
106    pub vital_signs: VitalSigns,
107    /// Raw counts for trend decomposition.
108    pub counts: VitalSignsCounts,
109}
110
111/// Current snapshot schema version. Independent of the report's SCHEMA_VERSION.
112pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
113
114/// Hotspot score threshold for counting a file as a hotspot in vital signs.
115pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
116
117/// A single function that exceeds a complexity threshold.
118#[derive(Debug, serde::Serialize)]
119pub struct HealthFinding {
120    /// Absolute file path.
121    pub path: std::path::PathBuf,
122    /// Function name.
123    pub name: String,
124    /// 1-based line number.
125    pub line: u32,
126    /// 0-based column.
127    pub col: u32,
128    /// Cyclomatic complexity.
129    pub cyclomatic: u16,
130    /// Cognitive complexity.
131    pub cognitive: u16,
132    /// Number of lines in the function.
133    pub line_count: u32,
134    /// Which threshold was exceeded.
135    pub exceeded: ExceededThreshold,
136}
137
138/// Which complexity threshold was exceeded.
139#[derive(Debug, serde::Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ExceededThreshold {
142    /// Only cyclomatic exceeded.
143    Cyclomatic,
144    /// Only cognitive exceeded.
145    Cognitive,
146    /// Both thresholds exceeded.
147    Both,
148}
149
150/// Summary statistics for the health report.
151#[derive(Debug, serde::Serialize)]
152pub struct HealthSummary {
153    /// Number of files analyzed.
154    pub files_analyzed: usize,
155    /// Total number of functions found.
156    pub functions_analyzed: usize,
157    /// Number of functions above threshold.
158    pub functions_above_threshold: usize,
159    /// Configured cyclomatic threshold.
160    pub max_cyclomatic_threshold: u16,
161    /// Configured cognitive threshold.
162    pub max_cognitive_threshold: u16,
163    /// Number of files scored (only set with `--file-scores`).
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub files_scored: Option<usize>,
166    /// Average maintainability index across all scored files (only set with `--file-scores`).
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub average_maintainability: Option<f64>,
169}
170
171/// Per-file health score combining complexity, coupling, and dead code metrics.
172///
173/// Files with zero functions (barrel files, re-export files) are excluded by default.
174///
175/// ## Maintainability Index Formula
176///
177/// ```text
178/// fan_out_penalty = min(ln(fan_out + 1) × 4, 15)
179/// maintainability = 100
180///     - (complexity_density × 30)
181///     - (dead_code_ratio × 20)
182///     - fan_out_penalty
183/// ```
184///
185/// Clamped to \[0, 100\]. Higher is better.
186///
187/// - **complexity_density**: total cyclomatic complexity / lines of code
188/// - **dead_code_ratio**: fraction of value exports (excluding type-only exports) with zero references (0.0–1.0)
189/// - **fan_out_penalty**: logarithmic scaling with cap at 15 points; reflects diminishing marginal risk of additional imports
190#[derive(Debug, Clone, serde::Serialize)]
191pub struct FileHealthScore {
192    /// File path (absolute; stripped to relative in output).
193    pub path: std::path::PathBuf,
194    /// Number of files that import this file.
195    pub fan_in: usize,
196    /// Number of files this file imports.
197    pub fan_out: usize,
198    /// Fraction of value exports with zero references (0.0–1.0). Files with no value exports get 0.0.
199    /// Type-only exports (interfaces, type aliases) are excluded from both numerator and denominator
200    /// to avoid inflating the ratio for well-typed codebases that export props types alongside components.
201    pub dead_code_ratio: f64,
202    /// Total cyclomatic complexity / lines of code.
203    pub complexity_density: f64,
204    /// Weighted composite score (0–100, higher is better).
205    pub maintainability_index: f64,
206    /// Sum of cyclomatic complexity across all functions.
207    pub total_cyclomatic: u32,
208    /// Sum of cognitive complexity across all functions.
209    pub total_cognitive: u32,
210    /// Number of functions in this file.
211    pub function_count: usize,
212    /// Total lines of code (from line_offsets).
213    pub lines: u32,
214}
215
216/// A hotspot: a file that is both complex and frequently changing.
217///
218/// ## Score Formula
219///
220/// ```text
221/// normalized_churn = weighted_commits / max_weighted_commits   (0..1)
222/// normalized_complexity = complexity_density / max_density      (0..1)
223/// score = normalized_churn × normalized_complexity × 100       (0..100)
224/// ```
225///
226/// Score uses within-project max normalization. Higher score = higher risk.
227/// Fan-in is shown separately as "blast radius" — not baked into the score.
228#[derive(Debug, Clone, serde::Serialize)]
229pub struct HotspotEntry {
230    /// File path (absolute; stripped to relative in output).
231    pub path: std::path::PathBuf,
232    /// Hotspot score (0–100). Higher means more risk.
233    pub score: f64,
234    /// Number of commits in the analysis window.
235    pub commits: u32,
236    /// Recency-weighted commit count (exponential decay, half-life 90 days).
237    pub weighted_commits: f64,
238    /// Total lines added across all commits.
239    pub lines_added: u32,
240    /// Total lines deleted across all commits.
241    pub lines_deleted: u32,
242    /// Cyclomatic complexity / lines of code.
243    pub complexity_density: f64,
244    /// Number of files that import this file (blast radius).
245    pub fan_in: usize,
246    /// Churn trend: accelerating, stable, or cooling.
247    pub trend: fallow_core::churn::ChurnTrend,
248}
249
250/// Summary statistics for hotspot analysis.
251#[derive(Debug, serde::Serialize)]
252pub struct HotspotSummary {
253    /// Analysis window display string (e.g., "6 months").
254    pub since: String,
255    /// Minimum commits threshold.
256    pub min_commits: u32,
257    /// Number of files with churn data meeting the threshold.
258    pub files_analyzed: usize,
259    /// Number of files excluded (below min_commits).
260    pub files_excluded: usize,
261    /// Whether the repository is a shallow clone.
262    pub shallow_clone: bool,
263}
264
265/// Adaptive thresholds used for refactoring target scoring.
266///
267/// Derived from the project's metric distribution (percentile-based with floors).
268/// Exposed in JSON output so consumers can interpret scores in context.
269#[derive(Debug, Clone, serde::Serialize)]
270#[allow(clippy::struct_field_names)] // triggered in bin but not lib — #[expect] would fail in lib
271pub struct TargetThresholds {
272    /// Fan-in saturation point for priority formula (p95, floor 5).
273    pub fan_in_p95: f64,
274    /// Fan-in moderate threshold for contributing factors (p75, floor 3).
275    pub fan_in_p75: f64,
276    /// Fan-out saturation point for priority formula (p95, floor 8).
277    pub fan_out_p95: f64,
278    /// Fan-out high threshold for rules and contributing factors (p90, floor 5).
279    pub fan_out_p90: usize,
280}
281
282/// Category of refactoring recommendation.
283#[derive(Debug, Clone, serde::Serialize)]
284#[serde(rename_all = "snake_case")]
285pub enum RecommendationCategory {
286    /// Actively-changing file with growing complexity — highest urgency.
287    UrgentChurnComplexity,
288    /// File participates in an import cycle with significant blast radius.
289    BreakCircularDependency,
290    /// High fan-in + high complexity — changes here ripple widely.
291    SplitHighImpact,
292    /// Majority of exports are unused — reduce surface area.
293    RemoveDeadCode,
294    /// Contains functions with very high cognitive complexity.
295    ExtractComplexFunctions,
296    /// Excessive imports reduce testability and increase coupling.
297    ExtractDependencies,
298}
299
300impl RecommendationCategory {
301    /// Human-readable label for terminal output.
302    pub fn label(&self) -> &'static str {
303        match self {
304            Self::UrgentChurnComplexity => "churn+complexity",
305            Self::BreakCircularDependency => "circular dep",
306            Self::SplitHighImpact => "high impact",
307            Self::RemoveDeadCode => "dead code",
308            Self::ExtractComplexFunctions => "complexity",
309            Self::ExtractDependencies => "coupling",
310        }
311    }
312
313    /// Machine-parseable label for compact output (no spaces).
314    pub fn compact_label(&self) -> &'static str {
315        match self {
316            Self::UrgentChurnComplexity => "churn_complexity",
317            Self::BreakCircularDependency => "circular_dep",
318            Self::SplitHighImpact => "high_impact",
319            Self::RemoveDeadCode => "dead_code",
320            Self::ExtractComplexFunctions => "complexity",
321            Self::ExtractDependencies => "coupling",
322        }
323    }
324}
325
326/// A contributing factor that triggered or strengthened a recommendation.
327#[derive(Debug, Clone, serde::Serialize)]
328pub struct ContributingFactor {
329    /// Metric name (matches JSON field names: `"fan_in"`, `"dead_code_ratio"`, etc.).
330    pub metric: &'static str,
331    /// Raw metric value for programmatic use.
332    pub value: f64,
333    /// Threshold that was exceeded.
334    pub threshold: f64,
335    /// Human-readable explanation.
336    pub detail: String,
337}
338
339/// A ranked refactoring recommendation for a file.
340///
341/// ## Priority Formula
342///
343/// ```text
344/// priority = min(density, 1) × 30 + hotspot_boost × 25 + dead_code × 20 + fan_in_norm × 15 + fan_out_norm × 10
345/// ```
346///
347/// Fan-in and fan-out normalization uses adaptive percentile-based thresholds
348/// (p95 of the project distribution, with floors) instead of fixed constants.
349///
350/// ## Efficiency (default sort)
351///
352/// ```text
353/// efficiency = priority / effort_numeric   (Low=1, Medium=2, High=3)
354/// ```
355///
356/// Surfaces quick wins: high-priority, low-effort targets rank first.
357/// Effort estimate for a refactoring target.
358#[derive(Debug, Clone, serde::Serialize)]
359#[serde(rename_all = "snake_case")]
360pub enum EffortEstimate {
361    /// Small file, few functions, low fan-in — quick to address.
362    Low,
363    /// Moderate size or coupling — needs planning.
364    Medium,
365    /// Large file, many functions, or high fan-in — significant effort.
366    High,
367}
368
369impl EffortEstimate {
370    /// Human-readable label for terminal output.
371    pub fn label(&self) -> &'static str {
372        match self {
373            Self::Low => "low",
374            Self::Medium => "medium",
375            Self::High => "high",
376        }
377    }
378
379    /// Numeric value for arithmetic (efficiency = priority / effort).
380    pub fn numeric(&self) -> f64 {
381        match self {
382            Self::Low => 1.0,
383            Self::Medium => 2.0,
384            Self::High => 3.0,
385        }
386    }
387}
388
389/// Confidence level for a refactoring recommendation.
390///
391/// Based on the data source reliability:
392/// - **High**: deterministic graph/AST analysis (dead code, circular deps, complexity)
393/// - **Medium**: heuristic thresholds (fan-in/fan-out coupling)
394/// - **Low**: depends on git history quality (churn-based recommendations)
395#[derive(Debug, Clone, serde::Serialize)]
396#[serde(rename_all = "snake_case")]
397pub enum Confidence {
398    /// Recommendation based on deterministic analysis (graph, AST).
399    High,
400    /// Recommendation based on heuristic thresholds.
401    Medium,
402    /// Recommendation depends on external data quality (git history).
403    Low,
404}
405
406impl Confidence {
407    /// Human-readable label for terminal output.
408    pub fn label(&self) -> &'static str {
409        match self {
410            Self::High => "high",
411            Self::Medium => "medium",
412            Self::Low => "low",
413        }
414    }
415}
416
417/// Evidence linking a target back to specific analysis data.
418///
419/// Provides enough detail for an AI agent to act on a recommendation
420/// without a second tool call.
421#[derive(Debug, Clone, serde::Serialize)]
422pub struct TargetEvidence {
423    /// Names of unused exports (populated for `RemoveDeadCode` targets).
424    #[serde(skip_serializing_if = "Vec::is_empty")]
425    pub unused_exports: Vec<String>,
426    /// Complex functions with line numbers and cognitive scores (populated for `ExtractComplexFunctions`).
427    #[serde(skip_serializing_if = "Vec::is_empty")]
428    pub complex_functions: Vec<EvidenceFunction>,
429    /// Files forming the import cycle (populated for `BreakCircularDependency` targets).
430    #[serde(skip_serializing_if = "Vec::is_empty")]
431    pub cycle_path: Vec<String>,
432}
433
434/// A function referenced in target evidence.
435#[derive(Debug, Clone, serde::Serialize)]
436pub struct EvidenceFunction {
437    /// Function name.
438    pub name: String,
439    /// 1-based line number.
440    pub line: u32,
441    /// Cognitive complexity score.
442    pub cognitive: u16,
443}
444
445#[derive(Debug, Clone, serde::Serialize)]
446pub struct RefactoringTarget {
447    /// Absolute file path (stripped to relative in output).
448    pub path: std::path::PathBuf,
449    /// Priority score (0–100, higher = more urgent).
450    pub priority: f64,
451    /// Efficiency score (priority / effort). Higher = better quick-win value.
452    /// Surfaces low-effort, high-priority targets first.
453    pub efficiency: f64,
454    /// One-line actionable recommendation.
455    pub recommendation: String,
456    /// Recommendation category for tooling/filtering.
457    pub category: RecommendationCategory,
458    /// Estimated effort to address this target.
459    pub effort: EffortEstimate,
460    /// Confidence in this recommendation based on data source reliability.
461    pub confidence: Confidence,
462    /// Which metric values contributed to this recommendation.
463    #[serde(skip_serializing_if = "Vec::is_empty")]
464    pub factors: Vec<ContributingFactor>,
465    /// Structured evidence linking to specific analysis data.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub evidence: Option<TargetEvidence>,
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    // --- RecommendationCategory ---
475
476    #[test]
477    fn category_labels_are_non_empty() {
478        let categories = [
479            RecommendationCategory::UrgentChurnComplexity,
480            RecommendationCategory::BreakCircularDependency,
481            RecommendationCategory::SplitHighImpact,
482            RecommendationCategory::RemoveDeadCode,
483            RecommendationCategory::ExtractComplexFunctions,
484            RecommendationCategory::ExtractDependencies,
485        ];
486        for cat in &categories {
487            assert!(!cat.label().is_empty(), "{cat:?} should have a label");
488        }
489    }
490
491    #[test]
492    fn category_labels_are_unique() {
493        let categories = [
494            RecommendationCategory::UrgentChurnComplexity,
495            RecommendationCategory::BreakCircularDependency,
496            RecommendationCategory::SplitHighImpact,
497            RecommendationCategory::RemoveDeadCode,
498            RecommendationCategory::ExtractComplexFunctions,
499            RecommendationCategory::ExtractDependencies,
500        ];
501        let labels: Vec<&str> = categories.iter().map(|c| c.label()).collect();
502        let unique: std::collections::HashSet<&&str> = labels.iter().collect();
503        assert_eq!(labels.len(), unique.len(), "category labels must be unique");
504    }
505
506    // --- Serde serialization ---
507
508    #[test]
509    fn category_serializes_as_snake_case() {
510        let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
511        assert_eq!(json, r#""urgent_churn_complexity""#);
512
513        let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
514        assert_eq!(json, r#""break_circular_dependency""#);
515    }
516
517    #[test]
518    fn exceeded_threshold_serializes_as_snake_case() {
519        let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
520        assert_eq!(json, r#""both""#);
521
522        let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
523        assert_eq!(json, r#""cyclomatic""#);
524    }
525
526    #[test]
527    fn health_report_skips_empty_collections() {
528        let report = HealthReport {
529            findings: vec![],
530            summary: HealthSummary {
531                files_analyzed: 0,
532                functions_analyzed: 0,
533                functions_above_threshold: 0,
534                max_cyclomatic_threshold: 20,
535                max_cognitive_threshold: 15,
536                files_scored: None,
537                average_maintainability: None,
538            },
539            vital_signs: None,
540            file_scores: vec![],
541            hotspots: vec![],
542            hotspot_summary: None,
543            targets: vec![],
544            target_thresholds: None,
545        };
546        let json = serde_json::to_string(&report).unwrap();
547        // Empty vecs should be omitted due to skip_serializing_if
548        assert!(!json.contains("file_scores"));
549        assert!(!json.contains("hotspots"));
550        assert!(!json.contains("hotspot_summary"));
551        assert!(!json.contains("targets"));
552        assert!(!json.contains("vital_signs"));
553    }
554
555    #[test]
556    fn vital_signs_serialization_roundtrip() {
557        let vs = VitalSigns {
558            dead_file_pct: Some(3.2),
559            dead_export_pct: Some(8.1),
560            avg_cyclomatic: 4.7,
561            p90_cyclomatic: 12,
562            duplication_pct: None,
563            hotspot_count: Some(5),
564            maintainability_avg: Some(72.4),
565            unused_dep_count: Some(4),
566            circular_dep_count: Some(2),
567        };
568        let json = serde_json::to_string(&vs).unwrap();
569        let deserialized: VitalSigns = serde_json::from_str(&json).unwrap();
570        assert_eq!(deserialized.avg_cyclomatic, 4.7);
571        assert_eq!(deserialized.p90_cyclomatic, 12);
572        assert_eq!(deserialized.hotspot_count, Some(5));
573        // duplication_pct should be absent in JSON and None after deser
574        assert!(!json.contains("duplication_pct"));
575        assert!(deserialized.duplication_pct.is_none());
576    }
577
578    #[test]
579    fn vital_signs_snapshot_roundtrip() {
580        let snapshot = VitalSignsSnapshot {
581            snapshot_schema_version: SNAPSHOT_SCHEMA_VERSION,
582            version: "1.8.1".into(),
583            timestamp: "2026-03-25T14:30:00Z".into(),
584            git_sha: Some("abc1234".into()),
585            git_branch: Some("main".into()),
586            shallow_clone: false,
587            vital_signs: VitalSigns {
588                dead_file_pct: Some(3.2),
589                dead_export_pct: Some(8.1),
590                avg_cyclomatic: 4.7,
591                p90_cyclomatic: 12,
592                duplication_pct: None,
593                hotspot_count: None,
594                maintainability_avg: Some(72.4),
595                unused_dep_count: Some(4),
596                circular_dep_count: Some(2),
597            },
598            counts: VitalSignsCounts {
599                total_files: 1200,
600                total_exports: 5400,
601                dead_files: 38,
602                dead_exports: 437,
603                duplicated_lines: None,
604                total_lines: None,
605                files_scored: Some(1150),
606                total_deps: 42,
607            },
608        };
609        let json = serde_json::to_string_pretty(&snapshot).unwrap();
610        let rt: VitalSignsSnapshot = serde_json::from_str(&json).unwrap();
611        assert_eq!(rt.snapshot_schema_version, SNAPSHOT_SCHEMA_VERSION);
612        assert_eq!(rt.git_sha.as_deref(), Some("abc1234"));
613        assert_eq!(rt.counts.total_files, 1200);
614        assert_eq!(rt.counts.dead_exports, 437);
615    }
616
617    #[test]
618    fn refactoring_target_skips_empty_factors() {
619        let target = RefactoringTarget {
620            path: std::path::PathBuf::from("/src/foo.ts"),
621            priority: 75.0,
622            efficiency: 75.0,
623            recommendation: "Test recommendation".into(),
624            category: RecommendationCategory::RemoveDeadCode,
625            effort: EffortEstimate::Low,
626            confidence: Confidence::High,
627            factors: vec![],
628            evidence: None,
629        };
630        let json = serde_json::to_string(&target).unwrap();
631        assert!(!json.contains("factors"));
632        assert!(!json.contains("evidence"));
633    }
634
635    #[test]
636    fn effort_numeric_values() {
637        assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
638        assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
639        assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
640    }
641
642    #[test]
643    fn confidence_labels_are_non_empty() {
644        let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
645        for level in &levels {
646            assert!(!level.label().is_empty(), "{level:?} should have a label");
647        }
648    }
649
650    #[test]
651    fn confidence_serializes_as_snake_case() {
652        let json = serde_json::to_string(&Confidence::High).unwrap();
653        assert_eq!(json, r#""high""#);
654        let json = serde_json::to_string(&Confidence::Medium).unwrap();
655        assert_eq!(json, r#""medium""#);
656        let json = serde_json::to_string(&Confidence::Low).unwrap();
657        assert_eq!(json, r#""low""#);
658    }
659
660    #[test]
661    fn contributing_factor_serializes_correctly() {
662        let factor = ContributingFactor {
663            metric: "fan_in",
664            value: 15.0,
665            threshold: 10.0,
666            detail: "15 files depend on this".into(),
667        };
668        let json = serde_json::to_string(&factor).unwrap();
669        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
670        assert_eq!(parsed["metric"], "fan_in");
671        assert_eq!(parsed["value"], 15.0);
672        assert_eq!(parsed["threshold"], 10.0);
673    }
674}