Skip to main content

fallow_cli/health_types/
finding.rs

1//! Health-finding wrappers, action context, and typed action builders.
2//!
3//! This module keeps the wire envelopes typed while preserving the existing
4//! flattened JSON shape.
5
6use fallow_types::output_health::{
7    HealthFindingAction, HealthFindingActionType, HotspotAction, HotspotActionHeuristic,
8    HotspotActionType, RefactoringTargetAction, RefactoringTargetActionType,
9};
10use std::ops::Deref;
11use std::path::Path;
12
13use crate::health_types::scores::{
14    ComplexityViolation, CoverageTier, HotspotEntry, OwnershipState,
15};
16use crate::health_types::targets::{RecommendationCategory, RefactoringTarget};
17
18/// Options controlling how the action builder populates `actions`.
19#[derive(Debug, Clone, Copy, Default)]
20pub struct HealthActionOptions {
21    /// Skip `suppress-line` action entries.
22    pub omit_suppress_line: bool,
23    /// Reason surfaced in `actions_meta` when `omit_suppress_line` is true.
24    pub omit_reason: Option<&'static str>,
25}
26
27/// Construction-time context for [`HealthFinding::with_actions`].
28#[derive(Debug, Clone, Copy)]
29pub struct HealthActionContext {
30    /// Action-emission options.
31    pub opts: HealthActionOptions,
32    /// Cyclomatic-complexity ceiling.
33    pub max_cyclomatic_threshold: u16,
34    /// Cognitive-complexity ceiling.
35    pub max_cognitive_threshold: u16,
36    /// CRAP ceiling.
37    pub max_crap_threshold: f64,
38    /// Band below `max_cyclomatic_threshold` where a CRAP-only finding also
39    /// gets a secondary `refactor-function` action.
40    pub crap_refactor_band: u16,
41}
42
43/// Wire envelope for a single complexity finding.
44#[derive(Debug, Clone, serde::Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct HealthFinding {
47    /// Inner complexity-violation payload.
48    #[serde(flatten)]
49    pub violation: ComplexityViolation,
50    /// Machine-actionable fix and suppress hints.
51    pub actions: Vec<HealthFindingAction>,
52    /// Audit-mode flag indicating whether the finding is new versus the base
53    /// snapshot.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub introduced: Option<bool>,
56}
57
58impl Deref for HealthFinding {
59    type Target = ComplexityViolation;
60
61    fn deref(&self) -> &Self::Target {
62        &self.violation
63    }
64}
65
66impl From<ComplexityViolation> for HealthFinding {
67    /// Wrap a violation with empty actions and no `introduced` flag.
68    fn from(violation: ComplexityViolation) -> Self {
69        Self {
70            violation,
71            actions: Vec::new(),
72            introduced: None,
73        }
74    }
75}
76
77impl HealthFinding {
78    /// Construct a wrapper around a pre-computed action list.
79    #[must_use]
80    #[allow(
81        dead_code,
82        reason = "intentional public constructor for audit / test paths that supply their own actions; with_actions is the production constructor"
83    )]
84    pub fn new(
85        violation: ComplexityViolation,
86        actions: Vec<HealthFindingAction>,
87        introduced: Option<bool>,
88    ) -> Self {
89        Self {
90            violation,
91            actions,
92            introduced,
93        }
94    }
95
96    /// Construct a wrapper with `actions` computed from the finding and
97    /// report-wide context.
98    #[must_use]
99    pub fn with_actions(violation: ComplexityViolation, ctx: &HealthActionContext) -> Self {
100        let actions = build_health_finding_actions(&violation, ctx);
101        Self {
102            violation,
103            actions,
104            introduced: None,
105        }
106    }
107}
108
109/// Compute the typed `actions` list for a complexity finding.
110#[must_use]
111pub fn build_health_finding_actions(
112    violation: &ComplexityViolation,
113    ctx: &HealthActionContext,
114) -> Vec<HealthFindingAction> {
115    let name = violation.name.as_str();
116    let exceeded = violation.exceeded;
117    let includes_crap = exceeded.includes_crap();
118    let crap_only = matches!(exceeded, crate::health_types::ExceededThreshold::Crap);
119    let cyclomatic = violation.cyclomatic;
120    let cognitive = violation.cognitive;
121    let max_cyclomatic_threshold = violation
122        .effective_thresholds
123        .map_or(ctx.max_cyclomatic_threshold, |thresholds| {
124            thresholds.max_cyclomatic
125        });
126    let max_cognitive_threshold = violation
127        .effective_thresholds
128        .map_or(ctx.max_cognitive_threshold, |thresholds| {
129            thresholds.max_cognitive
130        });
131    let max_crap_threshold = violation
132        .effective_thresholds
133        .map_or(ctx.max_crap_threshold, |thresholds| thresholds.max_crap);
134    let full_coverage_can_clear_crap = !includes_crap || f64::from(cyclomatic) < max_crap_threshold;
135
136    let mut actions: Vec<HealthFindingAction> = Vec::new();
137
138    let inherited_from = violation.inherited_from.as_deref();
139    if includes_crap
140        && let Some(action) = build_crap_coverage_action(
141            name,
142            violation.coverage_tier,
143            full_coverage_can_clear_crap,
144            inherited_from,
145        )
146    {
147        actions.push(action);
148    }
149
150    let is_template = name == "<template>";
151    let is_component = name == "<component>";
152    if should_add_refactor_action(RefactorActionDecision {
153        crap_only,
154        full_coverage_can_clear_crap,
155        cyclomatic,
156        cognitive,
157        max_cyclomatic_threshold,
158        max_cognitive_threshold,
159        ctx,
160    }) {
161        actions.push(build_refactor_action(
162            violation,
163            name,
164            is_template,
165            is_component,
166        ));
167    }
168
169    if !ctx.opts.omit_suppress_line {
170        actions.push(build_suppress_action(violation, is_template, is_component));
171    }
172
173    actions
174}
175
176#[derive(Clone, Copy)]
177struct RefactorActionDecision<'a> {
178    crap_only: bool,
179    full_coverage_can_clear_crap: bool,
180    cyclomatic: u16,
181    cognitive: u16,
182    max_cyclomatic_threshold: u16,
183    max_cognitive_threshold: u16,
184    ctx: &'a HealthActionContext,
185}
186
187fn should_add_refactor_action(input: RefactorActionDecision<'_>) -> bool {
188    let crap_only_needs_complexity_reduction =
189        input.crap_only && !input.full_coverage_can_clear_crap;
190    let cognitive_floor = input.max_cognitive_threshold / 2;
191    let near_cyclomatic_threshold = input.crap_only
192        && input.cyclomatic > 0
193        && input.cyclomatic
194            >= input
195                .max_cyclomatic_threshold
196                .saturating_sub(input.ctx.crap_refactor_band)
197        && input.cognitive >= cognitive_floor;
198    !input.crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold
199}
200
201fn build_refactor_action(
202    violation: &ComplexityViolation,
203    name: &str,
204    is_template: bool,
205    is_component: bool,
206) -> HealthFindingAction {
207    let (description, note): (String, &str) = if is_component {
208        component_refactor_copy(violation)
209    } else if is_template {
210        (
211            format!(
212                "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
213            ),
214            "Consider splitting complex template branches into smaller components or simpler bindings",
215        )
216    } else {
217        (
218            format!(
219                "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
220            ),
221            "Consider splitting into smaller functions with single responsibilities",
222        )
223    };
224    HealthFindingAction {
225        kind: HealthFindingActionType::RefactorFunction,
226        auto_fixable: false,
227        description,
228        note: Some(note.to_string()),
229        comment: None,
230        placement: None,
231        target_path: None,
232    }
233}
234
235fn component_refactor_copy(violation: &ComplexityViolation) -> (String, &'static str) {
236    let rollup = violation.component_rollup.as_ref();
237    let class_name = rollup.map_or("the component", |r| r.component.as_str());
238    let worst_method = rollup.map_or("the worst class method", |r| {
239        r.class_worst_function.as_str()
240    });
241    let class_cyc = rollup.map_or(0_u16, |r| r.class_cyclomatic);
242    let template_cyc = rollup.map_or(0_u16, |r| r.template_cyclomatic);
243    (
244        format!(
245            "Refactor `{class_name}` to reduce component complexity (rolled-up cyclomatic {} = {class_cyc} on `{worst_method}` + {template_cyc} on the template)",
246            violation.cyclomatic
247        ),
248        "Consider splitting the template into smaller components OR extracting helpers from the worst class method; the rollup reflects the component as one complexity unit",
249    )
250}
251
252fn build_suppress_action(
253    violation: &ComplexityViolation,
254    is_template: bool,
255    is_component: bool,
256) -> HealthFindingAction {
257    if is_template
258        && violation
259            .path
260            .extension()
261            .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
262    {
263        return suppress_file_action(
264            "Suppress with an HTML comment at the top of the template",
265            "<!-- fallow-ignore-file complexity -->",
266            "top-of-template",
267        );
268    }
269    if is_template {
270        return suppress_line_action(
271            "Suppress with an inline comment above the Angular decorator",
272            "above-angular-decorator",
273        );
274    }
275    if is_component {
276        return suppress_line_action(
277            "Suppress with an inline comment above the worst class method (the rollup is anchored at that method's line, so a comment above it hides both the function finding and the rollup)",
278            "above-component-worst-method",
279        );
280    }
281    suppress_line_action(
282        "Suppress with an inline comment above the function declaration",
283        "above-function-declaration",
284    )
285}
286
287fn suppress_file_action(description: &str, comment: &str, placement: &str) -> HealthFindingAction {
288    HealthFindingAction {
289        kind: HealthFindingActionType::SuppressFile,
290        auto_fixable: false,
291        description: description.to_string(),
292        note: None,
293        comment: Some(comment.to_string()),
294        placement: Some(placement.to_string()),
295        target_path: None,
296    }
297}
298
299fn suppress_line_action(description: &str, placement: &str) -> HealthFindingAction {
300    HealthFindingAction {
301        kind: HealthFindingActionType::SuppressLine,
302        auto_fixable: false,
303        description: description.to_string(),
304        note: None,
305        comment: Some("// fallow-ignore-next-line complexity".to_string()),
306        placement: Some(placement.to_string()),
307        target_path: None,
308    }
309}
310
311/// Build the coverage-leaning action for a CRAP-contributing finding.
312fn build_crap_coverage_action(
313    name: &str,
314    tier: Option<CoverageTier>,
315    full_coverage_can_clear_crap: bool,
316    inherited_from: Option<&Path>,
317) -> Option<HealthFindingAction> {
318    if !full_coverage_can_clear_crap {
319        return None;
320    }
321
322    if let Some(owner) = inherited_from {
323        let owner_str = owner.to_string_lossy().into_owned();
324        return Some(HealthFindingAction {
325            kind: HealthFindingActionType::IncreaseCoverage,
326            auto_fixable: false,
327            description: format!(
328                "Increase test coverage on `{owner_str}` (the CRAP score on `{name}` is inherited from this Angular component; add component tests there rather than against the template)"
329            ),
330            note: Some(
331                "CRAP = CC^2 * (1 - cov/100)^3 + CC; .html templates are exercised through their @Component class, so the test target is the .ts file referenced by `inherited_from`".to_string(),
332            ),
333            comment: None,
334            placement: None,
335            target_path: Some(owner_str),
336        });
337    }
338
339    match tier {
340        Some(CoverageTier::Partial | CoverageTier::High) => Some(HealthFindingAction {
341            kind: HealthFindingActionType::IncreaseCoverage,
342            auto_fixable: false,
343            description: format!(
344                "Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"
345            ),
346            note: Some(
347                "CRAP = CC^2 * (1 - cov/100)^3 + CC; targeted branch coverage is more efficient than scaffolding new test files when the file already has coverage".to_string(),
348            ),
349            comment: None,
350            placement: None,
351            target_path: None,
352        }),
353        _ => Some(HealthFindingAction {
354            kind: HealthFindingActionType::AddTests,
355            auto_fixable: false,
356            description: format!(
357                "Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"
358            ),
359            note: Some(
360                "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold".to_string(),
361            ),
362            comment: None,
363            placement: None,
364            target_path: None,
365        }),
366    }
367}
368
369/// Wire envelope for a single hotspot entry.
370///
371/// Flattens [`HotspotEntry`] for wire continuity and adds the typed
372/// `actions` list. The `#[serde(flatten)]` keeps each `hotspots[]` item
373/// byte-identical to the pre-wrapper shape: inner fields (`path`,
374/// `score`, `commits`, `weighted_commits`, ...) sit at the top level
375/// alongside `actions`. Optional inner fields (`ownership`,
376/// `is_test_path`) keep their original `skip_serializing_if` behaviour
377/// because serde applies the flatten before the parent serializer runs.
378///
379/// Construct via [`HotspotFinding::with_actions`] in the typical health
380/// pipeline (the typed action builder operates on the inner
381/// [`HotspotEntry`]) or via [`HotspotFinding::from`] for fixture and
382/// test code.
383#[derive(Debug, Clone, serde::Serialize)]
384#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
385pub struct HotspotFinding {
386    /// Inner hotspot payload. Flattened on the wire.
387    #[serde(flatten)]
388    pub entry: HotspotEntry,
389    /// Machine-actionable refactor and review hints. Always populated;
390    /// the list never empties because the action selector unconditionally
391    /// emits `refactor-file` plus `add-tests`. Ownership-derived variants
392    /// (`low-bus-factor`, `unowned-hotspot`, `ownership-drift`) are
393    /// appended when `--ownership` is active and the corresponding signal
394    /// fires.
395    pub actions: Vec<HotspotAction>,
396}
397
398impl Deref for HotspotFinding {
399    type Target = HotspotEntry;
400
401    fn deref(&self) -> &Self::Target {
402        &self.entry
403    }
404}
405
406impl From<HotspotEntry> for HotspotFinding {
407    /// Convenience conversion: wrap a hotspot entry with an empty
408    /// `actions` list. Used by tests and fixture builders. Production
409    /// code should call [`HotspotFinding::with_actions`] so the wire
410    /// shape carries the typed actions.
411    fn from(entry: HotspotEntry) -> Self {
412        Self {
413            entry,
414            actions: Vec::new(),
415        }
416    }
417}
418
419impl HotspotFinding {
420    /// Construct a wrapper with the `actions` list computed from the
421    /// hotspot's measured signals plus its ownership block (when
422    /// present).
423    ///
424    /// `root` is the project root used to strip the absolute
425    /// [`HotspotEntry::path`] when composing action descriptions like
426    /// `"Refactor `{path}`, ..."`.
427    /// The JSON post-pass that this wrapper retires ran AFTER
428    /// `strip_root_prefix`, so the typed builder must apply the same
429    /// stripping here for byte-identical wire output.
430    #[must_use]
431    pub fn with_actions(entry: HotspotEntry, root: &Path) -> Self {
432        let actions = build_hotspot_actions(&entry, root);
433        Self { entry, actions }
434    }
435}
436
437/// Compute the typed `actions` list for a hotspot entry.
438///
439/// The list always begins with `refactor-file` plus `add-tests`. The
440/// ownership-derived variants (`low-bus-factor`, `unowned-hotspot`,
441/// `ownership-drift`) are appended when [`HotspotEntry::ownership`] is
442/// present and the corresponding signal fires.
443fn build_hotspot_actions(entry: &HotspotEntry, root: &Path) -> Vec<HotspotAction> {
444    let relative = entry.path.strip_prefix(root).unwrap_or(&entry.path);
445    let path = relative.to_string_lossy().replace('\\', "/");
446    let mut actions = base_hotspot_actions(&path);
447    if let Some(ownership) = entry.ownership.as_ref() {
448        append_ownership_hotspot_actions(&mut actions, ownership, &path);
449    }
450    actions
451}
452
453fn base_hotspot_actions(path: &str) -> Vec<HotspotAction> {
454    vec![
455        HotspotAction {
456            kind: HotspotActionType::RefactorFile,
457            auto_fixable: false,
458            description: format!(
459                "Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"
460            ),
461            note: Some(
462                "Prioritize extracting complex functions, adding tests, or splitting the module"
463                    .to_string(),
464            ),
465            suggested_pattern: None,
466            heuristic: None,
467        },
468        HotspotAction {
469            kind: HotspotActionType::AddTests,
470            auto_fixable: false,
471            description: format!("Add test coverage for `{path}` to reduce change risk"),
472            note: Some(
473                "Frequently changed complex files benefit most from comprehensive test coverage"
474                    .to_string(),
475            ),
476            suggested_pattern: None,
477            heuristic: None,
478        },
479    ]
480}
481
482fn append_ownership_hotspot_actions(
483    actions: &mut Vec<HotspotAction>,
484    ownership: &crate::health_types::OwnershipMetrics,
485    path: &str,
486) {
487    if ownership.bus_factor == 1 {
488        let top = &ownership.top_contributor;
489        let owner = top.identifier.as_str();
490        let commits = top.commits;
491        let suggested: Vec<&str> = ownership
492            .suggested_reviewers
493            .iter()
494            .map(|r| r.identifier.as_str())
495            .collect();
496        let note = if suggested.is_empty() {
497            if commits < 5 {
498                Some(
499                    "Single recent contributor on a low-commit file. Consider a pair review for major changes."
500                        .to_string(),
501                )
502            } else {
503                None
504            }
505        } else {
506            let list = suggested
507                .iter()
508                .map(|s| format!("@{s}"))
509                .collect::<Vec<_>>()
510                .join(", ");
511            Some(format!("Candidate reviewers: {list}"))
512        };
513        actions.push(HotspotAction {
514            kind: HotspotActionType::LowBusFactor,
515            auto_fixable: false,
516            description: format!(
517                "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
518            ),
519            note,
520            suggested_pattern: None,
521            heuristic: None,
522        });
523    }
524
525    if ownership.unowned == Some(true) {
526        actions.push(HotspotAction {
527            kind: HotspotActionType::UnownedHotspot,
528            auto_fixable: false,
529            description: format!("Add a CODEOWNERS entry for `{path}`"),
530            note: Some(
531                "Frequently-changed files without declared owners create review bottlenecks"
532                    .to_string(),
533            ),
534            suggested_pattern: Some(suggest_codeowners_pattern(path)),
535            heuristic: Some(HotspotActionHeuristic::DirectoryDeepest),
536        });
537    }
538
539    if ownership.ownership_state == OwnershipState::Drifting && ownership.drift {
540        let reason = ownership
541            .drift_reason
542            .as_deref()
543            .unwrap_or("ownership has shifted from the original author");
544        actions.push(HotspotAction {
545            kind: HotspotActionType::OwnershipDrift,
546            auto_fixable: false,
547            description: format!("Update CODEOWNERS for `{path}`: {reason}"),
548            note: Some(
549                "Drift suggests the declared or original owner is no longer the right reviewer"
550                    .to_string(),
551            ),
552            suggested_pattern: None,
553            heuristic: None,
554        });
555    }
556}
557
558/// Suggest a CODEOWNERS pattern for an unowned hotspot.
559///
560/// Picks the deepest directory containing the file
561/// (e.g. `src/api/users/handlers.ts` -> `/src/api/users/`) so agents can
562/// paste a tightly-scoped default. Earlier versions used the first two
563/// directory levels but that catches too many siblings in monorepos
564/// (`/src/api/` could span 200 files across 8 sub-domains). The deepest
565/// directory keeps the suggestion reviewable while still being a directory
566/// pattern rather than a per-file rule.
567///
568/// The action emits this alongside
569/// [`HotspotActionHeuristic::DirectoryDeepest`] so consumers can branch
570/// on the strategy if it evolves.
571fn suggest_codeowners_pattern(path: &str) -> String {
572    let normalized = path.replace('\\', "/");
573    let trimmed = normalized.trim_start_matches('/');
574    let mut components: Vec<&str> = trimmed.split('/').collect();
575    components.pop(); // drop the file itself
576    if components.is_empty() {
577        return format!("/{trimmed}");
578    }
579    format!("/{}/", components.join("/"))
580}
581
582/// Wire envelope for a single refactoring target.
583///
584/// Flattens [`RefactoringTarget`] for wire continuity and adds the typed
585/// `actions` list. The `#[serde(flatten)]` keeps each `targets[]` item
586/// byte-identical to the pre-wrapper shape: inner fields (`path`,
587/// `priority`, `efficiency`, `recommendation`, `category`, ...) sit at
588/// the top level alongside `actions`. Optional inner fields (`factors`,
589/// `evidence`) keep their original `skip_serializing_if` behaviour.
590///
591/// Construct via [`RefactoringTargetFinding::with_actions`] in the
592/// typical health pipeline or via [`RefactoringTargetFinding::from`] for
593/// fixture and test code.
594#[derive(Debug, Clone, serde::Serialize)]
595#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
596pub struct RefactoringTargetFinding {
597    /// Inner refactoring target payload. Flattened on the wire.
598    #[serde(flatten)]
599    pub target: RefactoringTarget,
600    /// Machine-actionable refactoring and suppression hints. Always
601    /// populated; the list never empties because the action selector
602    /// unconditionally emits `apply-refactoring`. A trailing
603    /// `suppress-line` is appended only when the target carries
604    /// [`RefactoringTarget::evidence`] linking to specific functions.
605    pub actions: Vec<RefactoringTargetAction>,
606}
607
608impl Deref for RefactoringTargetFinding {
609    type Target = RefactoringTarget;
610
611    fn deref(&self) -> &Self::Target {
612        &self.target
613    }
614}
615
616impl From<RefactoringTarget> for RefactoringTargetFinding {
617    /// Convenience conversion: wrap a refactoring target with an empty
618    /// `actions` list. Used by tests and fixture builders. Production
619    /// code should call [`RefactoringTargetFinding::with_actions`] so
620    /// the wire shape carries the typed actions.
621    fn from(target: RefactoringTarget) -> Self {
622        Self {
623            target,
624            actions: Vec::new(),
625        }
626    }
627}
628
629impl RefactoringTargetFinding {
630    /// Construct a wrapper with the `actions` list computed from the
631    /// target's `recommendation`, `category`, and optional `evidence`.
632    ///
633    /// Asymmetry with [`HotspotFinding::with_actions`]: this constructor
634    /// does NOT take a `root: &Path` because refactoring-target action
635    /// descriptions never interpolate the file path; they pass
636    /// [`RefactoringTarget::recommendation`] verbatim into the
637    /// `apply-refactoring` action. The [`RefactoringTarget::category`]
638    /// field flows into the action's `category` field as the serde
639    /// snake-case form.
640    #[must_use]
641    pub fn with_actions(target: RefactoringTarget) -> Self {
642        let actions = build_refactoring_target_actions(&target);
643        Self { target, actions }
644    }
645}
646
647/// Compute the typed `actions` list for a refactoring target.
648///
649/// The list always begins with `apply-refactoring`. A trailing
650/// `suppress-line` is appended only when the target carries
651/// [`RefactoringTarget::evidence`] linking to specific functions.
652fn build_refactoring_target_actions(target: &RefactoringTarget) -> Vec<RefactoringTargetAction> {
653    let mut actions = vec![RefactoringTargetAction {
654        kind: RefactoringTargetActionType::ApplyRefactoring,
655        auto_fixable: false,
656        description: target.recommendation.clone(),
657        category: Some(category_snake_case(&target.category).to_string()),
658        comment: None,
659    }];
660
661    if target.evidence.is_some() {
662        actions.push(RefactoringTargetAction {
663            kind: RefactoringTargetActionType::SuppressLine,
664            auto_fixable: false,
665            description: "Suppress the underlying complexity finding".to_string(),
666            category: None,
667            comment: Some("// fallow-ignore-next-line complexity".to_string()),
668        });
669    }
670
671    actions
672}
673
674/// Serde-rename_all-snake_case form of a [`RecommendationCategory`]
675/// variant.
676///
677/// `RefactoringTargetAction.category` is `Option<String>` carrying the
678/// serde-encoded form of [`RecommendationCategory`]. The JSON post-pass
679/// retired by issue #408 read this string from the serialized JSON
680/// value; the typed action builder needs the same form without paying
681/// for a serde round-trip per target. The
682/// `recommendation_category_snake_case_round_trips` test in this module
683/// asserts every variant matches `serde_json::to_value` byte-for-byte,
684/// so silent drift between this function and the
685/// `#[serde(rename_all = "snake_case")]` attribute is caught at test
686/// time.
687const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
688    match cat {
689        RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
690        RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
691        RecommendationCategory::SplitHighImpact => "split_high_impact",
692        RecommendationCategory::RemoveDeadCode => "remove_dead_code",
693        RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
694        RecommendationCategory::ExtractDependencies => "extract_dependencies",
695        RecommendationCategory::AddTestCoverage => "add_test_coverage",
696    }
697}
698
699#[cfg(test)]
700mod hotspot_target_tests {
701    use super::*;
702    use crate::health_types::scores::{
703        ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
704    };
705    use fallow_core::churn::ChurnTrend;
706    use std::path::PathBuf;
707
708    fn sample_entry(path: &str) -> HotspotEntry {
709        HotspotEntry {
710            path: PathBuf::from(path),
711            score: 80.0,
712            commits: 12,
713            weighted_commits: 8.0,
714            lines_added: 100,
715            lines_deleted: 40,
716            complexity_density: 1.5,
717            fan_in: 3,
718            trend: ChurnTrend::Stable,
719            ownership: None,
720            is_test_path: false,
721        }
722    }
723
724    fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
725        ContributorEntry {
726            identifier: identifier.to_string(),
727            format: ContributorIdentifierFormat::Handle,
728            share: 1.0,
729            stale_days: 1,
730            commits,
731        }
732    }
733
734    fn sample_target() -> RefactoringTarget {
735        RefactoringTarget {
736            path: PathBuf::from("/root/src/foo.ts"),
737            priority: 75.0,
738            efficiency: 75.0,
739            recommendation: "Extract `handleRequest` into helpers".to_string(),
740            category: RecommendationCategory::ExtractComplexFunctions,
741            effort: crate::health_types::EffortEstimate::Low,
742            confidence: crate::health_types::Confidence::High,
743            factors: Vec::new(),
744            evidence: None,
745        }
746    }
747
748    #[test]
749    fn hotspot_finding_flattens_inner_fields_at_top_level() {
750        let entry = sample_entry("/root/src/api.ts");
751        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
752        let json = serde_json::to_value(&finding).unwrap();
753        let obj = json.as_object().unwrap();
754        assert!(obj.contains_key("score"));
755        assert!(obj.contains_key("commits"));
756        assert!(obj.contains_key("weighted_commits"));
757        assert!(obj.contains_key("actions"));
758        assert!(!obj.contains_key("ownership"));
759        assert!(!obj.contains_key("is_test_path"));
760    }
761
762    #[test]
763    fn hotspot_actions_default_pair_when_ownership_absent() {
764        let entry = sample_entry("/root/src/api.ts");
765        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
766        assert_eq!(finding.actions.len(), 2);
767        assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
768        assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
769        assert!(finding.actions[0].description.contains("src/api.ts"));
770    }
771
772    #[test]
773    fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
774        let mut entry = sample_entry("/root/src/api.ts");
775        entry.ownership = Some(OwnershipMetrics {
776            bus_factor: 1,
777            contributor_count: 1,
778            top_contributor: contributor("alice", 30),
779            recent_contributors: Vec::new(),
780            suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
781            declared_owner: None,
782            unowned: None,
783            ownership_state: OwnershipState::Active,
784            drift: false,
785            drift_reason: None,
786        });
787        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
788        let low_bus = finding
789            .actions
790            .iter()
791            .find(|a| a.kind == HotspotActionType::LowBusFactor)
792            .expect("low-bus-factor action present");
793        assert_eq!(
794            low_bus.note.as_deref(),
795            Some("Candidate reviewers: @bob, @carol"),
796        );
797    }
798
799    #[test]
800    fn hotspot_low_bus_factor_softens_for_low_commit_files() {
801        let mut entry = sample_entry("/root/src/api.ts");
802        entry.ownership = Some(OwnershipMetrics {
803            bus_factor: 1,
804            contributor_count: 1,
805            top_contributor: contributor("alice", 3),
806            recent_contributors: Vec::new(),
807            suggested_reviewers: Vec::new(),
808            declared_owner: None,
809            unowned: None,
810            ownership_state: OwnershipState::Active,
811            drift: false,
812            drift_reason: None,
813        });
814        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
815        let low_bus = finding
816            .actions
817            .iter()
818            .find(|a| a.kind == HotspotActionType::LowBusFactor)
819            .expect("low-bus-factor action present");
820        assert_eq!(
821            low_bus.note.as_deref(),
822            Some(
823                "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
824            ),
825        );
826    }
827
828    #[test]
829    fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
830        let mut entry = sample_entry("/root/src/api.ts");
831        entry.ownership = Some(OwnershipMetrics {
832            bus_factor: 1,
833            contributor_count: 1,
834            top_contributor: contributor("alice", 50),
835            recent_contributors: Vec::new(),
836            suggested_reviewers: Vec::new(),
837            declared_owner: None,
838            unowned: None,
839            ownership_state: OwnershipState::Active,
840            drift: false,
841            drift_reason: None,
842        });
843        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
844        let low_bus = finding
845            .actions
846            .iter()
847            .find(|a| a.kind == HotspotActionType::LowBusFactor)
848            .expect("low-bus-factor action present");
849        assert!(low_bus.note.is_none());
850    }
851
852    #[test]
853    fn hotspot_unowned_action_carries_deepest_directory_pattern() {
854        let mut entry = sample_entry("/root/src/api/users/handlers.ts");
855        entry.ownership = Some(OwnershipMetrics {
856            bus_factor: 2,
857            contributor_count: 3,
858            top_contributor: contributor("alice", 10),
859            recent_contributors: Vec::new(),
860            suggested_reviewers: Vec::new(),
861            declared_owner: None,
862            unowned: Some(true),
863            ownership_state: OwnershipState::Unowned,
864            drift: false,
865            drift_reason: None,
866        });
867        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
868        let unowned = finding
869            .actions
870            .iter()
871            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
872            .expect("unowned-hotspot action present");
873        assert_eq!(
874            unowned.suggested_pattern.as_deref(),
875            Some("/src/api/users/")
876        );
877        assert_eq!(
878            unowned.heuristic,
879            Some(HotspotActionHeuristic::DirectoryDeepest)
880        );
881    }
882
883    #[test]
884    fn hotspot_action_descriptions_normalise_windows_separators() {
885        let mut entry = sample_entry("src\\api\\users.ts");
886        entry.ownership = Some(OwnershipMetrics {
887            bus_factor: 2,
888            contributor_count: 3,
889            top_contributor: contributor("alice", 10),
890            recent_contributors: Vec::new(),
891            suggested_reviewers: Vec::new(),
892            declared_owner: None,
893            unowned: Some(true),
894            ownership_state: OwnershipState::Unowned,
895            drift: false,
896            drift_reason: None,
897        });
898        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
899        let refactor = finding
900            .actions
901            .iter()
902            .find(|a| a.kind == HotspotActionType::RefactorFile)
903            .expect("refactor-file action present");
904        assert!(refactor.description.contains("src/api/users.ts"));
905        assert!(!refactor.description.contains('\\'));
906        let unowned = finding
907            .actions
908            .iter()
909            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
910            .expect("unowned-hotspot action present");
911        assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
912    }
913
914    #[test]
915    fn hotspot_drift_action_uses_provided_reason() {
916        let mut entry = sample_entry("/root/src/api.ts");
917        entry.ownership = Some(OwnershipMetrics {
918            bus_factor: 2,
919            contributor_count: 4,
920            top_contributor: contributor("alice", 10),
921            recent_contributors: Vec::new(),
922            suggested_reviewers: Vec::new(),
923            declared_owner: None,
924            unowned: Some(false),
925            ownership_state: OwnershipState::Drifting,
926            drift: true,
927            drift_reason: Some("top contributor changed in last 6 months".to_string()),
928        });
929        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
930        let drift = finding
931            .actions
932            .iter()
933            .find(|a| a.kind == HotspotActionType::OwnershipDrift)
934            .expect("ownership-drift action present");
935        assert!(
936            drift
937                .description
938                .contains("top contributor changed in last 6 months"),
939        );
940    }
941
942    #[test]
943    fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
944        let target = sample_target();
945        let finding = RefactoringTargetFinding::with_actions(target);
946        let json = serde_json::to_value(&finding).unwrap();
947        let obj = json.as_object().unwrap();
948        assert!(obj.contains_key("priority"));
949        assert!(obj.contains_key("efficiency"));
950        assert!(obj.contains_key("recommendation"));
951        assert!(obj.contains_key("category"));
952        assert!(obj.contains_key("actions"));
953        assert!(!obj.contains_key("factors"));
954        assert!(!obj.contains_key("evidence"));
955    }
956
957    #[test]
958    fn refactoring_target_actions_default_to_apply_only_without_evidence() {
959        let target = sample_target();
960        let finding = RefactoringTargetFinding::with_actions(target);
961        assert_eq!(finding.actions.len(), 1);
962        assert_eq!(
963            finding.actions[0].kind,
964            RefactoringTargetActionType::ApplyRefactoring,
965        );
966        assert_eq!(
967            finding.actions[0].category.as_deref(),
968            Some("extract_complex_functions"),
969        );
970        assert_eq!(
971            finding.actions[0].description,
972            "Extract `handleRequest` into helpers",
973        );
974    }
975
976    #[test]
977    fn refactoring_target_actions_append_suppress_when_evidence_present() {
978        let mut target = sample_target();
979        target.evidence = Some(crate::health_types::TargetEvidence {
980            unused_exports: Vec::new(),
981            complex_functions: vec![crate::health_types::EvidenceFunction {
982                name: "handleRequest".to_string(),
983                line: 12,
984                cognitive: 30,
985            }],
986            cycle_path: Vec::new(),
987            ..Default::default()
988        });
989        let finding = RefactoringTargetFinding::with_actions(target);
990        assert_eq!(finding.actions.len(), 2);
991        assert_eq!(
992            finding.actions[1].kind,
993            RefactoringTargetActionType::SuppressLine,
994        );
995        assert_eq!(
996            finding.actions[1].comment.as_deref(),
997            Some("// fallow-ignore-next-line complexity"),
998        );
999    }
1000
1001    #[test]
1002    fn codeowners_pattern_uses_deepest_directory() {
1003        assert_eq!(
1004            suggest_codeowners_pattern("src/api/users/handlers.ts"),
1005            "/src/api/users/",
1006        );
1007    }
1008
1009    #[test]
1010    fn codeowners_pattern_for_root_file() {
1011        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
1012    }
1013
1014    #[test]
1015    fn codeowners_pattern_normalizes_backslashes() {
1016        assert_eq!(
1017            suggest_codeowners_pattern("src\\api\\users.ts"),
1018            "/src/api/",
1019        );
1020    }
1021
1022    #[test]
1023    fn codeowners_pattern_two_level_path() {
1024        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1025    }
1026
1027    #[test]
1028    fn recommendation_category_snake_case_round_trips_through_serde() {
1029        let variants = [
1030            RecommendationCategory::UrgentChurnComplexity,
1031            RecommendationCategory::BreakCircularDependency,
1032            RecommendationCategory::SplitHighImpact,
1033            RecommendationCategory::RemoveDeadCode,
1034            RecommendationCategory::ExtractComplexFunctions,
1035            RecommendationCategory::ExtractDependencies,
1036            RecommendationCategory::AddTestCoverage,
1037        ];
1038        for cat in &variants {
1039            let via_serde = serde_json::to_value(cat).unwrap();
1040            let serde_str = via_serde.as_str().unwrap();
1041            assert_eq!(
1042                serde_str,
1043                category_snake_case(cat),
1044                "category_snake_case for {cat:?} drifted from serde rename_all",
1045            );
1046        }
1047    }
1048}