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