Skip to main content

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