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 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
304fn 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#[derive(Debug, Clone, serde::Serialize)]
377#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
378pub struct HotspotFinding {
379 #[serde(flatten)]
381 pub entry: HotspotEntry,
382 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 fn from(entry: HotspotEntry) -> Self {
405 Self {
406 entry,
407 actions: Vec::new(),
408 }
409 }
410}
411
412impl HotspotFinding {
413 #[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
430fn build_hotspot_actions(entry: &HotspotEntry, root: &Path) -> Vec<HotspotAction> {
437 let relative = entry.path.strip_prefix(root).unwrap_or(&entry.path);
438 let path = relative.to_string_lossy().replace('\\', "/");
439 let mut actions = base_hotspot_actions(&path);
440 if let Some(ownership) = entry.ownership.as_ref() {
441 append_ownership_hotspot_actions(&mut actions, ownership, &path);
442 }
443 actions
444}
445
446fn base_hotspot_actions(path: &str) -> Vec<HotspotAction> {
447 vec![
448 HotspotAction {
449 kind: HotspotActionType::RefactorFile,
450 auto_fixable: false,
451 description: format!(
452 "Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"
453 ),
454 note: Some(
455 "Prioritize extracting complex functions, adding tests, or splitting the module"
456 .to_string(),
457 ),
458 suggested_pattern: None,
459 heuristic: None,
460 },
461 HotspotAction {
462 kind: HotspotActionType::AddTests,
463 auto_fixable: false,
464 description: format!("Add test coverage for `{path}` to reduce change risk"),
465 note: Some(
466 "Frequently changed complex files benefit most from comprehensive test coverage"
467 .to_string(),
468 ),
469 suggested_pattern: None,
470 heuristic: None,
471 },
472 ]
473}
474
475fn append_ownership_hotspot_actions(
476 actions: &mut Vec<HotspotAction>,
477 ownership: &crate::health_types::OwnershipMetrics,
478 path: &str,
479) {
480 if ownership.bus_factor == 1 {
481 let top = &ownership.top_contributor;
482 let owner = top.identifier.as_str();
483 let commits = top.commits;
484 let suggested: Vec<&str> = ownership
485 .suggested_reviewers
486 .iter()
487 .map(|r| r.identifier.as_str())
488 .collect();
489 let note = if suggested.is_empty() {
490 if commits < 5 {
491 Some(
492 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
493 .to_string(),
494 )
495 } else {
496 None
497 }
498 } else {
499 let list = suggested
500 .iter()
501 .map(|s| format!("@{s}"))
502 .collect::<Vec<_>>()
503 .join(", ");
504 Some(format!("Candidate reviewers: {list}"))
505 };
506 actions.push(HotspotAction {
507 kind: HotspotActionType::LowBusFactor,
508 auto_fixable: false,
509 description: format!(
510 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
511 ),
512 note,
513 suggested_pattern: None,
514 heuristic: None,
515 });
516 }
517
518 if ownership.unowned == Some(true) {
519 actions.push(HotspotAction {
520 kind: HotspotActionType::UnownedHotspot,
521 auto_fixable: false,
522 description: format!("Add a CODEOWNERS entry for `{path}`"),
523 note: Some(
524 "Frequently-changed files without declared owners create review bottlenecks"
525 .to_string(),
526 ),
527 suggested_pattern: Some(suggest_codeowners_pattern(path)),
528 heuristic: Some(HotspotActionHeuristic::DirectoryDeepest),
529 });
530 }
531
532 if ownership.ownership_state == OwnershipState::Drifting && ownership.drift {
533 let reason = ownership
534 .drift_reason
535 .as_deref()
536 .unwrap_or("ownership has shifted from the original author");
537 actions.push(HotspotAction {
538 kind: HotspotActionType::OwnershipDrift,
539 auto_fixable: false,
540 description: format!("Update CODEOWNERS for `{path}`: {reason}"),
541 note: Some(
542 "Drift suggests the declared or original owner is no longer the right reviewer"
543 .to_string(),
544 ),
545 suggested_pattern: None,
546 heuristic: None,
547 });
548 }
549}
550
551fn suggest_codeowners_pattern(path: &str) -> String {
565 let normalized = path.replace('\\', "/");
566 let trimmed = normalized.trim_start_matches('/');
567 let mut components: Vec<&str> = trimmed.split('/').collect();
568 components.pop(); if components.is_empty() {
570 return format!("/{trimmed}");
571 }
572 format!("/{}/", components.join("/"))
573}
574
575#[derive(Debug, Clone, serde::Serialize)]
588#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
589pub struct RefactoringTargetFinding {
590 #[serde(flatten)]
592 pub target: RefactoringTarget,
593 pub actions: Vec<RefactoringTargetAction>,
599}
600
601impl Deref for RefactoringTargetFinding {
602 type Target = RefactoringTarget;
603
604 fn deref(&self) -> &Self::Target {
605 &self.target
606 }
607}
608
609impl From<RefactoringTarget> for RefactoringTargetFinding {
610 fn from(target: RefactoringTarget) -> Self {
615 Self {
616 target,
617 actions: Vec::new(),
618 }
619 }
620}
621
622impl RefactoringTargetFinding {
623 #[must_use]
634 pub fn with_actions(target: RefactoringTarget) -> Self {
635 let actions = build_refactoring_target_actions(&target);
636 Self { target, actions }
637 }
638}
639
640fn build_refactoring_target_actions(target: &RefactoringTarget) -> Vec<RefactoringTargetAction> {
646 let mut actions = vec![RefactoringTargetAction {
647 kind: RefactoringTargetActionType::ApplyRefactoring,
648 auto_fixable: false,
649 description: target.recommendation.clone(),
650 category: Some(category_snake_case(&target.category).to_string()),
651 comment: None,
652 }];
653
654 if target.evidence.is_some() {
655 actions.push(RefactoringTargetAction {
656 kind: RefactoringTargetActionType::SuppressLine,
657 auto_fixable: false,
658 description: "Suppress the underlying complexity finding".to_string(),
659 category: None,
660 comment: Some("// fallow-ignore-next-line complexity".to_string()),
661 });
662 }
663
664 actions
665}
666
667const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
681 match cat {
682 RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
683 RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
684 RecommendationCategory::SplitHighImpact => "split_high_impact",
685 RecommendationCategory::RemoveDeadCode => "remove_dead_code",
686 RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
687 RecommendationCategory::ExtractDependencies => "extract_dependencies",
688 RecommendationCategory::AddTestCoverage => "add_test_coverage",
689 }
690}
691
692#[cfg(test)]
693mod hotspot_target_tests {
694 use super::*;
695 use crate::health_types::scores::{
696 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
697 };
698 use fallow_core::churn::ChurnTrend;
699 use std::path::PathBuf;
700
701 fn sample_entry(path: &str) -> HotspotEntry {
702 HotspotEntry {
703 path: PathBuf::from(path),
704 score: 80.0,
705 commits: 12,
706 weighted_commits: 8.0,
707 lines_added: 100,
708 lines_deleted: 40,
709 complexity_density: 1.5,
710 fan_in: 3,
711 trend: ChurnTrend::Stable,
712 ownership: None,
713 is_test_path: false,
714 }
715 }
716
717 fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
718 ContributorEntry {
719 identifier: identifier.to_string(),
720 format: ContributorIdentifierFormat::Handle,
721 share: 1.0,
722 stale_days: 1,
723 commits,
724 }
725 }
726
727 fn sample_target() -> RefactoringTarget {
728 RefactoringTarget {
729 path: PathBuf::from("/root/src/foo.ts"),
730 priority: 75.0,
731 efficiency: 75.0,
732 recommendation: "Extract `handleRequest` into helpers".to_string(),
733 category: RecommendationCategory::ExtractComplexFunctions,
734 effort: crate::health_types::EffortEstimate::Low,
735 confidence: crate::health_types::Confidence::High,
736 factors: Vec::new(),
737 evidence: None,
738 }
739 }
740
741 #[test]
742 fn hotspot_finding_flattens_inner_fields_at_top_level() {
743 let entry = sample_entry("/root/src/api.ts");
744 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
745 let json = serde_json::to_value(&finding).unwrap();
746 let obj = json.as_object().unwrap();
747 assert!(obj.contains_key("score"));
748 assert!(obj.contains_key("commits"));
749 assert!(obj.contains_key("weighted_commits"));
750 assert!(obj.contains_key("actions"));
751 assert!(!obj.contains_key("ownership"));
752 assert!(!obj.contains_key("is_test_path"));
753 }
754
755 #[test]
756 fn hotspot_actions_default_pair_when_ownership_absent() {
757 let entry = sample_entry("/root/src/api.ts");
758 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
759 assert_eq!(finding.actions.len(), 2);
760 assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
761 assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
762 assert!(finding.actions[0].description.contains("src/api.ts"));
763 }
764
765 #[test]
766 fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
767 let mut entry = sample_entry("/root/src/api.ts");
768 entry.ownership = Some(OwnershipMetrics {
769 bus_factor: 1,
770 contributor_count: 1,
771 top_contributor: contributor("alice", 30),
772 recent_contributors: Vec::new(),
773 suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
774 declared_owner: None,
775 unowned: None,
776 ownership_state: OwnershipState::Active,
777 drift: false,
778 drift_reason: None,
779 });
780 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
781 let low_bus = finding
782 .actions
783 .iter()
784 .find(|a| a.kind == HotspotActionType::LowBusFactor)
785 .expect("low-bus-factor action present");
786 assert_eq!(
787 low_bus.note.as_deref(),
788 Some("Candidate reviewers: @bob, @carol"),
789 );
790 }
791
792 #[test]
793 fn hotspot_low_bus_factor_softens_for_low_commit_files() {
794 let mut entry = sample_entry("/root/src/api.ts");
795 entry.ownership = Some(OwnershipMetrics {
796 bus_factor: 1,
797 contributor_count: 1,
798 top_contributor: contributor("alice", 3),
799 recent_contributors: Vec::new(),
800 suggested_reviewers: Vec::new(),
801 declared_owner: None,
802 unowned: None,
803 ownership_state: OwnershipState::Active,
804 drift: false,
805 drift_reason: None,
806 });
807 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
808 let low_bus = finding
809 .actions
810 .iter()
811 .find(|a| a.kind == HotspotActionType::LowBusFactor)
812 .expect("low-bus-factor action present");
813 assert_eq!(
814 low_bus.note.as_deref(),
815 Some(
816 "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
817 ),
818 );
819 }
820
821 #[test]
822 fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
823 let mut entry = sample_entry("/root/src/api.ts");
824 entry.ownership = Some(OwnershipMetrics {
825 bus_factor: 1,
826 contributor_count: 1,
827 top_contributor: contributor("alice", 50),
828 recent_contributors: Vec::new(),
829 suggested_reviewers: Vec::new(),
830 declared_owner: None,
831 unowned: None,
832 ownership_state: OwnershipState::Active,
833 drift: false,
834 drift_reason: None,
835 });
836 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
837 let low_bus = finding
838 .actions
839 .iter()
840 .find(|a| a.kind == HotspotActionType::LowBusFactor)
841 .expect("low-bus-factor action present");
842 assert!(low_bus.note.is_none());
843 }
844
845 #[test]
846 fn hotspot_unowned_action_carries_deepest_directory_pattern() {
847 let mut entry = sample_entry("/root/src/api/users/handlers.ts");
848 entry.ownership = Some(OwnershipMetrics {
849 bus_factor: 2,
850 contributor_count: 3,
851 top_contributor: contributor("alice", 10),
852 recent_contributors: Vec::new(),
853 suggested_reviewers: Vec::new(),
854 declared_owner: None,
855 unowned: Some(true),
856 ownership_state: OwnershipState::Unowned,
857 drift: false,
858 drift_reason: None,
859 });
860 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
861 let unowned = finding
862 .actions
863 .iter()
864 .find(|a| a.kind == HotspotActionType::UnownedHotspot)
865 .expect("unowned-hotspot action present");
866 assert_eq!(
867 unowned.suggested_pattern.as_deref(),
868 Some("/src/api/users/")
869 );
870 assert_eq!(
871 unowned.heuristic,
872 Some(HotspotActionHeuristic::DirectoryDeepest)
873 );
874 }
875
876 #[test]
877 fn hotspot_action_descriptions_normalise_windows_separators() {
878 let mut entry = sample_entry("src\\api\\users.ts");
879 entry.ownership = Some(OwnershipMetrics {
880 bus_factor: 2,
881 contributor_count: 3,
882 top_contributor: contributor("alice", 10),
883 recent_contributors: Vec::new(),
884 suggested_reviewers: Vec::new(),
885 declared_owner: None,
886 unowned: Some(true),
887 ownership_state: OwnershipState::Unowned,
888 drift: false,
889 drift_reason: None,
890 });
891 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
892 let refactor = finding
893 .actions
894 .iter()
895 .find(|a| a.kind == HotspotActionType::RefactorFile)
896 .expect("refactor-file action present");
897 assert!(refactor.description.contains("src/api/users.ts"));
898 assert!(!refactor.description.contains('\\'));
899 let unowned = finding
900 .actions
901 .iter()
902 .find(|a| a.kind == HotspotActionType::UnownedHotspot)
903 .expect("unowned-hotspot action present");
904 assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
905 }
906
907 #[test]
908 fn hotspot_drift_action_uses_provided_reason() {
909 let mut entry = sample_entry("/root/src/api.ts");
910 entry.ownership = Some(OwnershipMetrics {
911 bus_factor: 2,
912 contributor_count: 4,
913 top_contributor: contributor("alice", 10),
914 recent_contributors: Vec::new(),
915 suggested_reviewers: Vec::new(),
916 declared_owner: None,
917 unowned: Some(false),
918 ownership_state: OwnershipState::Drifting,
919 drift: true,
920 drift_reason: Some("top contributor changed in last 6 months".to_string()),
921 });
922 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
923 let drift = finding
924 .actions
925 .iter()
926 .find(|a| a.kind == HotspotActionType::OwnershipDrift)
927 .expect("ownership-drift action present");
928 assert!(
929 drift
930 .description
931 .contains("top contributor changed in last 6 months"),
932 );
933 }
934
935 #[test]
936 fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
937 let target = sample_target();
938 let finding = RefactoringTargetFinding::with_actions(target);
939 let json = serde_json::to_value(&finding).unwrap();
940 let obj = json.as_object().unwrap();
941 assert!(obj.contains_key("priority"));
942 assert!(obj.contains_key("efficiency"));
943 assert!(obj.contains_key("recommendation"));
944 assert!(obj.contains_key("category"));
945 assert!(obj.contains_key("actions"));
946 assert!(!obj.contains_key("factors"));
947 assert!(!obj.contains_key("evidence"));
948 }
949
950 #[test]
951 fn refactoring_target_actions_default_to_apply_only_without_evidence() {
952 let target = sample_target();
953 let finding = RefactoringTargetFinding::with_actions(target);
954 assert_eq!(finding.actions.len(), 1);
955 assert_eq!(
956 finding.actions[0].kind,
957 RefactoringTargetActionType::ApplyRefactoring,
958 );
959 assert_eq!(
960 finding.actions[0].category.as_deref(),
961 Some("extract_complex_functions"),
962 );
963 assert_eq!(
964 finding.actions[0].description,
965 "Extract `handleRequest` into helpers",
966 );
967 }
968
969 #[test]
970 fn refactoring_target_actions_append_suppress_when_evidence_present() {
971 let mut target = sample_target();
972 target.evidence = Some(crate::health_types::TargetEvidence {
973 unused_exports: Vec::new(),
974 complex_functions: vec![crate::health_types::EvidenceFunction {
975 name: "handleRequest".to_string(),
976 line: 12,
977 cognitive: 30,
978 }],
979 cycle_path: Vec::new(),
980 ..Default::default()
981 });
982 let finding = RefactoringTargetFinding::with_actions(target);
983 assert_eq!(finding.actions.len(), 2);
984 assert_eq!(
985 finding.actions[1].kind,
986 RefactoringTargetActionType::SuppressLine,
987 );
988 assert_eq!(
989 finding.actions[1].comment.as_deref(),
990 Some("// fallow-ignore-next-line complexity"),
991 );
992 }
993
994 #[test]
995 fn codeowners_pattern_uses_deepest_directory() {
996 assert_eq!(
997 suggest_codeowners_pattern("src/api/users/handlers.ts"),
998 "/src/api/users/",
999 );
1000 }
1001
1002 #[test]
1003 fn codeowners_pattern_for_root_file() {
1004 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
1005 }
1006
1007 #[test]
1008 fn codeowners_pattern_normalizes_backslashes() {
1009 assert_eq!(
1010 suggest_codeowners_pattern("src\\api\\users.ts"),
1011 "/src/api/",
1012 );
1013 }
1014
1015 #[test]
1016 fn codeowners_pattern_two_level_path() {
1017 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1018 }
1019
1020 #[test]
1021 fn recommendation_category_snake_case_round_trips_through_serde() {
1022 let variants = [
1023 RecommendationCategory::UrgentChurnComplexity,
1024 RecommendationCategory::BreakCircularDependency,
1025 RecommendationCategory::SplitHighImpact,
1026 RecommendationCategory::RemoveDeadCode,
1027 RecommendationCategory::ExtractComplexFunctions,
1028 RecommendationCategory::ExtractDependencies,
1029 RecommendationCategory::AddTestCoverage,
1030 ];
1031 for cat in &variants {
1032 let via_serde = serde_json::to_value(cat).unwrap();
1033 let serde_str = via_serde.as_str().unwrap();
1034 assert_eq!(
1035 serde_str,
1036 category_snake_case(cat),
1037 "category_snake_case for {cat:?} drifted from serde rename_all",
1038 );
1039 }
1040 }
1041}