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    /// Per-file health scores (only populated with `--file-scores` or `--hotspots`).
15    #[serde(skip_serializing_if = "Vec::is_empty")]
16    pub file_scores: Vec<FileHealthScore>,
17    /// Hotspot entries (only populated with `--hotspots`).
18    #[serde(skip_serializing_if = "Vec::is_empty")]
19    pub hotspots: Vec<HotspotEntry>,
20    /// Hotspot analysis summary (only set with `--hotspots`).
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub hotspot_summary: Option<HotspotSummary>,
23    /// Ranked refactoring recommendations (only populated with `--targets`).
24    #[serde(skip_serializing_if = "Vec::is_empty")]
25    pub targets: Vec<RefactoringTarget>,
26}
27
28/// A single function that exceeds a complexity threshold.
29#[derive(Debug, serde::Serialize)]
30pub struct HealthFinding {
31    /// Absolute file path.
32    pub path: std::path::PathBuf,
33    /// Function name.
34    pub name: String,
35    /// 1-based line number.
36    pub line: u32,
37    /// 0-based column.
38    pub col: u32,
39    /// Cyclomatic complexity.
40    pub cyclomatic: u16,
41    /// Cognitive complexity.
42    pub cognitive: u16,
43    /// Number of lines in the function.
44    pub line_count: u32,
45    /// Which threshold was exceeded.
46    pub exceeded: ExceededThreshold,
47}
48
49/// Which complexity threshold was exceeded.
50#[derive(Debug, serde::Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ExceededThreshold {
53    /// Only cyclomatic exceeded.
54    Cyclomatic,
55    /// Only cognitive exceeded.
56    Cognitive,
57    /// Both thresholds exceeded.
58    Both,
59}
60
61/// Summary statistics for the health report.
62#[derive(Debug, serde::Serialize)]
63pub struct HealthSummary {
64    /// Number of files analyzed.
65    pub files_analyzed: usize,
66    /// Total number of functions found.
67    pub functions_analyzed: usize,
68    /// Number of functions above threshold.
69    pub functions_above_threshold: usize,
70    /// Configured cyclomatic threshold.
71    pub max_cyclomatic_threshold: u16,
72    /// Configured cognitive threshold.
73    pub max_cognitive_threshold: u16,
74    /// Number of files scored (only set with `--file-scores`).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub files_scored: Option<usize>,
77    /// Average maintainability index across all scored files (only set with `--file-scores`).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub average_maintainability: Option<f64>,
80}
81
82/// Per-file health score combining complexity, coupling, and dead code metrics.
83///
84/// Files with zero functions (barrel files, re-export files) are excluded by default.
85///
86/// ## Maintainability Index Formula
87///
88/// ```text
89/// fan_out_penalty = min(ln(fan_out + 1) × 4, 15)
90/// maintainability = 100
91///     - (complexity_density × 30)
92///     - (dead_code_ratio × 20)
93///     - fan_out_penalty
94/// ```
95///
96/// Clamped to \[0, 100\]. Higher is better.
97///
98/// - **complexity_density**: total cyclomatic complexity / lines of code
99/// - **dead_code_ratio**: fraction of value exports (excluding type-only exports) with zero references (0.0–1.0)
100/// - **fan_out_penalty**: logarithmic scaling with cap at 15 points; reflects diminishing marginal risk of additional imports
101#[derive(Debug, Clone, serde::Serialize)]
102pub struct FileHealthScore {
103    /// File path (absolute; stripped to relative in output).
104    pub path: std::path::PathBuf,
105    /// Number of files that import this file.
106    pub fan_in: usize,
107    /// Number of files this file imports.
108    pub fan_out: usize,
109    /// Fraction of value exports with zero references (0.0–1.0). Files with no value exports get 0.0.
110    /// Type-only exports (interfaces, type aliases) are excluded from both numerator and denominator
111    /// to avoid inflating the ratio for well-typed codebases that export props types alongside components.
112    pub dead_code_ratio: f64,
113    /// Total cyclomatic complexity / lines of code.
114    pub complexity_density: f64,
115    /// Weighted composite score (0–100, higher is better).
116    pub maintainability_index: f64,
117    /// Sum of cyclomatic complexity across all functions.
118    pub total_cyclomatic: u32,
119    /// Sum of cognitive complexity across all functions.
120    pub total_cognitive: u32,
121    /// Number of functions in this file.
122    pub function_count: usize,
123    /// Total lines of code (from line_offsets).
124    pub lines: u32,
125}
126
127/// A hotspot: a file that is both complex and frequently changing.
128///
129/// ## Score Formula
130///
131/// ```text
132/// normalized_churn = weighted_commits / max_weighted_commits   (0..1)
133/// normalized_complexity = complexity_density / max_density      (0..1)
134/// score = normalized_churn × normalized_complexity × 100       (0..100)
135/// ```
136///
137/// Score uses within-project max normalization. Higher score = higher risk.
138/// Fan-in is shown separately as "blast radius" — not baked into the score.
139#[derive(Debug, Clone, serde::Serialize)]
140pub struct HotspotEntry {
141    /// File path (absolute; stripped to relative in output).
142    pub path: std::path::PathBuf,
143    /// Hotspot score (0–100). Higher means more risk.
144    pub score: f64,
145    /// Number of commits in the analysis window.
146    pub commits: u32,
147    /// Recency-weighted commit count (exponential decay, half-life 90 days).
148    pub weighted_commits: f64,
149    /// Total lines added across all commits.
150    pub lines_added: u32,
151    /// Total lines deleted across all commits.
152    pub lines_deleted: u32,
153    /// Cyclomatic complexity / lines of code.
154    pub complexity_density: f64,
155    /// Number of files that import this file (blast radius).
156    pub fan_in: usize,
157    /// Churn trend: accelerating, stable, or cooling.
158    pub trend: fallow_core::churn::ChurnTrend,
159}
160
161/// Summary statistics for hotspot analysis.
162#[derive(Debug, serde::Serialize)]
163pub struct HotspotSummary {
164    /// Analysis window display string (e.g., "6 months").
165    pub since: String,
166    /// Minimum commits threshold.
167    pub min_commits: u32,
168    /// Number of files with churn data meeting the threshold.
169    pub files_analyzed: usize,
170    /// Number of files excluded (below min_commits).
171    pub files_excluded: usize,
172    /// Whether the repository is a shallow clone.
173    pub shallow_clone: bool,
174}
175
176/// Category of refactoring recommendation.
177#[derive(Debug, Clone, serde::Serialize)]
178#[serde(rename_all = "snake_case")]
179pub enum RecommendationCategory {
180    /// Actively-changing file with growing complexity — highest urgency.
181    UrgentChurnComplexity,
182    /// File participates in an import cycle with significant blast radius.
183    BreakCircularDependency,
184    /// High fan-in + high complexity — changes here ripple widely.
185    SplitHighImpact,
186    /// Majority of exports are unused — reduce surface area.
187    RemoveDeadCode,
188    /// Contains functions with very high cognitive complexity.
189    ExtractComplexFunctions,
190    /// Excessive imports reduce testability and increase coupling.
191    ExtractDependencies,
192}
193
194impl RecommendationCategory {
195    /// Human-readable label for terminal and compact output.
196    pub fn label(&self) -> &'static str {
197        match self {
198            Self::UrgentChurnComplexity => "churn+complexity",
199            Self::BreakCircularDependency => "circular dep",
200            Self::SplitHighImpact => "high impact",
201            Self::RemoveDeadCode => "dead code",
202            Self::ExtractComplexFunctions => "complexity",
203            Self::ExtractDependencies => "coupling",
204        }
205    }
206}
207
208/// A contributing factor that triggered or strengthened a recommendation.
209#[derive(Debug, Clone, serde::Serialize)]
210pub struct ContributingFactor {
211    /// Metric name (matches JSON field names: `"fan_in"`, `"dead_code_ratio"`, etc.).
212    pub metric: &'static str,
213    /// Raw metric value for programmatic use.
214    pub value: f64,
215    /// Threshold that was exceeded.
216    pub threshold: f64,
217    /// Human-readable explanation.
218    pub detail: String,
219}
220
221/// A ranked refactoring recommendation for a file.
222///
223/// ## Priority Formula
224///
225/// ```text
226/// priority = min(complexity_density, 1) × 30
227///          + hotspot_boost × 25            (hotspot_score / 100 if in hotspots, else 0)
228///          + dead_code_ratio × 20
229///          + fan_in_norm × 15              (min(fan_in / 20, 1.0))
230///          + fan_out_norm × 10             (min(fan_out / 30, 1.0))
231/// ```
232///
233/// All inputs clamped to \[0, 1\] so each weight is a true percentage share.
234/// Clamped to \[0, 100\]. Higher is more urgent. Does not use the maintainability
235/// index to avoid double-counting (MI already incorporates density and dead code).
236/// Effort estimate for a refactoring target.
237#[derive(Debug, Clone, serde::Serialize)]
238#[serde(rename_all = "snake_case")]
239pub enum EffortEstimate {
240    /// Small file, few functions, low fan-in — quick to address.
241    Low,
242    /// Moderate size or coupling — needs planning.
243    Medium,
244    /// Large file, many functions, or high fan-in — significant effort.
245    High,
246}
247
248impl EffortEstimate {
249    /// Human-readable label for terminal output.
250    pub fn label(&self) -> &'static str {
251        match self {
252            Self::Low => "low",
253            Self::Medium => "medium",
254            Self::High => "high",
255        }
256    }
257}
258
259/// Evidence linking a target back to specific analysis data.
260///
261/// Provides enough detail for an AI agent to act on a recommendation
262/// without a second tool call.
263#[derive(Debug, Clone, serde::Serialize)]
264pub struct TargetEvidence {
265    /// Names of unused exports (populated for `RemoveDeadCode` targets).
266    #[serde(skip_serializing_if = "Vec::is_empty")]
267    pub unused_exports: Vec<String>,
268    /// Complex functions with line numbers and cognitive scores (populated for `ExtractComplexFunctions`).
269    #[serde(skip_serializing_if = "Vec::is_empty")]
270    pub complex_functions: Vec<EvidenceFunction>,
271    /// Files forming the import cycle (populated for `BreakCircularDependency` targets).
272    #[serde(skip_serializing_if = "Vec::is_empty")]
273    pub cycle_path: Vec<String>,
274}
275
276/// A function referenced in target evidence.
277#[derive(Debug, Clone, serde::Serialize)]
278pub struct EvidenceFunction {
279    /// Function name.
280    pub name: String,
281    /// 1-based line number.
282    pub line: u32,
283    /// Cognitive complexity score.
284    pub cognitive: u16,
285}
286
287#[derive(Debug, Clone, serde::Serialize)]
288pub struct RefactoringTarget {
289    /// Absolute file path (stripped to relative in output).
290    pub path: std::path::PathBuf,
291    /// Priority score (0–100, higher = more urgent).
292    pub priority: f64,
293    /// One-line actionable recommendation.
294    pub recommendation: String,
295    /// Recommendation category for tooling/filtering.
296    pub category: RecommendationCategory,
297    /// Estimated effort to address this target.
298    pub effort: EffortEstimate,
299    /// Which metric values contributed to this recommendation.
300    #[serde(skip_serializing_if = "Vec::is_empty")]
301    pub factors: Vec<ContributingFactor>,
302    /// Structured evidence linking to specific analysis data.
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub evidence: Option<TargetEvidence>,
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    // --- RecommendationCategory ---
312
313    #[test]
314    fn category_labels_are_non_empty() {
315        let categories = [
316            RecommendationCategory::UrgentChurnComplexity,
317            RecommendationCategory::BreakCircularDependency,
318            RecommendationCategory::SplitHighImpact,
319            RecommendationCategory::RemoveDeadCode,
320            RecommendationCategory::ExtractComplexFunctions,
321            RecommendationCategory::ExtractDependencies,
322        ];
323        for cat in &categories {
324            assert!(!cat.label().is_empty(), "{cat:?} should have a label");
325        }
326    }
327
328    #[test]
329    fn category_labels_are_unique() {
330        let categories = [
331            RecommendationCategory::UrgentChurnComplexity,
332            RecommendationCategory::BreakCircularDependency,
333            RecommendationCategory::SplitHighImpact,
334            RecommendationCategory::RemoveDeadCode,
335            RecommendationCategory::ExtractComplexFunctions,
336            RecommendationCategory::ExtractDependencies,
337        ];
338        let labels: Vec<&str> = categories.iter().map(|c| c.label()).collect();
339        let unique: std::collections::HashSet<&&str> = labels.iter().collect();
340        assert_eq!(labels.len(), unique.len(), "category labels must be unique");
341    }
342
343    // --- Serde serialization ---
344
345    #[test]
346    fn category_serializes_as_snake_case() {
347        let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
348        assert_eq!(json, r#""urgent_churn_complexity""#);
349
350        let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
351        assert_eq!(json, r#""break_circular_dependency""#);
352    }
353
354    #[test]
355    fn exceeded_threshold_serializes_as_snake_case() {
356        let json = serde_json::to_string(&ExceededThreshold::Both).unwrap();
357        assert_eq!(json, r#""both""#);
358
359        let json = serde_json::to_string(&ExceededThreshold::Cyclomatic).unwrap();
360        assert_eq!(json, r#""cyclomatic""#);
361    }
362
363    #[test]
364    fn health_report_skips_empty_collections() {
365        let report = HealthReport {
366            findings: vec![],
367            summary: HealthSummary {
368                files_analyzed: 0,
369                functions_analyzed: 0,
370                functions_above_threshold: 0,
371                max_cyclomatic_threshold: 20,
372                max_cognitive_threshold: 15,
373                files_scored: None,
374                average_maintainability: None,
375            },
376            file_scores: vec![],
377            hotspots: vec![],
378            hotspot_summary: None,
379            targets: vec![],
380        };
381        let json = serde_json::to_string(&report).unwrap();
382        // Empty vecs should be omitted due to skip_serializing_if
383        assert!(!json.contains("file_scores"));
384        assert!(!json.contains("hotspots"));
385        assert!(!json.contains("hotspot_summary"));
386        assert!(!json.contains("targets"));
387    }
388
389    #[test]
390    fn refactoring_target_skips_empty_factors() {
391        let target = RefactoringTarget {
392            path: std::path::PathBuf::from("/src/foo.ts"),
393            priority: 75.0,
394            recommendation: "Test recommendation".into(),
395            category: RecommendationCategory::RemoveDeadCode,
396            effort: EffortEstimate::Low,
397            factors: vec![],
398            evidence: None,
399        };
400        let json = serde_json::to_string(&target).unwrap();
401        assert!(!json.contains("factors"));
402        assert!(!json.contains("evidence"));
403    }
404
405    #[test]
406    fn contributing_factor_serializes_correctly() {
407        let factor = ContributingFactor {
408            metric: "fan_in",
409            value: 15.0,
410            threshold: 10.0,
411            detail: "15 files depend on this".into(),
412        };
413        let json = serde_json::to_string(&factor).unwrap();
414        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
415        assert_eq!(parsed["metric"], "fan_in");
416        assert_eq!(parsed["value"], 15.0);
417        assert_eq!(parsed["threshold"], 10.0);
418    }
419}