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