Skip to main content

fallow_cli/health_types/
targets.rs

1//! Refactoring target types, recommendations, effort estimates, and evidence.
2
3/// Adaptive thresholds used for refactoring target scoring.
4///
5/// Derived from the project's metric distribution (percentile-based with floors).
6/// Exposed in JSON output so consumers can interpret scores in context.
7#[derive(Debug, Clone, serde::Serialize)]
8#[allow(
9    clippy::struct_field_names,
10    reason = "triggered in bin but not lib — #[expect] would be unfulfilled in lib"
11)]
12pub struct TargetThresholds {
13    /// Fan-in saturation point for priority formula (p95, floor 5).
14    pub fan_in_p95: f64,
15    /// Fan-in moderate threshold for contributing factors (p75, floor 3).
16    pub fan_in_p75: f64,
17    /// Fan-out saturation point for priority formula (p95, floor 8).
18    pub fan_out_p95: f64,
19    /// Fan-out high threshold for rules and contributing factors (p90, floor 5).
20    pub fan_out_p90: usize,
21}
22
23/// Category of refactoring recommendation.
24#[derive(Debug, Clone, serde::Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum RecommendationCategory {
27    /// Actively-changing file with growing complexity — highest urgency.
28    UrgentChurnComplexity,
29    /// File participates in an import cycle with significant blast radius.
30    BreakCircularDependency,
31    /// High fan-in + high complexity — changes here ripple widely.
32    SplitHighImpact,
33    /// Majority of exports are unused — reduce surface area.
34    RemoveDeadCode,
35    /// Contains functions with very high cognitive complexity.
36    ExtractComplexFunctions,
37    /// Excessive imports reduce testability and increase coupling.
38    ExtractDependencies,
39}
40
41impl RecommendationCategory {
42    /// Human-readable label for terminal output.
43    #[must_use]
44    pub const fn label(&self) -> &'static str {
45        match self {
46            Self::UrgentChurnComplexity => "churn+complexity",
47            Self::BreakCircularDependency => "circular dep",
48            Self::SplitHighImpact => "high impact",
49            Self::RemoveDeadCode => "dead code",
50            Self::ExtractComplexFunctions => "complexity",
51            Self::ExtractDependencies => "coupling",
52        }
53    }
54
55    /// Machine-parseable label for compact output (no spaces).
56    #[must_use]
57    pub const fn compact_label(&self) -> &'static str {
58        match self {
59            Self::UrgentChurnComplexity => "churn_complexity",
60            Self::BreakCircularDependency => "circular_dep",
61            Self::SplitHighImpact => "high_impact",
62            Self::RemoveDeadCode => "dead_code",
63            Self::ExtractComplexFunctions => "complexity",
64            Self::ExtractDependencies => "coupling",
65        }
66    }
67}
68
69/// A contributing factor that triggered or strengthened a recommendation.
70#[derive(Debug, Clone, serde::Serialize)]
71pub struct ContributingFactor {
72    /// Metric name (matches JSON field names: `"fan_in"`, `"dead_code_ratio"`, etc.).
73    pub metric: &'static str,
74    /// Raw metric value for programmatic use.
75    pub value: f64,
76    /// Threshold that was exceeded.
77    pub threshold: f64,
78    /// Human-readable explanation.
79    pub detail: String,
80}
81
82/// A ranked refactoring recommendation for a file.
83///
84/// ## Priority Formula
85///
86/// ```text
87/// priority = min(density, 1) × 30 + hotspot_boost × 25 + dead_code × 20 + fan_in_norm × 15 + fan_out_norm × 10
88/// ```
89///
90/// Fan-in and fan-out normalization uses adaptive percentile-based thresholds
91/// (p95 of the project distribution, with floors) instead of fixed constants.
92///
93/// ## Efficiency (default sort)
94///
95/// ```text
96/// efficiency = priority / effort_numeric   (Low=1, Medium=2, High=3)
97/// ```
98///
99/// Surfaces quick wins: high-priority, low-effort targets rank first.
100/// Effort estimate for a refactoring target.
101#[derive(Debug, Clone, serde::Serialize)]
102#[serde(rename_all = "snake_case")]
103pub enum EffortEstimate {
104    /// Small file, few functions, low fan-in — quick to address.
105    Low,
106    /// Moderate size or coupling — needs planning.
107    Medium,
108    /// Large file, many functions, or high fan-in — significant effort.
109    High,
110}
111
112impl EffortEstimate {
113    /// Human-readable label for terminal output.
114    #[must_use]
115    pub const fn label(&self) -> &'static str {
116        match self {
117            Self::Low => "low",
118            Self::Medium => "medium",
119            Self::High => "high",
120        }
121    }
122
123    /// Numeric value for arithmetic (efficiency = priority / effort).
124    #[must_use]
125    pub const fn numeric(&self) -> f64 {
126        match self {
127            Self::Low => 1.0,
128            Self::Medium => 2.0,
129            Self::High => 3.0,
130        }
131    }
132}
133
134/// Confidence level for a refactoring recommendation.
135///
136/// Based on the data source reliability:
137/// - **High**: deterministic graph/AST analysis (dead code, circular deps, complexity)
138/// - **Medium**: heuristic thresholds (fan-in/fan-out coupling)
139/// - **Low**: depends on git history quality (churn-based recommendations)
140#[derive(Debug, Clone, serde::Serialize)]
141#[serde(rename_all = "snake_case")]
142pub enum Confidence {
143    /// Recommendation based on deterministic analysis (graph, AST).
144    High,
145    /// Recommendation based on heuristic thresholds.
146    Medium,
147    /// Recommendation depends on external data quality (git history).
148    Low,
149}
150
151impl Confidence {
152    /// Human-readable label for terminal output.
153    #[must_use]
154    pub const fn label(&self) -> &'static str {
155        match self {
156            Self::High => "high",
157            Self::Medium => "medium",
158            Self::Low => "low",
159        }
160    }
161}
162
163/// Evidence linking a target back to specific analysis data.
164///
165/// Provides enough detail for an AI agent to act on a recommendation
166/// without a second tool call.
167#[derive(Debug, Clone, serde::Serialize)]
168pub struct TargetEvidence {
169    /// Names of unused exports (populated for `RemoveDeadCode` targets).
170    #[serde(skip_serializing_if = "Vec::is_empty")]
171    pub unused_exports: Vec<String>,
172    /// Complex functions with line numbers and cognitive scores (populated for `ExtractComplexFunctions`).
173    #[serde(skip_serializing_if = "Vec::is_empty")]
174    pub complex_functions: Vec<EvidenceFunction>,
175    /// Files forming the import cycle (populated for `BreakCircularDependency` targets).
176    #[serde(skip_serializing_if = "Vec::is_empty")]
177    pub cycle_path: Vec<String>,
178}
179
180/// A function referenced in target evidence.
181#[derive(Debug, Clone, serde::Serialize)]
182pub struct EvidenceFunction {
183    /// Function name.
184    pub name: String,
185    /// 1-based line number.
186    pub line: u32,
187    /// Cognitive complexity score.
188    pub cognitive: u16,
189}
190
191#[derive(Debug, Clone, serde::Serialize)]
192pub struct RefactoringTarget {
193    /// Absolute file path (stripped to relative in output).
194    pub path: std::path::PathBuf,
195    /// Priority score (0–100, higher = more urgent).
196    pub priority: f64,
197    /// Efficiency score (priority / effort). Higher = better quick-win value.
198    /// Surfaces low-effort, high-priority targets first.
199    pub efficiency: f64,
200    /// One-line actionable recommendation.
201    pub recommendation: String,
202    /// Recommendation category for tooling/filtering.
203    pub category: RecommendationCategory,
204    /// Estimated effort to address this target.
205    pub effort: EffortEstimate,
206    /// Confidence in this recommendation based on data source reliability.
207    pub confidence: Confidence,
208    /// Which metric values contributed to this recommendation.
209    #[serde(skip_serializing_if = "Vec::is_empty")]
210    pub factors: Vec<ContributingFactor>,
211    /// Structured evidence linking to specific analysis data.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub evidence: Option<TargetEvidence>,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    // --- RecommendationCategory ---
221
222    #[test]
223    fn category_labels_are_non_empty() {
224        let categories = [
225            RecommendationCategory::UrgentChurnComplexity,
226            RecommendationCategory::BreakCircularDependency,
227            RecommendationCategory::SplitHighImpact,
228            RecommendationCategory::RemoveDeadCode,
229            RecommendationCategory::ExtractComplexFunctions,
230            RecommendationCategory::ExtractDependencies,
231        ];
232        for cat in &categories {
233            assert!(!cat.label().is_empty(), "{cat:?} should have a label");
234        }
235    }
236
237    #[test]
238    fn category_labels_are_unique() {
239        let categories = [
240            RecommendationCategory::UrgentChurnComplexity,
241            RecommendationCategory::BreakCircularDependency,
242            RecommendationCategory::SplitHighImpact,
243            RecommendationCategory::RemoveDeadCode,
244            RecommendationCategory::ExtractComplexFunctions,
245            RecommendationCategory::ExtractDependencies,
246        ];
247        let labels: Vec<&str> = categories
248            .iter()
249            .map(RecommendationCategory::label)
250            .collect();
251        let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
252        assert_eq!(labels.len(), unique.len(), "category labels must be unique");
253    }
254
255    // --- Serde serialization ---
256
257    #[test]
258    fn category_serializes_as_snake_case() {
259        let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
260        assert_eq!(json, r#""urgent_churn_complexity""#);
261
262        let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
263        assert_eq!(json, r#""break_circular_dependency""#);
264    }
265
266    #[test]
267    fn refactoring_target_skips_empty_factors() {
268        let target = RefactoringTarget {
269            path: std::path::PathBuf::from("/src/foo.ts"),
270            priority: 75.0,
271            efficiency: 75.0,
272            recommendation: "Test recommendation".into(),
273            category: RecommendationCategory::RemoveDeadCode,
274            effort: EffortEstimate::Low,
275            confidence: Confidence::High,
276            factors: vec![],
277            evidence: None,
278        };
279        let json = serde_json::to_string(&target).unwrap();
280        assert!(!json.contains("factors"));
281        assert!(!json.contains("evidence"));
282    }
283
284    #[test]
285    fn effort_numeric_values() {
286        assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
287        assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
288        assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
289    }
290
291    #[test]
292    fn confidence_labels_are_non_empty() {
293        let levels = [Confidence::High, Confidence::Medium, Confidence::Low];
294        for level in &levels {
295            assert!(!level.label().is_empty(), "{level:?} should have a label");
296        }
297    }
298
299    #[test]
300    fn confidence_serializes_as_snake_case() {
301        let json = serde_json::to_string(&Confidence::High).unwrap();
302        assert_eq!(json, r#""high""#);
303        let json = serde_json::to_string(&Confidence::Medium).unwrap();
304        assert_eq!(json, r#""medium""#);
305        let json = serde_json::to_string(&Confidence::Low).unwrap();
306        assert_eq!(json, r#""low""#);
307    }
308
309    #[test]
310    fn contributing_factor_serializes_correctly() {
311        let factor = ContributingFactor {
312            metric: "fan_in",
313            value: 15.0,
314            threshold: 10.0,
315            detail: "15 files depend on this".into(),
316        };
317        let json = serde_json::to_string(&factor).unwrap();
318        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
319        assert_eq!(parsed["metric"], "fan_in");
320        assert_eq!(parsed["value"], 15.0);
321        assert_eq!(parsed["threshold"], 10.0);
322    }
323
324    // --- RecommendationCategory compact_labels ---
325
326    #[test]
327    fn category_compact_labels_are_non_empty() {
328        let categories = [
329            RecommendationCategory::UrgentChurnComplexity,
330            RecommendationCategory::BreakCircularDependency,
331            RecommendationCategory::SplitHighImpact,
332            RecommendationCategory::RemoveDeadCode,
333            RecommendationCategory::ExtractComplexFunctions,
334            RecommendationCategory::ExtractDependencies,
335        ];
336        for cat in &categories {
337            assert!(
338                !cat.compact_label().is_empty(),
339                "{cat:?} should have a compact_label"
340            );
341        }
342    }
343
344    #[test]
345    fn category_compact_labels_are_unique() {
346        let categories = [
347            RecommendationCategory::UrgentChurnComplexity,
348            RecommendationCategory::BreakCircularDependency,
349            RecommendationCategory::SplitHighImpact,
350            RecommendationCategory::RemoveDeadCode,
351            RecommendationCategory::ExtractComplexFunctions,
352            RecommendationCategory::ExtractDependencies,
353        ];
354        let labels: Vec<&str> = categories
355            .iter()
356            .map(RecommendationCategory::compact_label)
357            .collect();
358        let unique: rustc_hash::FxHashSet<&&str> = labels.iter().collect();
359        assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
360    }
361
362    #[test]
363    fn category_compact_labels_have_no_spaces() {
364        let categories = [
365            RecommendationCategory::UrgentChurnComplexity,
366            RecommendationCategory::BreakCircularDependency,
367            RecommendationCategory::SplitHighImpact,
368            RecommendationCategory::RemoveDeadCode,
369            RecommendationCategory::ExtractComplexFunctions,
370            RecommendationCategory::ExtractDependencies,
371        ];
372        for cat in &categories {
373            assert!(
374                !cat.compact_label().contains(' '),
375                "compact_label for {:?} should not contain spaces: '{}'",
376                cat,
377                cat.compact_label()
378            );
379        }
380    }
381
382    // --- EffortEstimate ---
383
384    #[test]
385    fn effort_labels_are_non_empty() {
386        let efforts = [
387            EffortEstimate::Low,
388            EffortEstimate::Medium,
389            EffortEstimate::High,
390        ];
391        for effort in &efforts {
392            assert!(!effort.label().is_empty(), "{effort:?} should have a label");
393        }
394    }
395
396    #[test]
397    fn effort_serializes_as_snake_case() {
398        assert_eq!(
399            serde_json::to_string(&EffortEstimate::Low).unwrap(),
400            r#""low""#
401        );
402        assert_eq!(
403            serde_json::to_string(&EffortEstimate::Medium).unwrap(),
404            r#""medium""#
405        );
406        assert_eq!(
407            serde_json::to_string(&EffortEstimate::High).unwrap(),
408            r#""high""#
409        );
410    }
411
412    // --- TargetEvidence ---
413
414    #[test]
415    fn target_evidence_skips_empty_fields() {
416        let evidence = TargetEvidence {
417            unused_exports: vec![],
418            complex_functions: vec![],
419            cycle_path: vec![],
420        };
421        let json = serde_json::to_string(&evidence).unwrap();
422        assert!(!json.contains("unused_exports"));
423        assert!(!json.contains("complex_functions"));
424        assert!(!json.contains("cycle_path"));
425    }
426
427    #[test]
428    fn target_evidence_with_data() {
429        let evidence = TargetEvidence {
430            unused_exports: vec!["foo".to_string(), "bar".to_string()],
431            complex_functions: vec![EvidenceFunction {
432                name: "processData".into(),
433                line: 42,
434                cognitive: 30,
435            }],
436            cycle_path: vec![],
437        };
438        let json = serde_json::to_string(&evidence).unwrap();
439        assert!(json.contains("unused_exports"));
440        assert!(json.contains("complex_functions"));
441        assert!(json.contains("processData"));
442        assert!(!json.contains("cycle_path"));
443    }
444}