1use fallow_types::output_health::{
30 HealthFindingAction, HealthFindingActionType, HotspotAction, HotspotActionHeuristic,
31 HotspotActionType, RefactoringTargetAction, RefactoringTargetActionType,
32};
33use std::ops::Deref;
34use std::path::Path;
35
36use crate::health_types::scores::{
37 ComplexityViolation, CoverageTier, HotspotEntry, OwnershipState,
38};
39use crate::health_types::targets::{RecommendationCategory, RefactoringTarget};
40
41const SECONDARY_REFACTOR_BAND: u16 = 5;
51
52#[derive(Debug, Clone, Copy, Default)]
69pub struct HealthActionOptions {
70 pub omit_suppress_line: bool,
72 pub omit_reason: Option<&'static str>,
77}
78
79#[derive(Debug, Clone, Copy)]
87pub struct HealthActionContext {
88 pub opts: HealthActionOptions,
90 pub max_cyclomatic_threshold: u16,
93 pub max_cognitive_threshold: u16,
96 pub max_crap_threshold: f64,
98}
99
100#[derive(Debug, Clone, serde::Serialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub struct HealthFinding {
115 #[serde(flatten)]
117 pub violation: ComplexityViolation,
118 pub actions: Vec<HealthFindingAction>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub introduced: Option<bool>,
129}
130
131impl Deref for HealthFinding {
132 type Target = ComplexityViolation;
133
134 fn deref(&self) -> &Self::Target {
135 &self.violation
136 }
137}
138
139impl From<ComplexityViolation> for HealthFinding {
140 fn from(violation: ComplexityViolation) -> Self {
147 Self {
148 violation,
149 actions: Vec::new(),
150 introduced: None,
151 }
152 }
153}
154
155impl HealthFinding {
156 #[must_use]
162 #[allow(
163 dead_code,
164 reason = "intentional public constructor for audit / test paths that supply their own actions; with_actions is the production constructor"
165 )]
166 pub fn new(
167 violation: ComplexityViolation,
168 actions: Vec<HealthFindingAction>,
169 introduced: Option<bool>,
170 ) -> Self {
171 Self {
172 violation,
173 actions,
174 introduced,
175 }
176 }
177
178 #[must_use]
184 pub fn with_actions(violation: ComplexityViolation, ctx: &HealthActionContext) -> Self {
185 let actions = build_health_finding_actions(&violation, ctx);
186 Self {
187 violation,
188 actions,
189 introduced: None,
190 }
191 }
192}
193
194#[must_use]
216pub fn build_health_finding_actions(
217 violation: &ComplexityViolation,
218 ctx: &HealthActionContext,
219) -> Vec<HealthFindingAction> {
220 let name = violation.name.as_str();
221 let exceeded = violation.exceeded;
222 let includes_crap = exceeded.includes_crap();
223 let crap_only = matches!(exceeded, crate::health_types::ExceededThreshold::Crap);
224 let cyclomatic = violation.cyclomatic;
225 let cognitive = violation.cognitive;
226 let full_coverage_can_clear_crap =
227 !includes_crap || f64::from(cyclomatic) < ctx.max_crap_threshold;
228
229 let mut actions: Vec<HealthFindingAction> = Vec::new();
230
231 let inherited_from = violation.inherited_from.as_deref();
241 if includes_crap
242 && let Some(action) = build_crap_coverage_action(
243 name,
244 violation.coverage_tier,
245 full_coverage_can_clear_crap,
246 inherited_from,
247 )
248 {
249 actions.push(action);
250 }
251
252 let crap_only_needs_complexity_reduction = crap_only && !full_coverage_can_clear_crap;
268 let cognitive_floor = ctx.max_cognitive_threshold / 2;
269 let near_cyclomatic_threshold = crap_only
270 && cyclomatic > 0
271 && cyclomatic
272 >= ctx
273 .max_cyclomatic_threshold
274 .saturating_sub(SECONDARY_REFACTOR_BAND)
275 && cognitive >= cognitive_floor;
276 let is_template = name == "<template>";
277 let is_component = name == "<component>";
278 if !crap_only || crap_only_needs_complexity_reduction || near_cyclomatic_threshold {
279 let (description, note): (String, &str) = if is_component {
280 let rollup = violation.component_rollup.as_ref();
285 let class_name = rollup.map_or("the component", |r| r.component.as_str());
286 let worst_method = rollup.map_or("the worst class method", |r| {
287 r.class_worst_function.as_str()
288 });
289 let class_cyc = rollup.map_or(0_u16, |r| r.class_cyclomatic);
290 let template_cyc = rollup.map_or(0_u16, |r| r.template_cyclomatic);
291 (
292 format!(
293 "Refactor `{class_name}` to reduce component complexity (rolled-up cyclomatic {cyclomatic} = {class_cyc} on `{worst_method}` + {template_cyc} on the template)"
294 ),
295 "Consider splitting the template into smaller components OR extracting helpers from the worst class method; the rollup reflects the component as one complexity unit",
296 )
297 } else if is_template {
298 (
299 format!(
300 "Refactor `{name}` to reduce template complexity (simplify control flow and bindings)"
301 ),
302 "Consider splitting complex template branches into smaller components or simpler bindings",
303 )
304 } else {
305 (
306 format!(
307 "Refactor `{name}` to reduce complexity (extract helper functions, simplify branching)"
308 ),
309 "Consider splitting into smaller functions with single responsibilities",
310 )
311 };
312 actions.push(HealthFindingAction {
313 kind: HealthFindingActionType::RefactorFunction,
314 auto_fixable: false,
315 description,
316 note: Some(note.to_string()),
317 comment: None,
318 placement: None,
319 target_path: None,
320 });
321 }
322
323 if !ctx.opts.omit_suppress_line {
324 if is_template
325 && violation
326 .path
327 .extension()
328 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
329 {
330 actions.push(HealthFindingAction {
331 kind: HealthFindingActionType::SuppressFile,
332 auto_fixable: false,
333 description: "Suppress with an HTML comment at the top of the template".to_string(),
334 note: None,
335 comment: Some("<!-- fallow-ignore-file complexity -->".to_string()),
336 placement: Some("top-of-template".to_string()),
337 target_path: None,
338 });
339 } else if is_template {
340 actions.push(HealthFindingAction {
341 kind: HealthFindingActionType::SuppressLine,
342 auto_fixable: false,
343 description: "Suppress with an inline comment above the Angular decorator"
344 .to_string(),
345 note: None,
346 comment: Some("// fallow-ignore-next-line complexity".to_string()),
347 placement: Some("above-angular-decorator".to_string()),
348 target_path: None,
349 });
350 } else if is_component {
351 actions.push(HealthFindingAction {
357 kind: HealthFindingActionType::SuppressLine,
358 auto_fixable: false,
359 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(),
360 note: None,
361 comment: Some("// fallow-ignore-next-line complexity".to_string()),
362 placement: Some("above-component-worst-method".to_string()),
363 target_path: None,
364 });
365 } else {
366 actions.push(HealthFindingAction {
367 kind: HealthFindingActionType::SuppressLine,
368 auto_fixable: false,
369 description: "Suppress with an inline comment above the function declaration"
370 .to_string(),
371 note: None,
372 comment: Some("// fallow-ignore-next-line complexity".to_string()),
373 placement: Some("above-function-declaration".to_string()),
374 target_path: None,
375 });
376 }
377 }
378
379 actions
380}
381
382fn build_crap_coverage_action(
388 name: &str,
389 tier: Option<CoverageTier>,
390 full_coverage_can_clear_crap: bool,
391 inherited_from: Option<&Path>,
392) -> Option<HealthFindingAction> {
393 if !full_coverage_can_clear_crap {
394 return None;
395 }
396
397 if let Some(owner) = inherited_from {
403 let owner_str = owner.to_string_lossy().into_owned();
404 return Some(HealthFindingAction {
405 kind: HealthFindingActionType::IncreaseCoverage,
406 auto_fixable: false,
407 description: format!(
408 "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)"
409 ),
410 note: Some(
411 "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(),
412 ),
413 comment: None,
414 placement: None,
415 target_path: Some(owner_str),
416 });
417 }
418
419 match tier {
420 Some(CoverageTier::Partial | CoverageTier::High) => Some(HealthFindingAction {
425 kind: HealthFindingActionType::IncreaseCoverage,
426 auto_fixable: false,
427 description: format!(
428 "Increase test coverage for `{name}` (file is reachable from existing tests; add targeted assertions for uncovered branches)"
429 ),
430 note: Some(
431 "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(),
432 ),
433 comment: None,
434 placement: None,
435 target_path: None,
436 }),
437 _ => Some(HealthFindingAction {
439 kind: HealthFindingActionType::AddTests,
440 auto_fixable: false,
441 description: format!(
442 "Add test coverage for `{name}` to lower its CRAP score (coverage reduces risk even without refactoring)"
443 ),
444 note: Some(
445 "CRAP = CC^2 * (1 - cov/100)^3 + CC; higher coverage is the fastest way to bring CRAP under threshold".to_string(),
446 ),
447 comment: None,
448 placement: None,
449 target_path: None,
450 }),
451 }
452}
453
454#[derive(Debug, Clone, serde::Serialize)]
473#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
474pub struct HotspotFinding {
475 #[serde(flatten)]
477 pub entry: HotspotEntry,
478 pub actions: Vec<HotspotAction>,
485}
486
487impl Deref for HotspotFinding {
488 type Target = HotspotEntry;
489
490 fn deref(&self) -> &Self::Target {
491 &self.entry
492 }
493}
494
495impl From<HotspotEntry> for HotspotFinding {
496 fn from(entry: HotspotEntry) -> Self {
501 Self {
502 entry,
503 actions: Vec::new(),
504 }
505 }
506}
507
508impl HotspotFinding {
509 #[must_use]
520 pub fn with_actions(entry: HotspotEntry, root: &Path) -> Self {
521 let actions = build_hotspot_actions(&entry, root);
522 Self { entry, actions }
523 }
524}
525
526fn build_hotspot_actions(entry: &HotspotEntry, root: &Path) -> Vec<HotspotAction> {
533 let relative = entry.path.strip_prefix(root).unwrap_or(&entry.path);
534 let path = relative.to_string_lossy().replace('\\', "/");
541
542 let mut actions = vec![
543 HotspotAction {
544 kind: HotspotActionType::RefactorFile,
545 auto_fixable: false,
546 description: format!(
547 "Refactor `{path}`, high complexity combined with frequent changes makes this a maintenance risk"
548 ),
549 note: Some(
550 "Prioritize extracting complex functions, adding tests, or splitting the module"
551 .to_string(),
552 ),
553 suggested_pattern: None,
554 heuristic: None,
555 },
556 HotspotAction {
557 kind: HotspotActionType::AddTests,
558 auto_fixable: false,
559 description: format!("Add test coverage for `{path}` to reduce change risk"),
560 note: Some(
561 "Frequently changed complex files benefit most from comprehensive test coverage"
562 .to_string(),
563 ),
564 suggested_pattern: None,
565 heuristic: None,
566 },
567 ];
568
569 let Some(ownership) = entry.ownership.as_ref() else {
570 return actions;
571 };
572
573 if ownership.bus_factor == 1 {
575 let top = &ownership.top_contributor;
576 let owner = top.identifier.as_str();
577 let commits = top.commits;
578 let suggested: Vec<&str> = ownership
584 .suggested_reviewers
585 .iter()
586 .map(|r| r.identifier.as_str())
587 .collect();
588 let note = if suggested.is_empty() {
589 if commits < 5 {
590 Some(
591 "Single recent contributor on a low-commit file. Consider a pair review for major changes."
592 .to_string(),
593 )
594 } else {
595 None
597 }
598 } else {
599 let list = suggested
600 .iter()
601 .map(|s| format!("@{s}"))
602 .collect::<Vec<_>>()
603 .join(", ");
604 Some(format!("Candidate reviewers: {list}"))
605 };
606 actions.push(HotspotAction {
607 kind: HotspotActionType::LowBusFactor,
608 auto_fixable: false,
609 description: format!(
610 "{owner} is the sole recent contributor to `{path}`; adding a second reviewer reduces knowledge-loss risk"
611 ),
612 note,
613 suggested_pattern: None,
614 heuristic: None,
615 });
616 }
617
618 if ownership.unowned == Some(true) {
621 actions.push(HotspotAction {
622 kind: HotspotActionType::UnownedHotspot,
623 auto_fixable: false,
624 description: format!("Add a CODEOWNERS entry for `{path}`"),
625 note: Some(
626 "Frequently-changed files without declared owners create review bottlenecks"
627 .to_string(),
628 ),
629 suggested_pattern: Some(suggest_codeowners_pattern(&path)),
630 heuristic: Some(HotspotActionHeuristic::DirectoryDeepest),
631 });
632 }
633
634 if ownership.ownership_state == OwnershipState::Drifting && ownership.drift {
637 let reason = ownership
638 .drift_reason
639 .as_deref()
640 .unwrap_or("ownership has shifted from the original author");
641 actions.push(HotspotAction {
642 kind: HotspotActionType::OwnershipDrift,
643 auto_fixable: false,
644 description: format!("Update CODEOWNERS for `{path}`: {reason}"),
645 note: Some(
646 "Drift suggests the declared or original owner is no longer the right reviewer"
647 .to_string(),
648 ),
649 suggested_pattern: None,
650 heuristic: None,
651 });
652 }
653
654 actions
655}
656
657fn suggest_codeowners_pattern(path: &str) -> String {
671 let normalized = path.replace('\\', "/");
672 let trimmed = normalized.trim_start_matches('/');
673 let mut components: Vec<&str> = trimmed.split('/').collect();
674 components.pop(); if components.is_empty() {
676 return format!("/{trimmed}");
677 }
678 format!("/{}/", components.join("/"))
679}
680
681#[derive(Debug, Clone, serde::Serialize)]
698#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
699pub struct RefactoringTargetFinding {
700 #[serde(flatten)]
702 pub target: RefactoringTarget,
703 pub actions: Vec<RefactoringTargetAction>,
709}
710
711impl Deref for RefactoringTargetFinding {
712 type Target = RefactoringTarget;
713
714 fn deref(&self) -> &Self::Target {
715 &self.target
716 }
717}
718
719impl From<RefactoringTarget> for RefactoringTargetFinding {
720 fn from(target: RefactoringTarget) -> Self {
725 Self {
726 target,
727 actions: Vec::new(),
728 }
729 }
730}
731
732impl RefactoringTargetFinding {
733 #[must_use]
744 pub fn with_actions(target: RefactoringTarget) -> Self {
745 let actions = build_refactoring_target_actions(&target);
746 Self { target, actions }
747 }
748}
749
750fn build_refactoring_target_actions(target: &RefactoringTarget) -> Vec<RefactoringTargetAction> {
756 let mut actions = vec![RefactoringTargetAction {
757 kind: RefactoringTargetActionType::ApplyRefactoring,
758 auto_fixable: false,
759 description: target.recommendation.clone(),
760 category: Some(category_snake_case(&target.category).to_string()),
761 comment: None,
762 }];
763
764 if target.evidence.is_some() {
765 actions.push(RefactoringTargetAction {
766 kind: RefactoringTargetActionType::SuppressLine,
767 auto_fixable: false,
768 description: "Suppress the underlying complexity finding".to_string(),
769 category: None,
770 comment: Some("// fallow-ignore-next-line complexity".to_string()),
771 });
772 }
773
774 actions
775}
776
777const fn category_snake_case(cat: &RecommendationCategory) -> &'static str {
791 match cat {
792 RecommendationCategory::UrgentChurnComplexity => "urgent_churn_complexity",
793 RecommendationCategory::BreakCircularDependency => "break_circular_dependency",
794 RecommendationCategory::SplitHighImpact => "split_high_impact",
795 RecommendationCategory::RemoveDeadCode => "remove_dead_code",
796 RecommendationCategory::ExtractComplexFunctions => "extract_complex_functions",
797 RecommendationCategory::ExtractDependencies => "extract_dependencies",
798 RecommendationCategory::AddTestCoverage => "add_test_coverage",
799 }
800}
801
802#[cfg(test)]
803mod hotspot_target_tests {
804 use super::*;
805 use crate::health_types::scores::{
806 ContributorEntry, ContributorIdentifierFormat, OwnershipMetrics, OwnershipState,
807 };
808 use fallow_core::churn::ChurnTrend;
809 use std::path::PathBuf;
810
811 fn sample_entry(path: &str) -> HotspotEntry {
812 HotspotEntry {
813 path: PathBuf::from(path),
814 score: 80.0,
815 commits: 12,
816 weighted_commits: 8.0,
817 lines_added: 100,
818 lines_deleted: 40,
819 complexity_density: 1.5,
820 fan_in: 3,
821 trend: ChurnTrend::Stable,
822 ownership: None,
823 is_test_path: false,
824 }
825 }
826
827 fn contributor(identifier: &str, commits: u32) -> ContributorEntry {
828 ContributorEntry {
829 identifier: identifier.to_string(),
830 format: ContributorIdentifierFormat::Handle,
831 share: 1.0,
832 stale_days: 1,
833 commits,
834 }
835 }
836
837 fn sample_target() -> RefactoringTarget {
838 RefactoringTarget {
839 path: PathBuf::from("/root/src/foo.ts"),
840 priority: 75.0,
841 efficiency: 75.0,
842 recommendation: "Extract `handleRequest` into helpers".to_string(),
843 category: RecommendationCategory::ExtractComplexFunctions,
844 effort: crate::health_types::EffortEstimate::Low,
845 confidence: crate::health_types::Confidence::High,
846 factors: Vec::new(),
847 evidence: None,
848 }
849 }
850
851 #[test]
852 fn hotspot_finding_flattens_inner_fields_at_top_level() {
853 let entry = sample_entry("/root/src/api.ts");
854 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
855 let json = serde_json::to_value(&finding).unwrap();
856 let obj = json.as_object().unwrap();
857 assert!(obj.contains_key("score"));
859 assert!(obj.contains_key("commits"));
860 assert!(obj.contains_key("weighted_commits"));
861 assert!(obj.contains_key("actions"));
863 assert!(!obj.contains_key("ownership"));
865 assert!(!obj.contains_key("is_test_path"));
866 }
867
868 #[test]
869 fn hotspot_actions_default_pair_when_ownership_absent() {
870 let entry = sample_entry("/root/src/api.ts");
871 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
872 assert_eq!(finding.actions.len(), 2);
873 assert_eq!(finding.actions[0].kind, HotspotActionType::RefactorFile);
874 assert_eq!(finding.actions[1].kind, HotspotActionType::AddTests);
875 assert!(finding.actions[0].description.contains("src/api.ts"));
876 }
877
878 #[test]
879 fn hotspot_low_bus_factor_with_suggested_reviewers_lists_them() {
880 let mut entry = sample_entry("/root/src/api.ts");
881 entry.ownership = Some(OwnershipMetrics {
882 bus_factor: 1,
883 contributor_count: 1,
884 top_contributor: contributor("alice", 30),
885 recent_contributors: Vec::new(),
886 suggested_reviewers: vec![contributor("bob", 4), contributor("carol", 2)],
887 declared_owner: None,
888 unowned: None,
889 ownership_state: OwnershipState::Active,
890 drift: false,
891 drift_reason: None,
892 });
893 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
894 let low_bus = finding
895 .actions
896 .iter()
897 .find(|a| a.kind == HotspotActionType::LowBusFactor)
898 .expect("low-bus-factor action present");
899 assert_eq!(
900 low_bus.note.as_deref(),
901 Some("Candidate reviewers: @bob, @carol"),
902 );
903 }
904
905 #[test]
906 fn hotspot_low_bus_factor_softens_for_low_commit_files() {
907 let mut entry = sample_entry("/root/src/api.ts");
908 entry.ownership = Some(OwnershipMetrics {
909 bus_factor: 1,
910 contributor_count: 1,
911 top_contributor: contributor("alice", 3),
912 recent_contributors: Vec::new(),
913 suggested_reviewers: Vec::new(),
914 declared_owner: None,
915 unowned: None,
916 ownership_state: OwnershipState::Active,
917 drift: false,
918 drift_reason: None,
919 });
920 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
921 let low_bus = finding
922 .actions
923 .iter()
924 .find(|a| a.kind == HotspotActionType::LowBusFactor)
925 .expect("low-bus-factor action present");
926 assert_eq!(
927 low_bus.note.as_deref(),
928 Some(
929 "Single recent contributor on a low-commit file. Consider a pair review for major changes.",
930 ),
931 );
932 }
933
934 #[test]
935 fn hotspot_low_bus_factor_omits_note_for_high_commit_no_reviewers() {
936 let mut entry = sample_entry("/root/src/api.ts");
937 entry.ownership = Some(OwnershipMetrics {
938 bus_factor: 1,
939 contributor_count: 1,
940 top_contributor: contributor("alice", 50),
941 recent_contributors: Vec::new(),
942 suggested_reviewers: Vec::new(),
943 declared_owner: None,
944 unowned: None,
945 ownership_state: OwnershipState::Active,
946 drift: false,
947 drift_reason: None,
948 });
949 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
950 let low_bus = finding
951 .actions
952 .iter()
953 .find(|a| a.kind == HotspotActionType::LowBusFactor)
954 .expect("low-bus-factor action present");
955 assert!(low_bus.note.is_none());
956 }
957
958 #[test]
959 fn hotspot_unowned_action_carries_deepest_directory_pattern() {
960 let mut entry = sample_entry("/root/src/api/users/handlers.ts");
961 entry.ownership = Some(OwnershipMetrics {
962 bus_factor: 2,
963 contributor_count: 3,
964 top_contributor: contributor("alice", 10),
965 recent_contributors: Vec::new(),
966 suggested_reviewers: Vec::new(),
967 declared_owner: None,
968 unowned: Some(true),
969 ownership_state: OwnershipState::Unowned,
970 drift: false,
971 drift_reason: None,
972 });
973 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
974 let unowned = finding
975 .actions
976 .iter()
977 .find(|a| a.kind == HotspotActionType::UnownedHotspot)
978 .expect("unowned-hotspot action present");
979 assert_eq!(
980 unowned.suggested_pattern.as_deref(),
981 Some("/src/api/users/")
982 );
983 assert_eq!(
984 unowned.heuristic,
985 Some(HotspotActionHeuristic::DirectoryDeepest)
986 );
987 }
988
989 #[test]
990 fn hotspot_action_descriptions_normalise_windows_separators() {
991 let mut entry = sample_entry("src\\api\\users.ts");
1003 entry.ownership = Some(OwnershipMetrics {
1004 bus_factor: 2,
1005 contributor_count: 3,
1006 top_contributor: contributor("alice", 10),
1007 recent_contributors: Vec::new(),
1008 suggested_reviewers: Vec::new(),
1009 declared_owner: None,
1010 unowned: Some(true),
1011 ownership_state: OwnershipState::Unowned,
1012 drift: false,
1013 drift_reason: None,
1014 });
1015 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
1016 let refactor = finding
1017 .actions
1018 .iter()
1019 .find(|a| a.kind == HotspotActionType::RefactorFile)
1020 .expect("refactor-file action present");
1021 assert!(refactor.description.contains("src/api/users.ts"));
1022 assert!(!refactor.description.contains('\\'));
1023 let unowned = finding
1024 .actions
1025 .iter()
1026 .find(|a| a.kind == HotspotActionType::UnownedHotspot)
1027 .expect("unowned-hotspot action present");
1028 assert_eq!(unowned.suggested_pattern.as_deref(), Some("/src/api/"));
1029 }
1030
1031 #[test]
1032 fn hotspot_drift_action_uses_provided_reason() {
1033 let mut entry = sample_entry("/root/src/api.ts");
1034 entry.ownership = Some(OwnershipMetrics {
1035 bus_factor: 2,
1036 contributor_count: 4,
1037 top_contributor: contributor("alice", 10),
1038 recent_contributors: Vec::new(),
1039 suggested_reviewers: Vec::new(),
1040 declared_owner: None,
1041 unowned: Some(false),
1042 ownership_state: OwnershipState::Drifting,
1043 drift: true,
1044 drift_reason: Some("top contributor changed in last 6 months".to_string()),
1045 });
1046 let finding = HotspotFinding::with_actions(entry, Path::new("/root"));
1047 let drift = finding
1048 .actions
1049 .iter()
1050 .find(|a| a.kind == HotspotActionType::OwnershipDrift)
1051 .expect("ownership-drift action present");
1052 assert!(
1053 drift
1054 .description
1055 .contains("top contributor changed in last 6 months"),
1056 );
1057 }
1058
1059 #[test]
1060 fn refactoring_target_finding_flattens_inner_fields_at_top_level() {
1061 let target = sample_target();
1062 let finding = RefactoringTargetFinding::with_actions(target);
1063 let json = serde_json::to_value(&finding).unwrap();
1064 let obj = json.as_object().unwrap();
1065 assert!(obj.contains_key("priority"));
1067 assert!(obj.contains_key("efficiency"));
1068 assert!(obj.contains_key("recommendation"));
1069 assert!(obj.contains_key("category"));
1070 assert!(obj.contains_key("actions"));
1072 assert!(!obj.contains_key("factors"));
1074 assert!(!obj.contains_key("evidence"));
1075 }
1076
1077 #[test]
1078 fn refactoring_target_actions_default_to_apply_only_without_evidence() {
1079 let target = sample_target();
1080 let finding = RefactoringTargetFinding::with_actions(target);
1081 assert_eq!(finding.actions.len(), 1);
1082 assert_eq!(
1083 finding.actions[0].kind,
1084 RefactoringTargetActionType::ApplyRefactoring,
1085 );
1086 assert_eq!(
1087 finding.actions[0].category.as_deref(),
1088 Some("extract_complex_functions"),
1089 );
1090 assert_eq!(
1091 finding.actions[0].description,
1092 "Extract `handleRequest` into helpers",
1093 );
1094 }
1095
1096 #[test]
1097 fn refactoring_target_actions_append_suppress_when_evidence_present() {
1098 let mut target = sample_target();
1099 target.evidence = Some(crate::health_types::TargetEvidence {
1100 unused_exports: Vec::new(),
1101 complex_functions: vec![crate::health_types::EvidenceFunction {
1102 name: "handleRequest".to_string(),
1103 line: 12,
1104 cognitive: 30,
1105 }],
1106 cycle_path: Vec::new(),
1107 });
1108 let finding = RefactoringTargetFinding::with_actions(target);
1109 assert_eq!(finding.actions.len(), 2);
1110 assert_eq!(
1111 finding.actions[1].kind,
1112 RefactoringTargetActionType::SuppressLine,
1113 );
1114 assert_eq!(
1115 finding.actions[1].comment.as_deref(),
1116 Some("// fallow-ignore-next-line complexity"),
1117 );
1118 }
1119
1120 #[test]
1121 fn codeowners_pattern_uses_deepest_directory() {
1122 assert_eq!(
1125 suggest_codeowners_pattern("src/api/users/handlers.ts"),
1126 "/src/api/users/",
1127 );
1128 }
1129
1130 #[test]
1131 fn codeowners_pattern_for_root_file() {
1132 assert_eq!(suggest_codeowners_pattern("README.md"), "/README.md");
1133 }
1134
1135 #[test]
1136 fn codeowners_pattern_normalizes_backslashes() {
1137 assert_eq!(
1138 suggest_codeowners_pattern("src\\api\\users.ts"),
1139 "/src/api/",
1140 );
1141 }
1142
1143 #[test]
1144 fn codeowners_pattern_two_level_path() {
1145 assert_eq!(suggest_codeowners_pattern("src/foo.ts"), "/src/");
1146 }
1147
1148 #[test]
1149 fn recommendation_category_snake_case_round_trips_through_serde() {
1150 let variants = [
1157 RecommendationCategory::UrgentChurnComplexity,
1158 RecommendationCategory::BreakCircularDependency,
1159 RecommendationCategory::SplitHighImpact,
1160 RecommendationCategory::RemoveDeadCode,
1161 RecommendationCategory::ExtractComplexFunctions,
1162 RecommendationCategory::ExtractDependencies,
1163 RecommendationCategory::AddTestCoverage,
1164 ];
1165 for cat in &variants {
1166 let via_serde = serde_json::to_value(cat).unwrap();
1167 let serde_str = via_serde.as_str().unwrap();
1168 assert_eq!(
1169 serde_str,
1170 category_snake_case(cat),
1171 "category_snake_case for {cat:?} drifted from serde rename_all",
1172 );
1173 }
1174 }
1175}