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
440    let mut actions = vec![
441        HotspotAction {
442            kind: HotspotActionType::RefactorFile,
443            auto_fixable: false,
444            description: format!(
445                "Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"
446            ),
447            note: Some(
448                "Prioritize extracting complex functions, adding tests, or splitting the module"
449                    .to_string(),
450            ),
451            suggested_pattern: None,
452            heuristic: None,
453        },
454        HotspotAction {
455            kind: HotspotActionType::AddTests,
456            auto_fixable: false,
457            description: format!("Add test coverage for `{path}` to reduce change risk"),
458            note: Some(
459                "Frequently changed complex files benefit most from comprehensive test coverage"
460                    .to_string(),
461            ),
462            suggested_pattern: None,
463            heuristic: None,
464        },
465    ];
466
467    let Some(ownership) = entry.ownership.as_ref() else {
468        return actions;
469    };
470
471    if ownership.bus_factor == 1 {
472        let top = &ownership.top_contributor;
473        let owner = top.identifier.as_str();
474        let commits = top.commits;
475        let suggested: Vec<&str> = ownership
476            .suggested_reviewers
477            .iter()
478            .map(|r| r.identifier.as_str())
479            .collect();
480        let note = if suggested.is_empty() {
481            if commits < 5 {
482                Some(
483                    "Single recent contributor on a low-commit file. Consider a pair review for major changes."
484                        .to_string(),
485                )
486            } else {
487                None
488            }
489        } else {
490            let list = suggested
491                .iter()
492                .map(|s| format!("@{s}"))
493                .collect::<Vec<_>>()
494                .join(", ");
495            Some(format!("Candidate reviewers: {list}"))
496        };
497        actions.push(HotspotAction {
498            kind: HotspotActionType::LowBusFactor,
499            auto_fixable: false,
500            description: format!(
501                "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
502            ),
503            note,
504            suggested_pattern: None,
505            heuristic: None,
506        });
507    }
508
509    if ownership.unowned == Some(true) {
510        actions.push(HotspotAction {
511            kind: HotspotActionType::UnownedHotspot,
512            auto_fixable: false,
513            description: format!("Add a CODEOWNERS entry for `{path}`"),
514            note: Some(
515                "Frequently-changed files without declared owners create review bottlenecks"
516                    .to_string(),
517            ),
518            suggested_pattern: Some(suggest_codeowners_pattern(&path)),
519            heuristic: Some(HotspotActionHeuristic::DirectoryDeepest),
520        });
521    }
522
523    if ownership.ownership_state == OwnershipState::Drifting && ownership.drift {
524        let reason = ownership
525            .drift_reason
526            .as_deref()
527            .unwrap_or("ownership has shifted from the original author");
528        actions.push(HotspotAction {
529            kind: HotspotActionType::OwnershipDrift,
530            auto_fixable: false,
531            description: format!("Update CODEOWNERS for `{path}`: {reason}"),
532            note: Some(
533                "Drift suggests the declared or original owner is no longer the right reviewer"
534                    .to_string(),
535            ),
536            suggested_pattern: None,
537            heuristic: None,
538        });
539    }
540
541    actions
542}
543
544/// Suggest a CODEOWNERS pattern for an unowned hotspot.
545///
546/// Picks the deepest directory containing the file
547/// (e.g. `src/api/users/handlers.ts` -> `/src/api/users/`) so agents can
548/// paste a tightly-scoped default. Earlier versions used the first two
549/// directory levels but that catches too many siblings in monorepos
550/// (`/src/api/` could span 200 files across 8 sub-domains). The deepest
551/// directory keeps the suggestion reviewable while still being a directory
552/// pattern rather than a per-file rule.
553///
554/// The action emits this alongside
555/// [`HotspotActionHeuristic::DirectoryDeepest`] so consumers can branch
556/// on the strategy if it evolves.
557fn suggest_codeowners_pattern(path: &str) -> String {
558    let normalized = path.replace('\\', "/");
559    let trimmed = normalized.trim_start_matches('/');
560    let mut components: Vec<&str> = trimmed.split('/').collect();
561    components.pop(); // drop the file itself
562    if components.is_empty() {
563        return format!("/{trimmed}");
564    }
565    format!("/{}/", components.join("/"))
566}
567
568/// Wire envelope for a single refactoring target.
569///
570/// Flattens [`RefactoringTarget`] for wire continuity and adds the typed
571/// `actions` list. The `#[serde(flatten)]` keeps each `targets[]` item
572/// byte-identical to the pre-wrapper shape: inner fields (`path`,
573/// `priority`, `efficiency`, `recommendation`, `category`, ...) sit at
574/// the top level alongside `actions`. Optional inner fields (`factors`,
575/// `evidence`) keep their original `skip_serializing_if` behaviour.
576///
577/// Construct via [`RefactoringTargetFinding::with_actions`] in the
578/// typical health pipeline or via [`RefactoringTargetFinding::from`] for
579/// fixture and test code.
580#[derive(Debug, Clone, serde::Serialize)]
581#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
582pub struct RefactoringTargetFinding {
583    /// Inner refactoring target payload. Flattened on the wire.
584    #[serde(flatten)]
585    pub target: RefactoringTarget,
586    /// Machine-actionable refactoring and suppression hints. Always
587    /// populated; the list never empties because the action selector
588    /// unconditionally emits `apply-refactoring`. A trailing
589    /// `suppress-line` is appended only when the target carries
590    /// [`RefactoringTarget::evidence`] linking to specific functions.
591    pub actions: Vec<RefactoringTargetAction>,
592}
593
594impl Deref for RefactoringTargetFinding {
595    type Target = RefactoringTarget;
596
597    fn deref(&self) -> &Self::Target {
598        &self.target
599    }
600}
601
602impl From<RefactoringTarget> for RefactoringTargetFinding {
603    /// Convenience conversion: wrap a refactoring target with an empty
604    /// `actions` list. Used by tests and fixture builders. Production
605    /// code should call [`RefactoringTargetFinding::with_actions`] so
606    /// the wire shape carries the typed actions.
607    fn from(target: RefactoringTarget) -> Self {
608        Self {
609            target,
610            actions: Vec::new(),
611        }
612    }
613}
614
615impl RefactoringTargetFinding {
616    /// Construct a wrapper with the `actions` list computed from the
617    /// target's `recommendation`, `category`, and optional `evidence`.
618    ///
619    /// Asymmetry with [`HotspotFinding::with_actions`]: this constructor
620    /// does NOT take a `root: &Path` because refactoring-target action
621    /// descriptions never interpolate the file path; they pass
622    /// [`RefactoringTarget::recommendation`] verbatim into the
623    /// `apply-refactoring` action. The [`RefactoringTarget::category`]
624    /// field flows into the action's `category` field as the serde
625    /// snake-case form.
626    #[must_use]
627    pub fn with_actions(target: RefactoringTarget) -> Self {
628        let actions = build_refactoring_target_actions(&target);
629        Self { target, actions }
630    }
631}
632
633/// Compute the typed `actions` list for a refactoring target.
634///
635/// The list always begins with `apply-refactoring`. A trailing
636/// `suppress-line` is appended only when the target carries
637/// [`RefactoringTarget::evidence`] linking to specific functions.
638fn build_refactoring_target_actions(target: &RefactoringTarget) -> Vec<RefactoringTargetAction> {
639    let mut actions = vec![RefactoringTargetAction {
640        kind: RefactoringTargetActionType::ApplyRefactoring,
641        auto_fixable: false,
642        description: target.recommendation.clone(),
643        category: Some(category_snake_case(&target.category).to_string()),
644        comment: None,
645    }];
646
647    if target.evidence.is_some() {
648        actions.push(RefactoringTargetAction {
649            kind: RefactoringTargetActionType::SuppressLine,
650            auto_fixable: false,
651            description: "Suppress the underlying complexity finding".to_string(),
652            category: None,
653            comment: Some("// fallow-ignore-next-line complexity".to_string()),
654        });
655    }
656
657    actions
658}
659
660/// Serde-rename_all-snake_case form of a [`RecommendationCategory`]
661/// variant.
662///
663/// `RefactoringTargetAction.category` is `Option<String>` carrying the
664/// serde-encoded form of [`RecommendationCategory`]. The JSON post-pass
665/// retired by issue #408 read this string from the serialized JSON
666/// value; the typed action builder needs the same form without paying
667/// for a serde round-trip per target. The
668/// `recommendation_category_snake_case_round_trips` test in this module
669/// asserts every variant matches `serde_json::to_value` byte-for-byte,
670/// so silent drift between this function and the
671/// `#[serde(rename_all = "snake_case")]` attribute is caught at test
672/// time.
673const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
674    match cat {
675        RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
676        RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
677        RecommendationCategory::SplitHighImpact => "split_high_impact",
678        RecommendationCategory::RemoveDeadCode => "remove_dead_code",
679        RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
680        RecommendationCategory::ExtractDependencies => "extract_dependencies",
681        RecommendationCategory::AddTestCoverage => "add_test_coverage",
682    }
683}
684
685#[cfg(test)]
686mod hotspot_target_tests {
687    use super::*;
688    use crate::health_types::scores::{
689        ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
690    };
691    use fallow_core::churn::ChurnTrend;
692    use std::path::PathBuf;
693
694    fn sample_entry(path: &str) -> HotspotEntry {
695        HotspotEntry {
696            path: PathBuf::from(path),
697            score: 80.0,
698            commits: 12,
699            weighted_commits: 8.0,
700            lines_added: 100,
701            lines_deleted: 40,
702            complexity_density: 1.5,
703            fan_in: 3,
704            trend: ChurnTrend::Stable,
705            ownership: None,
706            is_test_path: false,
707        }
708    }
709
710    fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
711        ContributorEntry {
712            identifier: identifier.to_string(),
713            format: ContributorIdentifierFormat::Handle,
714            share: 1.0,
715            stale_days: 1,
716            commits,
717        }
718    }
719
720    fn sample_target() -> RefactoringTarget {
721        RefactoringTarget {
722            path: PathBuf::from("/root/src/foo.ts"),
723            priority: 75.0,
724            efficiency: 75.0,
725            recommendation: "Extract `handleRequest` into helpers".to_string(),
726            category: RecommendationCategory::ExtractComplexFunctions,
727            effort: crate::health_types::EffortEstimate::Low,
728            confidence: crate::health_types::Confidence::High,
729            factors: Vec::new(),
730            evidence: None,
731        }
732    }
733
734    #[test]
735    fn hotspot_finding_flattens_inner_fields_at_top_level() {
736        let entry = sample_entry("/root/src/api.ts");
737        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
738        let json = serde_json::to_value(&finding).unwrap();
739        let obj = json.as_object().unwrap();
740        assert!(obj.contains_key("score"));
741        assert!(obj.contains_key("commits"));
742        assert!(obj.contains_key("weighted_commits"));
743        assert!(obj.contains_key("actions"));
744        assert!(!obj.contains_key("ownership"));
745        assert!(!obj.contains_key("is_test_path"));
746    }
747
748    #[test]
749    fn hotspot_actions_default_pair_when_ownership_absent() {
750        let entry = sample_entry("/root/src/api.ts");
751        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
752        assert_eq!(finding.actions.len(), 2);
753        assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
754        assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
755        assert!(finding.actions[0].description.contains("src/api.ts"));
756    }
757
758    #[test]
759    fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
760        let mut entry = sample_entry("/root/src/api.ts");
761        entry.ownership = Some(OwnershipMetrics {
762            bus_factor: 1,
763            contributor_count: 1,
764            top_contributor: contributor("alice", 30),
765            recent_contributors: Vec::new(),
766            suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
767            declared_owner: None,
768            unowned: None,
769            ownership_state: OwnershipState::Active,
770            drift: false,
771            drift_reason: None,
772        });
773        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
774        let low_bus = finding
775            .actions
776            .iter()
777            .find(|a| a.kind == HotspotActionType::LowBusFactor)
778            .expect("low-bus-factor action present");
779        assert_eq!(
780            low_bus.note.as_deref(),
781            Some("Candidate reviewers: @bob, @carol"),
782        );
783    }
784
785    #[test]
786    fn hotspot_low_bus_factor_softens_for_low_commit_files() {
787        let mut entry = sample_entry("/root/src/api.ts");
788        entry.ownership = Some(OwnershipMetrics {
789            bus_factor: 1,
790            contributor_count: 1,
791            top_contributor: contributor("alice", 3),
792            recent_contributors: Vec::new(),
793            suggested_reviewers: Vec::new(),
794            declared_owner: None,
795            unowned: None,
796            ownership_state: OwnershipState::Active,
797            drift: false,
798            drift_reason: None,
799        });
800        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
801        let low_bus = finding
802            .actions
803            .iter()
804            .find(|a| a.kind == HotspotActionType::LowBusFactor)
805            .expect("low-bus-factor action present");
806        assert_eq!(
807            low_bus.note.as_deref(),
808            Some(
809                "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
810            ),
811        );
812    }
813
814    #[test]
815    fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
816        let mut entry = sample_entry("/root/src/api.ts");
817        entry.ownership = Some(OwnershipMetrics {
818            bus_factor: 1,
819            contributor_count: 1,
820            top_contributor: contributor("alice", 50),
821            recent_contributors: Vec::new(),
822            suggested_reviewers: Vec::new(),
823            declared_owner: None,
824            unowned: None,
825            ownership_state: OwnershipState::Active,
826            drift: false,
827            drift_reason: None,
828        });
829        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
830        let low_bus = finding
831            .actions
832            .iter()
833            .find(|a| a.kind == HotspotActionType::LowBusFactor)
834            .expect("low-bus-factor action present");
835        assert!(low_bus.note.is_none());
836    }
837
838    #[test]
839    fn hotspot_unowned_action_carries_deepest_directory_pattern() {
840        let mut entry = sample_entry("/root/src/api/users/handlers.ts");
841        entry.ownership = Some(OwnershipMetrics {
842            bus_factor: 2,
843            contributor_count: 3,
844            top_contributor: contributor("alice", 10),
845            recent_contributors: Vec::new(),
846            suggested_reviewers: Vec::new(),
847            declared_owner: None,
848            unowned: Some(true),
849            ownership_state: OwnershipState::Unowned,
850            drift: false,
851            drift_reason: None,
852        });
853        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
854        let unowned = finding
855            .actions
856            .iter()
857            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
858            .expect("unowned-hotspot action present");
859        assert_eq!(
860            unowned.suggested_pattern.as_deref(),
861            Some("/src/api/users/")
862        );
863        assert_eq!(
864            unowned.heuristic,
865            Some(HotspotActionHeuristic::DirectoryDeepest)
866        );
867    }
868
869    #[test]
870    fn hotspot_action_descriptions_normalise_windows_separators() {
871        let mut entry = sample_entry("src\\api\\users.ts");
872        entry.ownership = Some(OwnershipMetrics {
873            bus_factor: 2,
874            contributor_count: 3,
875            top_contributor: contributor("alice", 10),
876            recent_contributors: Vec::new(),
877            suggested_reviewers: Vec::new(),
878            declared_owner: None,
879            unowned: Some(true),
880            ownership_state: OwnershipState::Unowned,
881            drift: false,
882            drift_reason: None,
883        });
884        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
885        let refactor = finding
886            .actions
887            .iter()
888            .find(|a| a.kind == HotspotActionType::RefactorFile)
889            .expect("refactor-file action present");
890        assert!(refactor.description.contains("src/api/users.ts"));
891        assert!(!refactor.description.contains('\\'));
892        let unowned = finding
893            .actions
894            .iter()
895            .find(|a| a.kind == HotspotActionType::UnownedHotspot)
896            .expect("unowned-hotspot action present");
897        assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
898    }
899
900    #[test]
901    fn hotspot_drift_action_uses_provided_reason() {
902        let mut entry = sample_entry("/root/src/api.ts");
903        entry.ownership = Some(OwnershipMetrics {
904            bus_factor: 2,
905            contributor_count: 4,
906            top_contributor: contributor("alice", 10),
907            recent_contributors: Vec::new(),
908            suggested_reviewers: Vec::new(),
909            declared_owner: None,
910            unowned: Some(false),
911            ownership_state: OwnershipState::Drifting,
912            drift: true,
913            drift_reason: Some("top contributor changed in last 6 months".to_string()),
914        });
915        let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
916        let drift = finding
917            .actions
918            .iter()
919            .find(|a| a.kind == HotspotActionType::OwnershipDrift)
920            .expect("ownership-drift action present");
921        assert!(
922            drift
923                .description
924                .contains("top contributor changed in last 6 months"),
925        );
926    }
927
928    #[test]
929    fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
930        let target = sample_target();
931        let finding = RefactoringTargetFinding::with_actions(target);
932        let json = serde_json::to_value(&finding).unwrap();
933        let obj = json.as_object().unwrap();
934        assert!(obj.contains_key("priority"));
935        assert!(obj.contains_key("efficiency"));
936        assert!(obj.contains_key("recommendation"));
937        assert!(obj.contains_key("category"));
938        assert!(obj.contains_key("actions"));
939        assert!(!obj.contains_key("factors"));
940        assert!(!obj.contains_key("evidence"));
941    }
942
943    #[test]
944    fn refactoring_target_actions_default_to_apply_only_without_evidence() {
945        let target = sample_target();
946        let finding = RefactoringTargetFinding::with_actions(target);
947        assert_eq!(finding.actions.len(), 1);
948        assert_eq!(
949            finding.actions[0].kind,
950            RefactoringTargetActionType::ApplyRefactoring,
951        );
952        assert_eq!(
953            finding.actions[0].category.as_deref(),
954            Some("extract_complex_functions"),
955        );
956        assert_eq!(
957            finding.actions[0].description,
958            "Extract `handleRequest` into helpers",
959        );
960    }
961
962    #[test]
963    fn refactoring_target_actions_append_suppress_when_evidence_present() {
964        let mut target = sample_target();
965        target.evidence = Some(crate::health_types::TargetEvidence {
966            unused_exports: Vec::new(),
967            complex_functions: vec![crate::health_types::EvidenceFunction {
968                name: "handleRequest".to_string(),
969                line: 12,
970                cognitive: 30,
971            }],
972            cycle_path: Vec::new(),
973            ..Default::default()
974        });
975        let finding = RefactoringTargetFinding::with_actions(target);
976        assert_eq!(finding.actions.len(), 2);
977        assert_eq!(
978            finding.actions[1].kind,
979            RefactoringTargetActionType::SuppressLine,
980        );
981        assert_eq!(
982            finding.actions[1].comment.as_deref(),
983            Some("// fallow-ignore-next-line complexity"),
984        );
985    }
986
987    #[test]
988    fn codeowners_pattern_uses_deepest_directory() {
989        assert_eq!(
990            suggest_codeowners_pattern("src/api/users/handlers.ts"),
991            "/src/api/users/",
992        );
993    }
994
995    #[test]
996    fn codeowners_pattern_for_root_file() {
997        assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
998    }
999
1000    #[test]
1001    fn codeowners_pattern_normalizes_backslashes() {
1002        assert_eq!(
1003            suggest_codeowners_pattern("src\\api\\users.ts"),
1004            "/src/api/",
1005        );
1006    }
1007
1008    #[test]
1009    fn codeowners_pattern_two_level_path() {
1010        assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1011    }
1012
1013    #[test]
1014    fn recommendation_category_snake_case_round_trips_through_serde() {
1015        let variants = [
1016            RecommendationCategory::UrgentChurnComplexity,
1017            RecommendationCategory::BreakCircularDependency,
1018            RecommendationCategory::SplitHighImpact,
1019            RecommendationCategory::RemoveDeadCode,
1020            RecommendationCategory::ExtractComplexFunctions,
1021            RecommendationCategory::ExtractDependencies,
1022            RecommendationCategory::AddTestCoverage,
1023        ];
1024        for cat in &variants {
1025            let via_serde = serde_json::to_value(cat).unwrap();
1026            let serde_str = via_serde.as_str().unwrap();
1027            assert_eq!(
1028                serde_str,
1029                category_snake_case(cat),
1030                "category_snake_case for {cat:?} drifted from serde rename_all",
1031            );
1032        }
1033    }
1034}