1use serde::{Deserialize, Serialize};
5
6use crate::domain::types::{
7 ComplexityContributor, ContributorKind, FunctionVerdict, LineCoverage, SourceSpan,
8};
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[non_exhaustive]
17pub struct LineRange {
18 pub start: usize,
19 pub end: usize,
20}
21
22impl LineRange {
23 pub fn new(start: usize, end: usize) -> Self {
24 Self { start, end }
25 }
26
27 pub fn contains(&self, line: usize) -> bool {
29 self.start <= line && line <= self.end
30 }
31}
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41#[non_exhaustive]
42pub enum RootCause {
43 #[default]
44 LowCoverage,
45 HighComplexity,
46 Both,
47}
48
49impl RootCause {
50 pub fn as_wire_str(&self) -> &'static str {
55 match self {
56 Self::LowCoverage => "low_coverage",
57 Self::HighComplexity => "high_complexity",
58 Self::Both => "both",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum Applicability {
73 MachineApplicable,
74 MaybeIncorrect,
75 HasPlaceholders,
76 #[default]
77 Unspecified,
78}
79
80impl Applicability {
81 pub fn as_wire_str(&self) -> &'static str {
83 match self {
84 Self::MachineApplicable => "machine_applicable",
85 Self::MaybeIncorrect => "maybe_incorrect",
86 Self::HasPlaceholders => "has_placeholders",
87 Self::Unspecified => "unspecified",
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101#[non_exhaustive]
102pub enum SplitKind {
103 #[default]
104 DeepestNesting,
105 LargestSubblock,
106 HighestBranchCount,
107}
108
109impl SplitKind {
110 pub fn as_wire_str(&self) -> &'static str {
112 match self {
113 Self::DeepestNesting => "deepest_nesting",
114 Self::LargestSubblock => "largest_subblock",
115 Self::HighestBranchCount => "highest_branch_count",
116 }
117 }
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[non_exhaustive]
126pub struct ProposedSplit {
127 pub line_range: LineRange,
128 pub complexity_contribution: u32,
131 pub branch_path: String,
134 pub kind: SplitKind,
135 pub recommended: bool,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "kind", rename_all = "snake_case")]
146#[non_exhaustive]
147pub enum SuggestedAction {
148 AddTestsForLines {
149 lines: Vec<LineRange>,
150 applicability: Applicability,
151 },
152 ExtractFunction {
153 candidates: Vec<ProposedSplit>,
154 applicability: Applicability,
155 },
156 SimplifyBranching {
157 drivers: Vec<ContributorKind>,
158 applicability: Applicability,
159 },
160 AcceptInherentComplexity {
161 applicability: Applicability,
162 },
163}
164
165#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(default)]
173#[non_exhaustive]
174pub struct Diagnostic {
175 pub coverage_gaps: Vec<LineRange>,
176 pub complexity_drivers: Vec<ComplexityContributor>,
177 pub suggested_actions: Vec<SuggestedAction>,
178 pub root_cause: RootCause,
179}
180
181fn effective_end_line(c: &ComplexityContributor) -> usize {
192 if c.end_line >= c.line {
193 c.end_line
194 } else {
195 c.line
196 }
197}
198
199fn is_compound(c: &ComplexityContributor) -> bool {
201 effective_end_line(c) > c.line
202}
203
204fn sum_increments_in(contributors: &[ComplexityContributor], range: LineRange) -> u32 {
207 contributors
208 .iter()
209 .filter(|c| range.contains(c.line))
210 .map(|c| c.increment)
211 .sum()
212}
213
214fn count_inner_contributors(
218 contributors: &[ComplexityContributor],
219 outer: &ComplexityContributor,
220) -> usize {
221 let outer_range = LineRange::new(outer.line, effective_end_line(outer));
222 contributors
223 .iter()
224 .filter(|c| {
225 !(c.line == outer.line && c.kind == outer.kind && c.column == outer.column)
227 && outer_range.contains(c.line)
228 })
229 .count()
230}
231
232fn is_viable_split(range: LineRange, span: &SourceSpan, contribution: u32) -> bool {
236 range.start < range.end
237 && range.start >= span.start_line
238 && range.end <= span.end_line
239 && !(range.start == span.start_line && range.end == span.end_line)
240 && contribution >= 1
241}
242
243pub(crate) fn derive_branch_path(
249 contributors: &[ComplexityContributor],
250 start_line: usize,
251) -> String {
252 let mut enclosing: Vec<&ComplexityContributor> = contributors
253 .iter()
254 .filter(|c| c.line < start_line && start_line <= effective_end_line(c))
255 .collect();
256 enclosing.sort_by_key(|c| (c.nesting_depth, c.line));
257 enclosing
258 .iter()
259 .map(|c| c.kind.to_string())
260 .collect::<Vec<_>>()
261 .join("/")
262}
263
264pub(crate) fn derive_coverage_gaps(
268 line_coverage: &[LineCoverage],
269 span: &SourceSpan,
270) -> Vec<LineRange> {
271 let mut uncovered: Vec<usize> = line_coverage
272 .iter()
273 .filter(|lc| lc.hits == 0 && lc.line >= span.start_line && lc.line <= span.end_line)
274 .map(|lc| lc.line)
275 .collect();
276 uncovered.sort_unstable();
277 uncovered.dedup();
278
279 let mut gaps: Vec<LineRange> = Vec::new();
280 for line in uncovered {
281 match gaps.last_mut() {
282 Some(last) if last.end + 1 == line => last.end = line,
283 _ => gaps.push(LineRange::new(line, line)),
284 }
285 }
286 gaps
287}
288
289pub(crate) fn pick_deepest_nesting(
292 contributors: &[ComplexityContributor],
293 span: &SourceSpan,
294) -> Option<ProposedSplit> {
295 let pick = contributors
296 .iter()
297 .filter(|c| is_compound(c) && c.nesting_depth > 0)
298 .max_by_key(|c| (c.nesting_depth, std::cmp::Reverse(c.line)))?;
299 let range = LineRange::new(pick.line, effective_end_line(pick));
300 let contribution = sum_increments_in(contributors, range);
301 if !is_viable_split(range, span, contribution) {
302 return None;
303 }
304 Some(ProposedSplit {
305 line_range: range,
306 complexity_contribution: contribution,
307 branch_path: derive_branch_path(contributors, range.start),
308 kind: SplitKind::DeepestNesting,
309 recommended: false,
310 })
311}
312
313pub(crate) fn pick_largest_subblock(
316 contributors: &[ComplexityContributor],
317 span: &SourceSpan,
318) -> Option<ProposedSplit> {
319 let pick = contributors
320 .iter()
321 .filter(|c| is_compound(c))
322 .max_by_key(|c| {
323 let span_len = effective_end_line(c) - c.line;
324 (span_len, std::cmp::Reverse(c.line))
325 })?;
326 let range = LineRange::new(pick.line, effective_end_line(pick));
327 let contribution = sum_increments_in(contributors, range);
328 if !is_viable_split(range, span, contribution) {
329 return None;
330 }
331 Some(ProposedSplit {
332 line_range: range,
333 complexity_contribution: contribution,
334 branch_path: derive_branch_path(contributors, range.start),
335 kind: SplitKind::LargestSubblock,
336 recommended: false,
337 })
338}
339
340pub(crate) fn pick_highest_branch_count(
343 contributors: &[ComplexityContributor],
344 span: &SourceSpan,
345) -> Option<ProposedSplit> {
346 let pick = contributors
347 .iter()
348 .filter(|c| is_compound(c))
349 .max_by_key(|c| {
350 let count = count_inner_contributors(contributors, c);
351 (count, std::cmp::Reverse(c.line))
352 })?;
353 if count_inner_contributors(contributors, pick) == 0 {
356 return None;
357 }
358 let range = LineRange::new(pick.line, effective_end_line(pick));
359 let contribution = sum_increments_in(contributors, range);
360 if !is_viable_split(range, span, contribution) {
361 return None;
362 }
363 Some(ProposedSplit {
364 line_range: range,
365 complexity_contribution: contribution,
366 branch_path: derive_branch_path(contributors, range.start),
367 kind: SplitKind::HighestBranchCount,
368 recommended: false,
369 })
370}
371
372pub(crate) fn extract_split_candidates(
375 contributors: &[ComplexityContributor],
376 span: &SourceSpan,
377) -> Vec<ProposedSplit> {
378 let raw: Vec<ProposedSplit> = [
379 pick_deepest_nesting(contributors, span),
380 pick_highest_branch_count(contributors, span),
381 pick_largest_subblock(contributors, span),
382 ]
383 .into_iter()
384 .flatten()
385 .collect();
386 dedup_splits(raw)
387}
388
389fn split_kind_priority(kind: SplitKind) -> u8 {
391 match kind {
392 SplitKind::DeepestNesting => 3,
393 SplitKind::HighestBranchCount => 2,
394 SplitKind::LargestSubblock => 1,
395 }
396}
397
398pub(crate) fn dedup_splits(splits: Vec<ProposedSplit>) -> Vec<ProposedSplit> {
404 let mut by_range: Vec<ProposedSplit> = Vec::new();
405 for split in splits {
406 match by_range
407 .iter()
408 .position(|existing| existing.line_range == split.line_range)
409 {
410 Some(idx) => {
411 let new_priority = split_kind_priority(split.kind);
412 let existing_priority = split_kind_priority(by_range[idx].kind);
413 if new_priority > existing_priority {
414 by_range[idx] = split;
415 }
416 }
417 None => by_range.push(split),
418 }
419 }
420
421 by_range.sort_by_key(|s| {
422 (
423 std::cmp::Reverse(split_kind_priority(s.kind)),
424 s.line_range.start,
425 )
426 });
427
428 if let Some(first) = by_range.first_mut() {
429 first.recommended = true;
430 }
431 by_range
432}
433
434fn dominant_contributor_kind(contributors: &[ComplexityContributor]) -> Option<ContributorKind> {
441 if contributors.is_empty() {
442 return None;
443 }
444 let total = contributors.len();
445 let mut counts: Vec<(ContributorKind, usize)> = Vec::new();
446 for c in contributors {
447 match counts.iter_mut().find(|(k, _)| *k == c.kind) {
448 Some((_, n)) => *n += 1,
449 None => counts.push((c.kind, 1)),
450 }
451 }
452 counts
453 .into_iter()
454 .max_by_key(|(_, count)| *count)
455 .filter(|(_, count)| *count * 100 > total * 70)
456 .map(|(kind, _)| kind)
457}
458
459pub(crate) fn pick_actions(
467 coverage_gaps: &[LineRange],
468 splits: &[ProposedSplit],
469 contributors: &[ComplexityContributor],
470) -> (Vec<SuggestedAction>, RootCause) {
471 let mut actions: Vec<SuggestedAction> = Vec::new();
472
473 if !coverage_gaps.is_empty() {
474 actions.push(SuggestedAction::AddTestsForLines {
475 lines: coverage_gaps.to_vec(),
476 applicability: Applicability::default(),
477 });
478 }
479
480 if !splits.is_empty() {
481 actions.push(SuggestedAction::ExtractFunction {
482 candidates: splits.to_vec(),
483 applicability: Applicability::default(),
484 });
485 } else if let Some(dominant) = dominant_contributor_kind(contributors) {
486 actions.push(SuggestedAction::SimplifyBranching {
487 drivers: vec![dominant],
488 applicability: Applicability::default(),
489 });
490 }
491
492 let has_complexity_action = actions
493 .iter()
494 .any(|a| !matches!(a, SuggestedAction::AddTestsForLines { .. }));
495
496 if !has_complexity_action && coverage_gaps.is_empty() {
497 actions.push(SuggestedAction::AcceptInherentComplexity {
498 applicability: Applicability::default(),
499 });
500 }
501
502 let has_add_tests = !coverage_gaps.is_empty();
503 let has_complexity = actions
504 .iter()
505 .any(|a| !matches!(a, SuggestedAction::AddTestsForLines { .. }));
506
507 let root_cause = match (has_add_tests, has_complexity) {
508 (true, true) => RootCause::Both,
509 (true, false) => RootCause::LowCoverage,
510 (false, _) => RootCause::HighComplexity,
511 };
512
513 (actions, root_cause)
514}
515
516pub fn compute_diagnostic(
527 verdict: &FunctionVerdict,
528 line_coverage: &[LineCoverage],
529) -> Option<Diagnostic> {
530 if !verdict.exceeds {
531 return None;
532 }
533
534 let span = &verdict.scored.identity.span;
535 let contributors = verdict.scored.contributors.as_slice();
536
537 let coverage_gaps = derive_coverage_gaps(line_coverage, span);
538 let splits = extract_split_candidates(contributors, span);
539 let (suggested_actions, root_cause) = pick_actions(&coverage_gaps, &splits, contributors);
540
541 Some(Diagnostic {
542 coverage_gaps,
543 complexity_drivers: contributors.to_vec(),
544 suggested_actions,
545 root_cause,
546 })
547}
548
549#[cfg(test)]
552mod tests {
553 use super::*;
554
555 #[test]
556 fn line_range_contains_inclusive_endpoints() {
557 let r = LineRange::new(10, 12);
558 assert!(r.contains(10));
559 assert!(r.contains(11));
560 assert!(r.contains(12));
561 assert!(!r.contains(9));
562 assert!(!r.contains(13));
563 }
564
565 #[test]
566 fn diagnostic_default_is_low_coverage_and_empty_vecs() {
567 let d = Diagnostic::default();
568 assert_eq!(d.root_cause, RootCause::LowCoverage);
569 assert!(d.coverage_gaps.is_empty());
570 assert!(d.complexity_drivers.is_empty());
571 assert!(d.suggested_actions.is_empty());
572 }
573
574 #[test]
575 fn diagnostic_deserializes_empty_object_to_default() {
576 let parsed: Diagnostic = serde_json::from_str("{}").unwrap();
580 assert_eq!(parsed, Diagnostic::default());
581 }
582
583 #[test]
584 fn root_cause_serializes_snake_case() {
585 assert_eq!(
586 serde_json::to_string(&RootCause::LowCoverage).unwrap(),
587 "\"low_coverage\""
588 );
589 assert_eq!(
590 serde_json::to_string(&RootCause::HighComplexity).unwrap(),
591 "\"high_complexity\""
592 );
593 assert_eq!(serde_json::to_string(&RootCause::Both).unwrap(), "\"both\"");
594 }
595
596 #[test]
597 fn applicability_default_is_unspecified() {
598 assert_eq!(Applicability::default(), Applicability::Unspecified);
599 assert_eq!(
600 serde_json::to_string(&Applicability::default()).unwrap(),
601 "\"unspecified\""
602 );
603 }
604
605 #[test]
606 fn applicability_round_trips_all_variants() {
607 for variant in [
608 Applicability::MachineApplicable,
609 Applicability::MaybeIncorrect,
610 Applicability::HasPlaceholders,
611 Applicability::Unspecified,
612 ] {
613 let json = serde_json::to_string(&variant).unwrap();
614 let parsed: Applicability = serde_json::from_str(&json).unwrap();
615 assert_eq!(parsed, variant);
616 }
617 }
618
619 #[test]
620 fn split_kind_default_is_deepest_nesting() {
621 assert_eq!(SplitKind::default(), SplitKind::DeepestNesting);
622 }
623
624 #[test]
625 fn split_kind_round_trips_all_variants() {
626 for variant in [
627 SplitKind::DeepestNesting,
628 SplitKind::LargestSubblock,
629 SplitKind::HighestBranchCount,
630 ] {
631 let json = serde_json::to_string(&variant).unwrap();
632 let parsed: SplitKind = serde_json::from_str(&json).unwrap();
633 assert_eq!(parsed, variant);
634 }
635 }
636
637 #[test]
638 fn proposed_split_round_trips() {
639 let original = ProposedSplit {
640 line_range: LineRange::new(20, 35),
641 complexity_contribution: 7,
642 branch_path: "if-branch/match".to_string(),
643 kind: SplitKind::DeepestNesting,
644 recommended: true,
645 };
646 let json = serde_json::to_string(&original).unwrap();
647 let parsed: ProposedSplit = serde_json::from_str(&json).unwrap();
648 assert_eq!(parsed, original);
649 }
650
651 #[test]
652 fn suggested_action_serializes_with_kind_tag_add_tests() {
653 let action = SuggestedAction::AddTestsForLines {
654 lines: vec![LineRange::new(1, 5)],
655 applicability: Applicability::Unspecified,
656 };
657 let value: serde_json::Value = serde_json::to_value(&action).unwrap();
658 assert_eq!(value["kind"], "add_tests_for_lines");
659 assert!(value["lines"].is_array());
660 assert_eq!(value["applicability"], "unspecified");
661 }
662
663 #[test]
664 fn suggested_action_serializes_with_kind_tag_extract_function() {
665 let action = SuggestedAction::ExtractFunction {
666 candidates: vec![ProposedSplit {
667 line_range: LineRange::new(10, 20),
668 complexity_contribution: 4,
669 branch_path: "if-branch".to_string(),
670 kind: SplitKind::HighestBranchCount,
671 recommended: true,
672 }],
673 applicability: Applicability::Unspecified,
674 };
675 let value: serde_json::Value = serde_json::to_value(&action).unwrap();
676 assert_eq!(value["kind"], "extract_function");
677 assert!(value["candidates"].is_array());
678 }
679
680 #[test]
681 fn suggested_action_serializes_with_kind_tag_simplify_branching() {
682 let action = SuggestedAction::SimplifyBranching {
683 drivers: vec![ContributorKind::Match],
684 applicability: Applicability::Unspecified,
685 };
686 let value: serde_json::Value = serde_json::to_value(&action).unwrap();
687 assert_eq!(value["kind"], "simplify_branching");
688 assert_eq!(value["drivers"][0], "match");
689 }
690
691 #[test]
692 fn suggested_action_serializes_with_kind_tag_accept_inherent() {
693 let action = SuggestedAction::AcceptInherentComplexity {
694 applicability: Applicability::Unspecified,
695 };
696 let value: serde_json::Value = serde_json::to_value(&action).unwrap();
697 assert_eq!(value["kind"], "accept_inherent_complexity");
698 }
699
700 #[test]
701 fn suggested_action_round_trips_through_json() {
702 let actions = vec![
704 SuggestedAction::AddTestsForLines {
705 lines: vec![LineRange::new(3, 7)],
706 applicability: Applicability::MachineApplicable,
707 },
708 SuggestedAction::ExtractFunction {
709 candidates: vec![],
710 applicability: Applicability::MaybeIncorrect,
711 },
712 SuggestedAction::SimplifyBranching {
713 drivers: vec![ContributorKind::IfBranch, ContributorKind::Match],
714 applicability: Applicability::HasPlaceholders,
715 },
716 SuggestedAction::AcceptInherentComplexity {
717 applicability: Applicability::Unspecified,
718 },
719 ];
720 for original in actions {
721 let json = serde_json::to_string(&original).unwrap();
722 let parsed: SuggestedAction = serde_json::from_str(&json).unwrap();
723 assert_eq!(parsed, original);
724 }
725 }
726
727 #[test]
728 fn diagnostic_round_trips_full_shape() {
729 let original = Diagnostic {
730 coverage_gaps: vec![LineRange::new(12, 14)],
731 complexity_drivers: vec![ComplexityContributor {
732 kind: ContributorKind::Match,
733 line: 20,
734 column: Some(4),
735 increment: 2,
736 end_line: 30,
737 nesting_depth: 1,
738 }],
739 suggested_actions: vec![SuggestedAction::AcceptInherentComplexity {
740 applicability: Applicability::Unspecified,
741 }],
742 root_cause: RootCause::HighComplexity,
743 };
744 let json = serde_json::to_string(&original).unwrap();
745 let parsed: Diagnostic = serde_json::from_str(&json).unwrap();
746 assert_eq!(parsed, original);
747 }
748
749 use crate::domain::types::{
752 ComplexityMetric, CrapScore, FunctionIdentity, FunctionVerdict, LineCoverage, RiskLevel,
753 ScoredFunction, SourceSpan,
754 };
755
756 fn make_contributor(
757 kind: ContributorKind,
758 line: usize,
759 end_line: usize,
760 increment: u32,
761 nesting_depth: u32,
762 ) -> ComplexityContributor {
763 ComplexityContributor {
764 kind,
765 line,
766 column: None,
767 increment,
768 end_line,
769 nesting_depth,
770 }
771 }
772
773 fn span_for(start: usize, end: usize) -> SourceSpan {
774 SourceSpan {
775 start_line: start,
776 end_line: end,
777 start_column: 0,
778 end_column: 0,
779 }
780 }
781
782 fn make_verdict(
783 contributors: Vec<ComplexityContributor>,
784 span: SourceSpan,
785 exceeds: bool,
786 ) -> FunctionVerdict {
787 FunctionVerdict {
788 scored: ScoredFunction {
789 identity: FunctionIdentity {
790 file_path: "src/lib.rs".to_string(),
791 qualified_name: "demo".to_string(),
792 span,
793 },
794 complexity: contributors.iter().map(|c| c.increment).sum::<u32>().max(1),
795 complexity_metric: ComplexityMetric::Cognitive,
796 coverage_percent: 100.0,
797 branch_coverage_percent: None,
798 crap: CrapScore {
799 value: 50.0,
800 risk_level: RiskLevel::High,
801 },
802 contributors,
803 },
804 threshold: 30.0,
805 exceeds,
806 diagnostic: None,
807 }
808 }
809
810 #[test]
813 fn derive_branch_path_empty_for_top_level_construct() {
814 let contributors = vec![make_contributor(ContributorKind::IfBranch, 9, 13, 1, 0)];
817 assert_eq!(derive_branch_path(&contributors, 9), "");
818 }
819
820 #[test]
821 fn derive_branch_path_single_for_nested_if_inner_start() {
822 let contributors = vec![
826 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
827 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
828 ];
829 assert_eq!(derive_branch_path(&contributors, 18), "if-branch");
830 }
831
832 #[test]
833 fn derive_branch_path_chains_outer_to_inner_by_nesting_depth() {
834 let contributors = vec![
837 make_contributor(ContributorKind::ForLoop, 91, 96, 1, 0),
838 make_contributor(ContributorKind::IfBranch, 92, 94, 2, 1),
839 make_contributor(ContributorKind::Continue, 93, 93, 3, 2),
840 ];
841 assert_eq!(derive_branch_path(&contributors, 93), "for-loop/if-branch");
842 }
843
844 #[test]
845 fn derive_branch_path_carries_no_prose_or_whitespace() {
846 let contributors = vec![
848 make_contributor(ContributorKind::Match, 10, 30, 1, 0),
849 make_contributor(ContributorKind::IfBranch, 15, 20, 2, 1),
850 ];
851 let path = derive_branch_path(&contributors, 17);
852 for component in path.split('/') {
853 assert!(
854 !component.contains(' '),
855 "branch_path component {component:?} contains whitespace"
856 );
857 }
858 }
859
860 #[test]
863 fn derive_coverage_gaps_returns_empty_for_full_coverage() {
864 let cov = vec![
865 LineCoverage { line: 5, hits: 1 },
866 LineCoverage { line: 6, hits: 3 },
867 ];
868 let gaps = derive_coverage_gaps(&cov, &span_for(1, 10));
869 assert!(gaps.is_empty());
870 }
871
872 #[test]
873 fn derive_coverage_gaps_coalesces_contiguous_uncovered_lines() {
874 let cov = vec![
875 LineCoverage { line: 5, hits: 0 },
876 LineCoverage { line: 6, hits: 0 },
877 LineCoverage { line: 7, hits: 0 },
878 LineCoverage { line: 9, hits: 0 },
879 ];
880 let gaps = derive_coverage_gaps(&cov, &span_for(1, 10));
881 assert_eq!(gaps, vec![LineRange::new(5, 7), LineRange::new(9, 9)]);
882 }
883
884 #[test]
885 fn derive_coverage_gaps_filters_lines_outside_span() {
886 let cov = vec![
887 LineCoverage { line: 1, hits: 0 },
888 LineCoverage { line: 5, hits: 0 },
889 LineCoverage { line: 99, hits: 0 },
890 ];
891 let gaps = derive_coverage_gaps(&cov, &span_for(4, 6));
892 assert_eq!(gaps, vec![LineRange::new(5, 5)]);
893 }
894
895 #[test]
896 fn derive_coverage_gaps_handles_unsorted_input() {
897 let cov = vec![
898 LineCoverage { line: 7, hits: 0 },
899 LineCoverage { line: 5, hits: 0 },
900 LineCoverage { line: 6, hits: 0 },
901 ];
902 let gaps = derive_coverage_gaps(&cov, &span_for(1, 10));
903 assert_eq!(gaps, vec![LineRange::new(5, 7)]);
904 }
905
906 #[test]
909 fn pick_deepest_nesting_picks_inner_if_in_nested_function() {
910 let contributors = vec![
911 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
912 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
913 ];
914 let split = pick_deepest_nesting(&contributors, &span_for(16, 26)).expect("viable");
915 assert_eq!(split.line_range, LineRange::new(18, 22));
916 assert_eq!(split.kind, SplitKind::DeepestNesting);
917 assert_eq!(split.complexity_contribution, 2);
918 assert_eq!(split.branch_path, "if-branch");
919 assert!(!split.recommended); }
921
922 #[test]
923 fn pick_deepest_nesting_returns_none_for_flat_function() {
924 let contributors = vec![make_contributor(ContributorKind::IfBranch, 9, 13, 1, 0)];
927 assert!(pick_deepest_nesting(&contributors, &span_for(8, 14)).is_none());
928 }
929
930 #[test]
931 fn pick_deepest_nesting_skips_atomic_continue() {
932 let contributors = vec![
935 make_contributor(ContributorKind::ForLoop, 91, 96, 1, 0),
936 make_contributor(ContributorKind::IfBranch, 92, 94, 2, 1),
937 make_contributor(ContributorKind::Continue, 93, 93, 3, 2),
938 ];
939 let split = pick_deepest_nesting(&contributors, &span_for(89, 98)).expect("viable");
940 assert_eq!(split.line_range, LineRange::new(92, 94));
941 }
942
943 #[test]
946 fn pick_largest_subblock_picks_outer_if_over_inner() {
947 let contributors = vec![
948 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
949 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
950 ];
951 let split = pick_largest_subblock(&contributors, &span_for(16, 30)).expect("viable");
952 assert_eq!(split.line_range, LineRange::new(17, 25));
953 assert_eq!(split.kind, SplitKind::LargestSubblock);
954 }
955
956 #[test]
957 fn pick_largest_subblock_returns_none_when_range_equals_full_function() {
958 let contributors = vec![make_contributor(ContributorKind::IfBranch, 1, 10, 1, 0)];
961 assert!(pick_largest_subblock(&contributors, &span_for(1, 10)).is_none());
962 }
963
964 #[test]
967 fn pick_highest_branch_count_picks_outer_with_inner_contributors() {
968 let contributors = vec![
971 make_contributor(ContributorKind::IfBranch, 91, 96, 1, 0),
972 make_contributor(ContributorKind::IfBranch, 92, 94, 2, 1),
973 make_contributor(ContributorKind::Continue, 93, 93, 3, 2),
974 ];
975 let split = pick_highest_branch_count(&contributors, &span_for(89, 98)).expect("viable");
976 assert_eq!(split.line_range, LineRange::new(91, 96));
977 assert_eq!(split.kind, SplitKind::HighestBranchCount);
978 }
979
980 #[test]
981 fn pick_highest_branch_count_returns_none_when_no_nested_contributors() {
982 let contributors = vec![make_contributor(ContributorKind::IfBranch, 9, 13, 1, 0)];
984 assert!(pick_highest_branch_count(&contributors, &span_for(8, 14)).is_none());
985 }
986
987 #[test]
990 fn dedup_splits_keeps_highest_priority_for_duplicate_range() {
991 let range = LineRange::new(10, 20);
993 let dn = ProposedSplit {
994 line_range: range,
995 complexity_contribution: 4,
996 branch_path: "match".to_string(),
997 kind: SplitKind::DeepestNesting,
998 recommended: false,
999 };
1000 let hbc = ProposedSplit {
1001 kind: SplitKind::HighestBranchCount,
1002 ..dn.clone()
1003 };
1004 let lsb = ProposedSplit {
1005 kind: SplitKind::LargestSubblock,
1006 ..dn.clone()
1007 };
1008 let result = dedup_splits(vec![lsb, dn.clone(), hbc]);
1009 assert_eq!(result.len(), 1);
1010 assert_eq!(result[0].kind, SplitKind::DeepestNesting);
1011 assert!(result[0].recommended);
1012 }
1013
1014 #[test]
1015 fn dedup_splits_keeps_highest_branch_count_over_largest_subblock() {
1016 let range = LineRange::new(10, 20);
1017 let hbc = ProposedSplit {
1018 line_range: range,
1019 complexity_contribution: 4,
1020 branch_path: String::new(),
1021 kind: SplitKind::HighestBranchCount,
1022 recommended: false,
1023 };
1024 let lsb = ProposedSplit {
1025 kind: SplitKind::LargestSubblock,
1026 ..hbc.clone()
1027 };
1028 let result = dedup_splits(vec![lsb, hbc]);
1029 assert_eq!(result.len(), 1);
1030 assert_eq!(result[0].kind, SplitKind::HighestBranchCount);
1031 assert!(result[0].recommended);
1032 }
1033
1034 #[test]
1035 fn dedup_splits_marks_exactly_one_recommended_when_distinct_ranges() {
1036 let s1 = ProposedSplit {
1037 line_range: LineRange::new(10, 20),
1038 complexity_contribution: 2,
1039 branch_path: String::new(),
1040 kind: SplitKind::DeepestNesting,
1041 recommended: false,
1042 };
1043 let s2 = ProposedSplit {
1044 line_range: LineRange::new(30, 40),
1045 kind: SplitKind::HighestBranchCount,
1046 ..s1.clone()
1047 };
1048 let s3 = ProposedSplit {
1049 line_range: LineRange::new(50, 60),
1050 kind: SplitKind::LargestSubblock,
1051 ..s1.clone()
1052 };
1053 let result = dedup_splits(vec![s3, s2, s1]);
1054 assert_eq!(result.len(), 3);
1055 let recommended_count = result.iter().filter(|s| s.recommended).count();
1056 assert_eq!(recommended_count, 1);
1057 assert!(result[0].recommended);
1059 assert_eq!(result[0].kind, SplitKind::DeepestNesting);
1060 }
1061
1062 #[test]
1063 fn dedup_splits_empty_input_yields_empty() {
1064 assert!(dedup_splits(vec![]).is_empty());
1065 }
1066
1067 #[test]
1070 fn pick_actions_low_coverage_only() {
1071 let gaps = vec![LineRange::new(5, 7)];
1072 let (actions, root) = pick_actions(&gaps, &[], &[]);
1073 assert_eq!(root, RootCause::LowCoverage);
1074 assert_eq!(actions.len(), 1);
1075 assert!(matches!(
1076 actions[0],
1077 SuggestedAction::AddTestsForLines { .. }
1078 ));
1079 }
1080
1081 #[test]
1082 fn pick_actions_high_complexity_only_via_extract_function() {
1083 let split = ProposedSplit {
1084 line_range: LineRange::new(10, 20),
1085 complexity_contribution: 3,
1086 branch_path: String::new(),
1087 kind: SplitKind::DeepestNesting,
1088 recommended: true,
1089 };
1090 let (actions, root) = pick_actions(&[], &[split], &[]);
1091 assert_eq!(root, RootCause::HighComplexity);
1092 assert_eq!(actions.len(), 1);
1093 assert!(matches!(
1094 actions[0],
1095 SuggestedAction::ExtractFunction { .. }
1096 ));
1097 }
1098
1099 #[test]
1100 fn pick_actions_both_emits_both_actions() {
1101 let gaps = vec![LineRange::new(5, 7)];
1102 let split = ProposedSplit {
1103 line_range: LineRange::new(10, 20),
1104 complexity_contribution: 3,
1105 branch_path: String::new(),
1106 kind: SplitKind::DeepestNesting,
1107 recommended: true,
1108 };
1109 let (actions, root) = pick_actions(&gaps, &[split], &[]);
1110 assert_eq!(root, RootCause::Both);
1111 assert_eq!(actions.len(), 2);
1112 assert!(
1113 actions
1114 .iter()
1115 .any(|a| matches!(a, SuggestedAction::AddTestsForLines { .. }))
1116 );
1117 assert!(
1118 actions
1119 .iter()
1120 .any(|a| matches!(a, SuggestedAction::ExtractFunction { .. }))
1121 );
1122 }
1123
1124 #[test]
1125 fn pick_actions_no_splits_no_gaps_falls_back_to_accept_inherent() {
1126 let (actions, root) = pick_actions(&[], &[], &[]);
1127 assert_eq!(root, RootCause::HighComplexity);
1128 assert_eq!(actions.len(), 1);
1129 assert!(matches!(
1130 actions[0],
1131 SuggestedAction::AcceptInherentComplexity { .. }
1132 ));
1133 }
1134
1135 #[test]
1136 fn pick_actions_dominant_kind_emits_simplify_branching_when_no_splits() {
1137 let mut contribs = vec![
1139 make_contributor(ContributorKind::IfBranch, 1, 1, 1, 0),
1140 make_contributor(ContributorKind::IfBranch, 2, 2, 1, 0),
1141 make_contributor(ContributorKind::IfBranch, 3, 3, 1, 0),
1142 make_contributor(ContributorKind::IfBranch, 4, 4, 1, 0),
1143 ];
1144 contribs.push(make_contributor(ContributorKind::Match, 5, 5, 1, 0));
1145 let (actions, root) = pick_actions(&[], &[], &contribs);
1146 assert_eq!(root, RootCause::HighComplexity);
1147 assert!(
1148 actions
1149 .iter()
1150 .any(|a| matches!(a, SuggestedAction::SimplifyBranching { .. }))
1151 );
1152 }
1153
1154 #[test]
1155 fn pick_actions_simplify_branching_with_low_coverage_yields_both() {
1156 let gaps = vec![LineRange::new(2, 3)];
1157 let contribs = vec![
1158 make_contributor(ContributorKind::IfBranch, 1, 1, 1, 0),
1159 make_contributor(ContributorKind::IfBranch, 2, 2, 1, 0),
1160 make_contributor(ContributorKind::IfBranch, 3, 3, 1, 0),
1161 make_contributor(ContributorKind::IfBranch, 4, 4, 1, 0),
1162 ];
1163 let (actions, root) = pick_actions(&gaps, &[], &contribs);
1164 assert_eq!(root, RootCause::Both);
1165 assert!(
1166 actions
1167 .iter()
1168 .any(|a| matches!(a, SuggestedAction::AddTestsForLines { .. }))
1169 );
1170 assert!(
1171 actions
1172 .iter()
1173 .any(|a| matches!(a, SuggestedAction::SimplifyBranching { .. }))
1174 );
1175 }
1176
1177 #[test]
1178 fn pick_actions_extract_function_takes_precedence_over_simplify() {
1179 let contribs = vec![
1182 make_contributor(ContributorKind::IfBranch, 1, 5, 1, 0),
1183 make_contributor(ContributorKind::IfBranch, 2, 4, 2, 1),
1184 ];
1185 let split = ProposedSplit {
1186 line_range: LineRange::new(2, 4),
1187 complexity_contribution: 2,
1188 branch_path: "if-branch".to_string(),
1189 kind: SplitKind::DeepestNesting,
1190 recommended: true,
1191 };
1192 let (actions, _) = pick_actions(&[], &[split], &contribs);
1193 assert!(
1194 !actions
1195 .iter()
1196 .any(|a| matches!(a, SuggestedAction::SimplifyBranching { .. }))
1197 );
1198 }
1199
1200 #[test]
1203 fn compute_diagnostic_returns_none_for_passing_verdict() {
1204 let verdict = make_verdict(vec![], span_for(1, 10), false);
1205 assert!(compute_diagnostic(&verdict, &[]).is_none());
1206 }
1207
1208 #[test]
1209 fn compute_diagnostic_returns_some_for_exceeding_verdict() {
1210 let verdict = make_verdict(
1211 vec![
1212 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
1213 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
1214 ],
1215 span_for(16, 26),
1216 true,
1217 );
1218 let diag = compute_diagnostic(&verdict, &[]).expect("diagnostic populated");
1219 assert!(diag.coverage_gaps.is_empty());
1220 assert_eq!(diag.complexity_drivers.len(), 2);
1221 assert!(!diag.suggested_actions.is_empty());
1222 }
1223
1224 #[test]
1225 fn compute_diagnostic_low_coverage_only_emits_add_tests() {
1226 let verdict = make_verdict(vec![], span_for(1, 10), true);
1229 let cov = vec![LineCoverage { line: 5, hits: 0 }];
1230 let diag = compute_diagnostic(&verdict, &cov).expect("populated");
1231 assert_eq!(diag.root_cause, RootCause::LowCoverage);
1232 assert_eq!(diag.coverage_gaps, vec![LineRange::new(5, 5)]);
1233 assert_eq!(diag.suggested_actions.len(), 1);
1234 assert!(matches!(
1235 diag.suggested_actions[0],
1236 SuggestedAction::AddTestsForLines { .. }
1237 ));
1238 }
1239
1240 #[test]
1241 fn compute_diagnostic_full_coverage_no_splits_falls_back_to_accept_inherent() {
1242 let verdict = make_verdict(vec![], span_for(1, 10), true);
1243 let diag = compute_diagnostic(&verdict, &[]).expect("populated");
1244 assert_eq!(diag.root_cause, RootCause::HighComplexity);
1245 assert_eq!(diag.suggested_actions.len(), 1);
1246 assert!(matches!(
1247 diag.suggested_actions[0],
1248 SuggestedAction::AcceptInherentComplexity { .. }
1249 ));
1250 }
1251
1252 #[test]
1253 fn compute_diagnostic_extract_function_carries_recommended_marker() {
1254 let verdict = make_verdict(
1255 vec![
1256 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
1257 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
1258 ],
1259 span_for(16, 30),
1260 true,
1261 );
1262 let diag = compute_diagnostic(&verdict, &[]).expect("populated");
1263 let ef = diag
1264 .suggested_actions
1265 .iter()
1266 .find_map(|a| match a {
1267 SuggestedAction::ExtractFunction { candidates, .. } => Some(candidates),
1268 _ => None,
1269 })
1270 .expect("ExtractFunction emitted");
1271 assert!(!ef.is_empty());
1272 let recommended_count = ef.iter().filter(|s| s.recommended).count();
1273 assert_eq!(recommended_count, 1);
1274 }
1275}
1276
1277#[cfg(test)]
1280mod proptests {
1281 use super::*;
1282 use proptest::prelude::*;
1283
1284 fn arb_split_kind() -> impl Strategy<Value = SplitKind> {
1285 prop_oneof![
1286 Just(SplitKind::DeepestNesting),
1287 Just(SplitKind::LargestSubblock),
1288 Just(SplitKind::HighestBranchCount),
1289 ]
1290 }
1291
1292 fn arb_proposed_split() -> impl Strategy<Value = ProposedSplit> {
1293 (1usize..200, 1usize..200, 0u32..50, arb_split_kind()).prop_map(
1294 |(start, len, contribution, kind)| ProposedSplit {
1295 line_range: LineRange::new(start, start + len),
1296 complexity_contribution: contribution,
1297 branch_path: String::new(),
1298 kind,
1299 recommended: false,
1300 },
1301 )
1302 }
1303
1304 proptest! {
1305 #![proptest_config(ProptestConfig::with_cases(256))]
1306
1307 #[test]
1310 fn dedup_splits_has_exactly_one_recommended_when_non_empty(
1311 splits in proptest::collection::vec(arb_proposed_split(), 1..10)
1312 ) {
1313 let result = dedup_splits(splits);
1314 if !result.is_empty() {
1315 let count = result.iter().filter(|s| s.recommended).count();
1316 prop_assert_eq!(count, 1);
1317 }
1318 }
1319
1320 #[test]
1323 fn dedup_splits_is_idempotent(
1324 splits in proptest::collection::vec(arb_proposed_split(), 0..10)
1325 ) {
1326 let once = dedup_splits(splits);
1327 let twice = dedup_splits(once.clone());
1328 prop_assert_eq!(once, twice);
1329 }
1330
1331 #[test]
1334 fn branch_path_carries_no_whitespace_or_commas(
1335 depths in proptest::collection::vec(0u32..6, 0..10)
1336 ) {
1337 let contributors: Vec<ComplexityContributor> = depths
1340 .iter()
1341 .enumerate()
1342 .map(|(i, depth)| {
1343 let start = 50 + i;
1344 ComplexityContributor {
1345 kind: ContributorKind::IfBranch,
1346 line: start,
1347 column: None,
1348 increment: 1,
1349 end_line: 200,
1350 nesting_depth: *depth,
1351 }
1352 })
1353 .collect();
1354 let path = derive_branch_path(&contributors, 100);
1355 prop_assert!(!path.contains(' '));
1356 prop_assert!(!path.contains(','));
1357 }
1358
1359 #[test]
1362 fn dedup_splits_sorts_by_priority_descending(
1363 splits in proptest::collection::vec(arb_proposed_split(), 1..10)
1364 ) {
1365 let result = dedup_splits(splits);
1366 for window in result.windows(2) {
1367 prop_assert!(
1368 split_kind_priority(window[0].kind)
1369 >= split_kind_priority(window[1].kind)
1370 );
1371 }
1372 }
1373 }
1374}