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