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(RefactorActionDecision {
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
176#[derive(Clone, Copy)]
177struct RefactorActionDecision<'a> {
178 crap_only: bool,
179 full_coverage_can_clear_crap: bool,
180 cyclomatic: u16,
181 cognitive: u16,
182 max_cyclomatic_threshold: u16,
183 max_cognitive_threshold: u16,
184 ctx: &'a HealthActionContext,
185}
186
187fn should_add_refactor_action(input: RefactorActionDecision<'_>) -> bool {
188 let crap_only_needs_complexity_reduction =
189 input.crap_only && !input.full_coverage_can_clear_crap;
190 let cognitive_floor = input.max_cognitive_threshold / 2;
191 let near_cyclomatic_threshold = input.crap_only
192 && input.cyclomatic > 0
193 && input.cyclomatic
194 >= input
195 .max_cyclomatic_threshold
196 .saturating_sub(input.ctx.crap_refactor_band)
197 && input.cognitive >= cognitive_floor;
198 !input.crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold
199}
200
201fn build_refactor_action(
202 violation: &ComplexityViolation,
203 name: &str,
204 is_template: bool,
205 is_component: bool,
206) -> HealthFindingAction {
207 let (description, note): (String, &str) = if is_component {
208 component_refactor_copy(violation)
209 } else if is_template {
210 (
211 format!(
212 "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
213 ),
214 "Consider splitting complex template branches into smaller components or simpler bindings",
215 )
216 } else {
217 (
218 format!(
219 "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
220 ),
221 "Consider splitting into smaller functions with single responsibilities",
222 )
223 };
224 HealthFindingAction {
225 kind: HealthFindingActionType::RefactorFunction,
226 auto_fixable: false,
227 description,
228 note: Some(note.to_string()),
229 comment: None,
230 placement: None,
231 target_path: None,
232 }
233}
234
235fn component_refactor_copy(violation: &ComplexityViolation) -> (String, &'static str) {
236 let rollup = violation.component_rollup.as_ref();
237 let class_name = rollup.map_or("the component", |r| r.component.as_str());
238 let worst_method = rollup.map_or("the worst class method", |r| {
239 r.class_worst_function.as_str()
240 });
241 let class_cyc = rollup.map_or(0_u16, |r| r.class_cyclomatic);
242 let template_cyc = rollup.map_or(0_u16, |r| r.template_cyclomatic);
243 (
244 format!(
245 "Refactor `{class_name}` to reduce component complexity (rolled-up cyclomatic {} = {class_cyc} on `{worst_method}` + {template_cyc} on the template)",
246 violation.cyclomatic
247 ),
248 "Consider splitting the template into smaller components OR extracting helpers from the worst class method; the rollup reflects the component as one complexity unit",
249 )
250}
251
252fn build_suppress_action(
253 violation: &ComplexityViolation,
254 is_template: bool,
255 is_component: bool,
256) -> HealthFindingAction {
257 if is_template
258 && violation
259 .path
260 .extension()
261 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
262 {
263 return suppress_file_action(
264 "Suppress with an HTML comment at the top of the template",
265 "<!-- fallow-ignore-file complexity -->",
266 "top-of-template",
267 );
268 }
269 if is_template {
270 return suppress_line_action(
271 "Suppress with an inline comment above the Angular decorator",
272 "above-angular-decorator",
273 );
274 }
275 if is_component {
276 return suppress_line_action(
277 "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)",
278 "above-component-worst-method",
279 );
280 }
281 suppress_line_action(
282 "Suppress with an inline comment above the function declaration",
283 "above-function-declaration",
284 )
285}
286
287fn suppress_file_action(description: &str, comment: &str, placement: &str) -> HealthFindingAction {
288 HealthFindingAction {
289 kind: HealthFindingActionType::SuppressFile,
290 auto_fixable: false,
291 description: description.to_string(),
292 note: None,
293 comment: Some(comment.to_string()),
294 placement: Some(placement.to_string()),
295 target_path: None,
296 }
297}
298
299fn suppress_line_action(description: &str, placement: &str) -> HealthFindingAction {
300 HealthFindingAction {
301 kind: HealthFindingActionType::SuppressLine,
302 auto_fixable: false,
303 description: description.to_string(),
304 note: None,
305 comment: Some("// fallow-ignore-next-line complexity".to_string()),
306 placement: Some(placement.to_string()),
307 target_path: None,
308 }
309}
310
311fn build_crap_coverage_action(
313 name: &str,
314 tier: Option<CoverageTier>,
315 full_coverage_can_clear_crap: bool,
316 inherited_from: Option<&Path>,
317) -> Option<HealthFindingAction> {
318 if !full_coverage_can_clear_crap {
319 return None;
320 }
321
322 if let Some(owner) = inherited_from {
323 let owner_str = owner.to_string_lossy().into_owned();
324 return Some(HealthFindingAction {
325 kind: HealthFindingActionType::IncreaseCoverage,
326 auto_fixable: false,
327 description: format!(
328 "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)"
329 ),
330 note: Some(
331 "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(),
332 ),
333 comment: None,
334 placement: None,
335 target_path: Some(owner_str),
336 });
337 }
338
339 match tier {
340 Some(CoverageTier::Partial | CoverageTier::High) => Some(HealthFindingAction {
341 kind: HealthFindingActionType::IncreaseCoverage,
342 auto_fixable: false,
343 description: format!(
344 "Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"
345 ),
346 note: Some(
347 "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(),
348 ),
349 comment: None,
350 placement: None,
351 target_path: None,
352 }),
353 _ => Some(HealthFindingAction {
354 kind: HealthFindingActionType::AddTests,
355 auto_fixable: false,
356 description: format!(
357 "Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"
358 ),
359 note: Some(
360 "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold".to_string(),
361 ),
362 comment: None,
363 placement: None,
364 target_path: None,
365 }),
366 }
367}
368
369#[derive(Debug, Clone, serde::Serialize)]
384#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
385pub struct HotspotFinding {
386 #[serde(flatten)]
388 pub entry: HotspotEntry,
389 pub actions: Vec<HotspotAction>,
396}
397
398impl Deref for HotspotFinding {
399 type Target = HotspotEntry;
400
401 fn deref(&self) -> &Self::Target {
402 &self.entry
403 }
404}
405
406impl From<HotspotEntry> for HotspotFinding {
407 fn from(entry: HotspotEntry) -> Self {
412 Self {
413 entry,
414 actions: Vec::new(),
415 }
416 }
417}
418
419impl HotspotFinding {
420 #[must_use]
431 pub fn with_actions(entry: HotspotEntry, root: &Path) -> Self {
432 let actions = build_hotspot_actions(&entry, root);
433 Self { entry, actions }
434 }
435}
436
437fn build_hotspot_actions(entry: &HotspotEntry, root: &Path) -> Vec<HotspotAction> {
444 let relative = entry.path.strip_prefix(root).unwrap_or(&entry.path);
445 let path = relative.to_string_lossy().replace('\\', "/");
446 let mut actions = base_hotspot_actions(&path);
447 if let Some(ownership) = entry.ownership.as_ref() {
448 append_ownership_hotspot_actions(&mut actions, ownership, &path);
449 }
450 actions
451}
452
453fn base_hotspot_actions(path: &str) -> Vec<HotspotAction> {
454 vec![
455 HotspotAction {
456 kind: HotspotActionType::RefactorFile,
457 auto_fixable: false,
458 description: format!(
459 "Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"
460 ),
461 note: Some(
462 "Prioritize extracting complex functions, adding tests, or splitting the module"
463 .to_string(),
464 ),
465 suggested_pattern: None,
466 heuristic: None,
467 },
468 HotspotAction {
469 kind: HotspotActionType::AddTests,
470 auto_fixable: false,
471 description: format!("Add test coverage for `{path}` to reduce change risk"),
472 note: Some(
473 "Frequently changed complex files benefit most from comprehensive test coverage"
474 .to_string(),
475 ),
476 suggested_pattern: None,
477 heuristic: None,
478 },
479 ]
480}
481
482fn append_ownership_hotspot_actions(
483 actions: &mut Vec<HotspotAction>,
484 ownership: &crate::health_types::OwnershipMetrics,
485 path: &str,
486) {
487 if ownership.bus_factor == 1 {
488 let top = &ownership.top_contributor;
489 let owner = top.identifier.as_str();
490 let commits = top.commits;
491 let suggested: Vec<&str> = ownership
492 .suggested_reviewers
493 .iter()
494 .map(|r| r.identifier.as_str())
495 .collect();
496 let note = if suggested.is_empty() {
497 if commits < 5 {
498 Some(
499 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
500 .to_string(),
501 )
502 } else {
503 None
504 }
505 } else {
506 let list = suggested
507 .iter()
508 .map(|s| format!("@{s}"))
509 .collect::<Vec<_>>()
510 .join(", ");
511 Some(format!("Candidate reviewers: {list}"))
512 };
513 actions.push(HotspotAction {
514 kind: HotspotActionType::LowBusFactor,
515 auto_fixable: false,
516 description: format!(
517 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
518 ),
519 note,
520 suggested_pattern: None,
521 heuristic: None,
522 });
523 }
524
525 if ownership.unowned == Some(true) {
526 actions.push(HotspotAction {
527 kind: HotspotActionType::UnownedHotspot,
528 auto_fixable: false,
529 description: format!("Add a CODEOWNERS entry for `{path}`"),
530 note: Some(
531 "Frequently-changed files without declared owners create review bottlenecks"
532 .to_string(),
533 ),
534 suggested_pattern: Some(suggest_codeowners_pattern(path)),
535 heuristic: Some(HotspotActionHeuristic::DirectoryDeepest),
536 });
537 }
538
539 if ownership.ownership_state == OwnershipState::Drifting && ownership.drift {
540 let reason = ownership
541 .drift_reason
542 .as_deref()
543 .unwrap_or("ownership has shifted from the original author");
544 actions.push(HotspotAction {
545 kind: HotspotActionType::OwnershipDrift,
546 auto_fixable: false,
547 description: format!("Update CODEOWNERS for `{path}`: {reason}"),
548 note: Some(
549 "Drift suggests the declared or original owner is no longer the right reviewer"
550 .to_string(),
551 ),
552 suggested_pattern: None,
553 heuristic: None,
554 });
555 }
556}
557
558fn suggest_codeowners_pattern(path: &str) -> String {
572 let normalized = path.replace('\\', "/");
573 let trimmed = normalized.trim_start_matches('/');
574 let mut components: Vec<&str> = trimmed.split('/').collect();
575 components.pop(); if components.is_empty() {
577 return format!("/{trimmed}");
578 }
579 format!("/{}/", components.join("/"))
580}
581
582#[derive(Debug, Clone, serde::Serialize)]
595#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
596pub struct RefactoringTargetFinding {
597 #[serde(flatten)]
599 pub target: RefactoringTarget,
600 pub actions: Vec<RefactoringTargetAction>,
606}
607
608impl Deref for RefactoringTargetFinding {
609 type Target = RefactoringTarget;
610
611 fn deref(&self) -> &Self::Target {
612 &self.target
613 }
614}
615
616impl From<RefactoringTarget> for RefactoringTargetFinding {
617 fn from(target: RefactoringTarget) -> Self {
622 Self {
623 target,
624 actions: Vec::new(),
625 }
626 }
627}
628
629impl RefactoringTargetFinding {
630 #[must_use]
641 pub fn with_actions(target: RefactoringTarget) -> Self {
642 let actions = build_refactoring_target_actions(&target);
643 Self { target, actions }
644 }
645}
646
647fn build_refactoring_target_actions(target: &RefactoringTarget) -> Vec<RefactoringTargetAction> {
653 let mut actions = vec![RefactoringTargetAction {
654 kind: RefactoringTargetActionType::ApplyRefactoring,
655 auto_fixable: false,
656 description: target.recommendation.clone(),
657 category: Some(category_snake_case(&target.category).to_string()),
658 comment: None,
659 }];
660
661 if target.evidence.is_some() {
662 actions.push(RefactoringTargetAction {
663 kind: RefactoringTargetActionType::SuppressLine,
664 auto_fixable: false,
665 description: "Suppress the underlying complexity finding".to_string(),
666 category: None,
667 comment: Some("// fallow-ignore-next-line complexity".to_string()),
668 });
669 }
670
671 actions
672}
673
674const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
688 match cat {
689 RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
690 RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
691 RecommendationCategory::SplitHighImpact => "split_high_impact",
692 RecommendationCategory::RemoveDeadCode => "remove_dead_code",
693 RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
694 RecommendationCategory::ExtractDependencies => "extract_dependencies",
695 RecommendationCategory::AddTestCoverage => "add_test_coverage",
696 }
697}
698
699#[cfg(test)]
700mod hotspot_target_tests {
701 use super::*;
702 use crate::health_types::scores::{
703 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
704 };
705 use fallow_core::churn::ChurnTrend;
706 use std::path::PathBuf;
707
708 fn sample_entry(path: &str) -> HotspotEntry {
709 HotspotEntry {
710 path: PathBuf::from(path),
711 score: 80.0,
712 commits: 12,
713 weighted_commits: 8.0,
714 lines_added: 100,
715 lines_deleted: 40,
716 complexity_density: 1.5,
717 fan_in: 3,
718 trend: ChurnTrend::Stable,
719 ownership: None,
720 is_test_path: false,
721 }
722 }
723
724 fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
725 ContributorEntry {
726 identifier: identifier.to_string(),
727 format: ContributorIdentifierFormat::Handle,
728 share: 1.0,
729 stale_days: 1,
730 commits,
731 }
732 }
733
734 fn sample_target() -> RefactoringTarget {
735 RefactoringTarget {
736 path: PathBuf::from("/root/src/foo.ts"),
737 priority: 75.0,
738 efficiency: 75.0,
739 recommendation: "Extract `handleRequest` into helpers".to_string(),
740 category: RecommendationCategory::ExtractComplexFunctions,
741 effort: crate::health_types::EffortEstimate::Low,
742 confidence: crate::health_types::Confidence::High,
743 factors: Vec::new(),
744 evidence: None,
745 }
746 }
747
748 #[test]
749 fn hotspot_finding_flattens_inner_fields_at_top_level() {
750 let entry = sample_entry("/root/src/api.ts");
751 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
752 let json = serde_json::to_value(&finding).unwrap();
753 let obj = json.as_object().unwrap();
754 assert!(obj.contains_key("score"));
755 assert!(obj.contains_key("commits"));
756 assert!(obj.contains_key("weighted_commits"));
757 assert!(obj.contains_key("actions"));
758 assert!(!obj.contains_key("ownership"));
759 assert!(!obj.contains_key("is_test_path"));
760 }
761
762 #[test]
763 fn hotspot_actions_default_pair_when_ownership_absent() {
764 let entry = sample_entry("/root/src/api.ts");
765 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
766 assert_eq!(finding.actions.len(), 2);
767 assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
768 assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
769 assert!(finding.actions[0].description.contains("src/api.ts"));
770 }
771
772 #[test]
773 fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
774 let mut entry = sample_entry("/root/src/api.ts");
775 entry.ownership = Some(OwnershipMetrics {
776 bus_factor: 1,
777 contributor_count: 1,
778 top_contributor: contributor("alice", 30),
779 recent_contributors: Vec::new(),
780 suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
781 declared_owner: None,
782 unowned: None,
783 ownership_state: OwnershipState::Active,
784 drift: false,
785 drift_reason: None,
786 });
787 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
788 let low_bus = finding
789 .actions
790 .iter()
791 .find(|a| a.kind == HotspotActionType::LowBusFactor)
792 .expect("low-bus-factor action present");
793 assert_eq!(
794 low_bus.note.as_deref(),
795 Some("Candidate reviewers: @bob, @carol"),
796 );
797 }
798
799 #[test]
800 fn hotspot_low_bus_factor_softens_for_low_commit_files() {
801 let mut entry = sample_entry("/root/src/api.ts");
802 entry.ownership = Some(OwnershipMetrics {
803 bus_factor: 1,
804 contributor_count: 1,
805 top_contributor: contributor("alice", 3),
806 recent_contributors: Vec::new(),
807 suggested_reviewers: Vec::new(),
808 declared_owner: None,
809 unowned: None,
810 ownership_state: OwnershipState::Active,
811 drift: false,
812 drift_reason: None,
813 });
814 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
815 let low_bus = finding
816 .actions
817 .iter()
818 .find(|a| a.kind == HotspotActionType::LowBusFactor)
819 .expect("low-bus-factor action present");
820 assert_eq!(
821 low_bus.note.as_deref(),
822 Some(
823 "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
824 ),
825 );
826 }
827
828 #[test]
829 fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
830 let mut entry = sample_entry("/root/src/api.ts");
831 entry.ownership = Some(OwnershipMetrics {
832 bus_factor: 1,
833 contributor_count: 1,
834 top_contributor: contributor("alice", 50),
835 recent_contributors: Vec::new(),
836 suggested_reviewers: Vec::new(),
837 declared_owner: None,
838 unowned: None,
839 ownership_state: OwnershipState::Active,
840 drift: false,
841 drift_reason: None,
842 });
843 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
844 let low_bus = finding
845 .actions
846 .iter()
847 .find(|a| a.kind == HotspotActionType::LowBusFactor)
848 .expect("low-bus-factor action present");
849 assert!(low_bus.note.is_none());
850 }
851
852 #[test]
853 fn hotspot_unowned_action_carries_deepest_directory_pattern() {
854 let mut entry = sample_entry("/root/src/api/users/handlers.ts");
855 entry.ownership = Some(OwnershipMetrics {
856 bus_factor: 2,
857 contributor_count: 3,
858 top_contributor: contributor("alice", 10),
859 recent_contributors: Vec::new(),
860 suggested_reviewers: Vec::new(),
861 declared_owner: None,
862 unowned: Some(true),
863 ownership_state: OwnershipState::Unowned,
864 drift: false,
865 drift_reason: None,
866 });
867 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
868 let unowned = finding
869 .actions
870 .iter()
871 .find(|a| a.kind == HotspotActionType::UnownedHotspot)
872 .expect("unowned-hotspot action present");
873 assert_eq!(
874 unowned.suggested_pattern.as_deref(),
875 Some("/src/api/users/")
876 );
877 assert_eq!(
878 unowned.heuristic,
879 Some(HotspotActionHeuristic::DirectoryDeepest)
880 );
881 }
882
883 #[test]
884 fn hotspot_action_descriptions_normalise_windows_separators() {
885 let mut entry = sample_entry("src\\api\\users.ts");
886 entry.ownership = Some(OwnershipMetrics {
887 bus_factor: 2,
888 contributor_count: 3,
889 top_contributor: contributor("alice", 10),
890 recent_contributors: Vec::new(),
891 suggested_reviewers: Vec::new(),
892 declared_owner: None,
893 unowned: Some(true),
894 ownership_state: OwnershipState::Unowned,
895 drift: false,
896 drift_reason: None,
897 });
898 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
899 let refactor = finding
900 .actions
901 .iter()
902 .find(|a| a.kind == HotspotActionType::RefactorFile)
903 .expect("refactor-file action present");
904 assert!(refactor.description.contains("src/api/users.ts"));
905 assert!(!refactor.description.contains('\\'));
906 let unowned = finding
907 .actions
908 .iter()
909 .find(|a| a.kind == HotspotActionType::UnownedHotspot)
910 .expect("unowned-hotspot action present");
911 assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
912 }
913
914 #[test]
915 fn hotspot_drift_action_uses_provided_reason() {
916 let mut entry = sample_entry("/root/src/api.ts");
917 entry.ownership = Some(OwnershipMetrics {
918 bus_factor: 2,
919 contributor_count: 4,
920 top_contributor: contributor("alice", 10),
921 recent_contributors: Vec::new(),
922 suggested_reviewers: Vec::new(),
923 declared_owner: None,
924 unowned: Some(false),
925 ownership_state: OwnershipState::Drifting,
926 drift: true,
927 drift_reason: Some("top contributor changed in last 6 months".to_string()),
928 });
929 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
930 let drift = finding
931 .actions
932 .iter()
933 .find(|a| a.kind == HotspotActionType::OwnershipDrift)
934 .expect("ownership-drift action present");
935 assert!(
936 drift
937 .description
938 .contains("top contributor changed in last 6 months"),
939 );
940 }
941
942 #[test]
943 fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
944 let target = sample_target();
945 let finding = RefactoringTargetFinding::with_actions(target);
946 let json = serde_json::to_value(&finding).unwrap();
947 let obj = json.as_object().unwrap();
948 assert!(obj.contains_key("priority"));
949 assert!(obj.contains_key("efficiency"));
950 assert!(obj.contains_key("recommendation"));
951 assert!(obj.contains_key("category"));
952 assert!(obj.contains_key("actions"));
953 assert!(!obj.contains_key("factors"));
954 assert!(!obj.contains_key("evidence"));
955 }
956
957 #[test]
958 fn refactoring_target_actions_default_to_apply_only_without_evidence() {
959 let target = sample_target();
960 let finding = RefactoringTargetFinding::with_actions(target);
961 assert_eq!(finding.actions.len(), 1);
962 assert_eq!(
963 finding.actions[0].kind,
964 RefactoringTargetActionType::ApplyRefactoring,
965 );
966 assert_eq!(
967 finding.actions[0].category.as_deref(),
968 Some("extract_complex_functions"),
969 );
970 assert_eq!(
971 finding.actions[0].description,
972 "Extract `handleRequest` into helpers",
973 );
974 }
975
976 #[test]
977 fn refactoring_target_actions_append_suppress_when_evidence_present() {
978 let mut target = sample_target();
979 target.evidence = Some(crate::health_types::TargetEvidence {
980 unused_exports: Vec::new(),
981 complex_functions: vec![crate::health_types::EvidenceFunction {
982 name: "handleRequest".to_string(),
983 line: 12,
984 cognitive: 30,
985 }],
986 cycle_path: Vec::new(),
987 ..Default::default()
988 });
989 let finding = RefactoringTargetFinding::with_actions(target);
990 assert_eq!(finding.actions.len(), 2);
991 assert_eq!(
992 finding.actions[1].kind,
993 RefactoringTargetActionType::SuppressLine,
994 );
995 assert_eq!(
996 finding.actions[1].comment.as_deref(),
997 Some("// fallow-ignore-next-line complexity"),
998 );
999 }
1000
1001 #[test]
1002 fn codeowners_pattern_uses_deepest_directory() {
1003 assert_eq!(
1004 suggest_codeowners_pattern("src/api/users/handlers.ts"),
1005 "/src/api/users/",
1006 );
1007 }
1008
1009 #[test]
1010 fn codeowners_pattern_for_root_file() {
1011 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
1012 }
1013
1014 #[test]
1015 fn codeowners_pattern_normalizes_backslashes() {
1016 assert_eq!(
1017 suggest_codeowners_pattern("src\\api\\users.ts"),
1018 "/src/api/",
1019 );
1020 }
1021
1022 #[test]
1023 fn codeowners_pattern_two_level_path() {
1024 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1025 }
1026
1027 #[test]
1028 fn recommendation_category_snake_case_round_trips_through_serde() {
1029 let variants = [
1030 RecommendationCategory::UrgentChurnComplexity,
1031 RecommendationCategory::BreakCircularDependency,
1032 RecommendationCategory::SplitHighImpact,
1033 RecommendationCategory::RemoveDeadCode,
1034 RecommendationCategory::ExtractComplexFunctions,
1035 RecommendationCategory::ExtractDependencies,
1036 RecommendationCategory::AddTestCoverage,
1037 ];
1038 for cat in &variants {
1039 let via_serde = serde_json::to_value(cat).unwrap();
1040 let serde_str = via_serde.as_str().unwrap();
1041 assert_eq!(
1042 serde_str,
1043 category_snake_case(cat),
1044 "category_snake_case for {cat:?} drifted from serde rename_all",
1045 );
1046 }
1047 }
1048}