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, serde::Deserialize)]
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, serde::Deserialize)]
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    #[serde(deserialize_with = "crate::static_str::deserialize")]
81    pub metric: crate::static_str::StaticStr,
82    /// Raw metric value for programmatic use.
83    pub value: f64,
84    /// Threshold that was exceeded.
85    pub threshold: f64,
86    /// Human-readable explanation.
87    pub detail: String,
88}
89
90/// A ranked refactoring recommendation for a file.
91///
92/// ## Priority Formula
93///
94/// ```text
95/// priority = min(density, 1) × 30 + hotspot_boost × 25 + dead_code × 20 + fan_in_norm × 15 + fan_out_norm × 10
96/// ```
97///
98/// Fan-in and fan-out normalization uses adaptive percentile-based thresholds
99/// (p95 of the project distribution, with floors) instead of fixed constants.
100///
101/// ## Efficiency (default sort)
102///
103/// ```text
104/// efficiency = priority / effort_numeric   (Low=1, Medium=2, High=3)
105/// ```
106///
107/// Surfaces quick wins: high-priority, low-effort targets rank first.
108/// Effort estimate for a refactoring target.
109#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(rename_all = "snake_case")]
112pub enum EffortEstimate {
113    /// Small file, few functions, low fan-in: quick to address.
114    Low,
115    /// Moderate size or coupling: needs planning.
116    Medium,
117    /// Large file, many functions, or high fan-in: significant effort.
118    High,
119}
120
121impl EffortEstimate {
122    /// Human-readable label for terminal output.
123    #[must_use]
124    pub const fn label(&self) -> &'static str {
125        match self {
126            Self::Low => "low",
127            Self::Medium => "medium",
128            Self::High => "high",
129        }
130    }
131
132    /// Numeric value for arithmetic (efficiency = priority / effort).
133    #[must_use]
134    pub const fn numeric(&self) -> f64 {
135        match self {
136            Self::Low => 1.0,
137            Self::Medium => 2.0,
138            Self::High => 3.0,
139        }
140    }
141}
142
143/// Confidence level for a refactoring recommendation.
144///
145/// Based on the data source reliability:
146/// - **High**: deterministic graph/AST analysis (dead code, circular deps, complexity)
147/// - **Medium**: heuristic thresholds (fan-in/fan-out coupling)
148/// - **Low**: depends on git history quality (churn-based recommendations)
149#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
150#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
151#[serde(rename_all = "snake_case")]
152pub enum Confidence {
153    /// Recommendation based on deterministic analysis (graph, AST).
154    High,
155    /// Recommendation based on heuristic thresholds.
156    Medium,
157    /// Recommendation depends on external data quality (git history).
158    Low,
159}
160
161impl Confidence {
162    /// Human-readable label for terminal output.
163    #[must_use]
164    pub const fn label(&self) -> &'static str {
165        match self {
166            Self::High => "high",
167            Self::Medium => "medium",
168            Self::Low => "low",
169        }
170    }
171}
172
173/// Evidence linking a target back to specific analysis data.
174///
175/// Provides enough detail for an AI agent to act on a recommendation
176/// without a second tool call.
177#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
178#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
179pub struct TargetEvidence {
180    /// Names of unused exports (populated for `RemoveDeadCode` targets).
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub unused_exports: Vec<String>,
183    /// Complex functions with line numbers and cognitive scores (populated for `ExtractComplexFunctions`).
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub complex_functions: Vec<EvidenceFunction>,
186    /// Files forming the import cycle (populated for `BreakCircularDependency` targets).
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub cycle_path: Vec<String>,
189    /// Files that directly import this target, with imported and local symbols.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub direct_callers: Vec<DirectCallerEvidence>,
192    /// Other duplicate-code instances that share a clone group with this target.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub clone_siblings: Vec<CloneSiblingEvidence>,
195}
196
197/// A direct importer referenced in target evidence.
198#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
199#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
200pub struct DirectCallerEvidence {
201    /// File that directly imports the target.
202    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
203    pub path: std::path::PathBuf,
204    /// Symbols imported from the target by this file.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub symbols: Vec<DirectCallerSymbolEvidence>,
207}
208
209/// Symbol details for a direct importer.
210#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212pub struct DirectCallerSymbolEvidence {
213    /// Imported binding name.
214    pub imported: String,
215    /// Local binding name in the importing file.
216    pub local: String,
217    /// Whether the import is type-only.
218    pub type_only: bool,
219}
220
221/// A duplicate-code sibling referenced in target evidence.
222#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct CloneSiblingEvidence {
225    /// File containing the sibling clone instance.
226    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
227    pub path: std::path::PathBuf,
228    /// 1-based start line of the sibling clone.
229    pub start_line: usize,
230    /// 1-based end line of the sibling clone.
231    pub end_line: usize,
232    /// Stable duplicate-group handle, matching `dupes --trace dup:<id>`.
233    pub fingerprint: String,
234}
235
236/// A function referenced in target evidence.
237#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239pub struct EvidenceFunction {
240    /// Function name.
241    pub name: String,
242    /// 1-based line number.
243    pub line: u32,
244    /// Cognitive complexity score.
245    pub cognitive: u16,
246}
247
248/// One prioritized refactoring recommendation in the health report's
249/// `refactoring_targets` section.
250#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
251#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
252pub struct RefactoringTarget {
253    /// Absolute file path (stripped to relative in output).
254    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
255    pub path: std::path::PathBuf,
256    /// Priority score (0–100, higher = more urgent).
257    pub priority: f64,
258    /// Efficiency score (priority / effort). Higher = better quick-win value.
259    /// Surfaces low-effort, high-priority targets first.
260    pub efficiency: f64,
261    /// One-line actionable recommendation.
262    pub recommendation: String,
263    /// Recommendation category for tooling/filtering.
264    pub category: RecommendationCategory,
265    /// Estimated effort to address this target.
266    pub effort: EffortEstimate,
267    /// Confidence in this recommendation based on data source reliability.
268    pub confidence: Confidence,
269    /// Contributing factors that triggered this recommendation. Empty array
270    /// omitted from JSON.
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    #[cfg_attr(feature = "schema", schemars(default))]
273    pub factors: Vec<ContributingFactor>,
274    /// Structured evidence linking to specific analysis data.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub evidence: Option<TargetEvidence>,
277}
278
279#[cfg(test)]
280#[allow(
281    clippy::unwrap_used,
282    reason = "tests use unwrap to keep serialization assertions concise"
283)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn category_labels_are_unique() {
289        let categories = [
290            RecommendationCategory::UrgentChurnComplexity,
291            RecommendationCategory::BreakCircularDependency,
292            RecommendationCategory::SplitHighImpact,
293            RecommendationCategory::RemoveDeadCode,
294            RecommendationCategory::ExtractComplexFunctions,
295            RecommendationCategory::ExtractDependencies,
296            RecommendationCategory::AddTestCoverage,
297        ];
298        let labels: Vec<&str> = categories
299            .iter()
300            .map(RecommendationCategory::label)
301            .collect();
302        let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
303        assert_eq!(labels.len(), unique.len(), "category labels must be unique");
304    }
305
306    #[test]
307    fn category_serializes_as_snake_case() {
308        let json = serde_json::to_string(&RecommendationCategory::UrgentChurnComplexity).unwrap();
309        assert_eq!(json, r#""urgent_churn_complexity""#);
310
311        let json = serde_json::to_string(&RecommendationCategory::BreakCircularDependency).unwrap();
312        assert_eq!(json, r#""break_circular_dependency""#);
313    }
314
315    #[test]
316    fn refactoring_target_skips_empty_factors() {
317        let target = RefactoringTarget {
318            path: std::path::PathBuf::from("/src/foo.ts"),
319            priority: 75.0,
320            efficiency: 75.0,
321            recommendation: "Test recommendation".into(),
322            category: RecommendationCategory::RemoveDeadCode,
323            effort: EffortEstimate::Low,
324            confidence: Confidence::High,
325            factors: vec![],
326            evidence: None,
327        };
328        let json = serde_json::to_string(&target).unwrap();
329        assert!(!json.contains("factors"));
330        assert!(!json.contains("evidence"));
331    }
332
333    #[test]
334    fn effort_numeric_values() {
335        assert!((EffortEstimate::Low.numeric() - 1.0).abs() < f64::EPSILON);
336        assert!((EffortEstimate::Medium.numeric() - 2.0).abs() < f64::EPSILON);
337        assert!((EffortEstimate::High.numeric() - 3.0).abs() < f64::EPSILON);
338    }
339
340    #[test]
341    fn confidence_serializes_as_snake_case() {
342        let json = serde_json::to_string(&Confidence::High).unwrap();
343        assert_eq!(json, r#""high""#);
344        let json = serde_json::to_string(&Confidence::Medium).unwrap();
345        assert_eq!(json, r#""medium""#);
346        let json = serde_json::to_string(&Confidence::Low).unwrap();
347        assert_eq!(json, r#""low""#);
348    }
349
350    #[test]
351    fn contributing_factor_serializes_correctly() {
352        let factor = ContributingFactor {
353            metric: "fan_in",
354            value: 15.0,
355            threshold: 10.0,
356            detail: "15 files depend on this".into(),
357        };
358        let json = serde_json::to_string(&factor).unwrap();
359        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
360        assert_eq!(parsed["metric"], "fan_in");
361        assert_eq!(parsed["value"], 15.0);
362        assert_eq!(parsed["threshold"], 10.0);
363    }
364
365    #[test]
366    fn category_compact_labels_are_unique() {
367        let categories = [
368            RecommendationCategory::UrgentChurnComplexity,
369            RecommendationCategory::BreakCircularDependency,
370            RecommendationCategory::SplitHighImpact,
371            RecommendationCategory::RemoveDeadCode,
372            RecommendationCategory::ExtractComplexFunctions,
373            RecommendationCategory::ExtractDependencies,
374            RecommendationCategory::AddTestCoverage,
375        ];
376        let labels: Vec<&str> = categories
377            .iter()
378            .map(RecommendationCategory::compact_label)
379            .collect();
380        let unique: std::collections::BTreeSet<&&str> = labels.iter().collect();
381        assert_eq!(labels.len(), unique.len(), "compact labels must be unique");
382    }
383
384    #[test]
385    fn category_compact_labels_have_no_spaces() {
386        let categories = [
387            RecommendationCategory::UrgentChurnComplexity,
388            RecommendationCategory::BreakCircularDependency,
389            RecommendationCategory::SplitHighImpact,
390            RecommendationCategory::RemoveDeadCode,
391            RecommendationCategory::ExtractComplexFunctions,
392            RecommendationCategory::ExtractDependencies,
393            RecommendationCategory::AddTestCoverage,
394        ];
395        for cat in &categories {
396            assert!(
397                !cat.compact_label().contains(' '),
398                "compact_label for {:?} should not contain spaces: '{}'",
399                cat,
400                cat.compact_label()
401            );
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    #[test]
422    fn target_evidence_skips_empty_fields() {
423        let evidence = TargetEvidence {
424            unused_exports: vec![],
425            complex_functions: vec![],
426            cycle_path: vec![],
427            direct_callers: vec![],
428            clone_siblings: 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        assert!(!json.contains("direct_callers"));
435        assert!(!json.contains("clone_siblings"));
436    }
437
438    #[test]
439    fn target_evidence_with_data() {
440        let evidence = TargetEvidence {
441            unused_exports: vec!["foo".to_string(), "bar".to_string()],
442            complex_functions: vec![EvidenceFunction {
443                name: "processData".into(),
444                line: 42,
445                cognitive: 30,
446            }],
447            cycle_path: vec![],
448            direct_callers: vec![DirectCallerEvidence {
449                path: "src/consumer.ts".into(),
450                symbols: vec![DirectCallerSymbolEvidence {
451                    imported: "processData".into(),
452                    local: "processData".into(),
453                    type_only: false,
454                }],
455            }],
456            clone_siblings: vec![CloneSiblingEvidence {
457                path: "src/peer.ts".into(),
458                start_line: 12,
459                end_line: 20,
460                fingerprint: "dup:12345678".into(),
461            }],
462        };
463        let json = serde_json::to_string(&evidence).unwrap();
464        assert!(json.contains("unused_exports"));
465        assert!(json.contains("complex_functions"));
466        assert!(json.contains("processData"));
467        assert!(json.contains("direct_callers"));
468        assert!(json.contains("clone_siblings"));
469        assert!(!json.contains("cycle_path"));
470    }
471}