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}