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