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