1use 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#[derive(Debug, Clone, Copy, Default)]
20pub struct HealthActionOptions {
21 pub omit_suppress_line: bool,
23 pub omit_reason: Option<&'static str>,
25}
26
27#[derive(Debug, Clone, Copy)]
29pub struct HealthActionContext {
30 pub opts: HealthActionOptions,
32 pub max_cyclomatic_threshold: u16,
34 pub max_cognitive_threshold: u16,
36 pub max_crap_threshold: f64,
38 pub crap_refactor_band: u16,
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46pub struct HealthFinding {
47 #[serde(flatten)]
49 pub violation: ComplexityViolation,
50 pub actions: Vec<HealthFindingAction>,
52 #[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 fn from(violation: ComplexityViolation) -> Self {
69 Self {
70 violation,
71 actions: Vec::new(),
72 introduced: None,
73 }
74 }
75}
76
77impl HealthFinding {
78 #[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 #[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#[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
244fn 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#[derive(Debug, Clone, serde::Serialize)]
317#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318pub struct HotspotFinding {
319 #[serde(flatten)]
321 pub entry: HotspotEntry,
322 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 fn from(entry: HotspotEntry) -> Self {
345 Self {
346 entry,
347 actions: Vec::new(),
348 }
349 }
350}
351
352impl HotspotFinding {
353 #[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
370fn 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
484fn 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(); if components.is_empty() {
503 return format!("/{trimmed}");
504 }
505 format!("/{}/", components.join("/"))
506}
507
508#[derive(Debug, Clone, serde::Serialize)]
521#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
522pub struct RefactoringTargetFinding {
523 #[serde(flatten)]
525 pub target: RefactoringTarget,
526 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 fn from(target: RefactoringTarget) -> Self {
548 Self {
549 target,
550 actions: Vec::new(),
551 }
552 }
553}
554
555impl RefactoringTargetFinding {
556 #[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
573fn 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
600const 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}