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