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)]
16pub 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)]
125pub 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)]
173pub 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 crap: CrapScore {
798 value: 50.0,
799 risk_level: RiskLevel::High,
800 },
801 contributors,
802 },
803 threshold: 30.0,
804 exceeds,
805 diagnostic: None,
806 }
807 }
808
809 #[test]
812 fn derive_branch_path_empty_for_top_level_construct() {
813 let contributors = vec![make_contributor(ContributorKind::IfBranch, 9, 13, 1, 0)];
816 assert_eq!(derive_branch_path(&contributors, 9), "");
817 }
818
819 #[test]
820 fn derive_branch_path_single_for_nested_if_inner_start() {
821 let contributors = vec![
825 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
826 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
827 ];
828 assert_eq!(derive_branch_path(&contributors, 18), "if-branch");
829 }
830
831 #[test]
832 fn derive_branch_path_chains_outer_to_inner_by_nesting_depth() {
833 let contributors = vec![
836 make_contributor(ContributorKind::ForLoop, 91, 96, 1, 0),
837 make_contributor(ContributorKind::IfBranch, 92, 94, 2, 1),
838 make_contributor(ContributorKind::Continue, 93, 93, 3, 2),
839 ];
840 assert_eq!(derive_branch_path(&contributors, 93), "for-loop/if-branch");
841 }
842
843 #[test]
844 fn derive_branch_path_carries_no_prose_or_whitespace() {
845 let contributors = vec![
847 make_contributor(ContributorKind::Match, 10, 30, 1, 0),
848 make_contributor(ContributorKind::IfBranch, 15, 20, 2, 1),
849 ];
850 let path = derive_branch_path(&contributors, 17);
851 for component in path.split('/') {
852 assert!(
853 !component.contains(' '),
854 "branch_path component {component:?} contains whitespace"
855 );
856 }
857 }
858
859 #[test]
862 fn derive_coverage_gaps_returns_empty_for_full_coverage() {
863 let cov = vec![
864 LineCoverage { line: 5, hits: 1 },
865 LineCoverage { line: 6, hits: 3 },
866 ];
867 let gaps = derive_coverage_gaps(&cov, &span_for(1, 10));
868 assert!(gaps.is_empty());
869 }
870
871 #[test]
872 fn derive_coverage_gaps_coalesces_contiguous_uncovered_lines() {
873 let cov = vec![
874 LineCoverage { line: 5, hits: 0 },
875 LineCoverage { line: 6, hits: 0 },
876 LineCoverage { line: 7, hits: 0 },
877 LineCoverage { line: 9, hits: 0 },
878 ];
879 let gaps = derive_coverage_gaps(&cov, &span_for(1, 10));
880 assert_eq!(gaps, vec![LineRange::new(5, 7), LineRange::new(9, 9)]);
881 }
882
883 #[test]
884 fn derive_coverage_gaps_filters_lines_outside_span() {
885 let cov = vec![
886 LineCoverage { line: 1, hits: 0 },
887 LineCoverage { line: 5, hits: 0 },
888 LineCoverage { line: 99, hits: 0 },
889 ];
890 let gaps = derive_coverage_gaps(&cov, &span_for(4, 6));
891 assert_eq!(gaps, vec![LineRange::new(5, 5)]);
892 }
893
894 #[test]
895 fn derive_coverage_gaps_handles_unsorted_input() {
896 let cov = vec![
897 LineCoverage { line: 7, hits: 0 },
898 LineCoverage { line: 5, hits: 0 },
899 LineCoverage { line: 6, hits: 0 },
900 ];
901 let gaps = derive_coverage_gaps(&cov, &span_for(1, 10));
902 assert_eq!(gaps, vec![LineRange::new(5, 7)]);
903 }
904
905 #[test]
908 fn pick_deepest_nesting_picks_inner_if_in_nested_function() {
909 let contributors = vec![
910 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
911 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
912 ];
913 let split = pick_deepest_nesting(&contributors, &span_for(16, 26)).expect("viable");
914 assert_eq!(split.line_range, LineRange::new(18, 22));
915 assert_eq!(split.kind, SplitKind::DeepestNesting);
916 assert_eq!(split.complexity_contribution, 2);
917 assert_eq!(split.branch_path, "if-branch");
918 assert!(!split.recommended); }
920
921 #[test]
922 fn pick_deepest_nesting_returns_none_for_flat_function() {
923 let contributors = vec![make_contributor(ContributorKind::IfBranch, 9, 13, 1, 0)];
926 assert!(pick_deepest_nesting(&contributors, &span_for(8, 14)).is_none());
927 }
928
929 #[test]
930 fn pick_deepest_nesting_skips_atomic_continue() {
931 let contributors = vec![
934 make_contributor(ContributorKind::ForLoop, 91, 96, 1, 0),
935 make_contributor(ContributorKind::IfBranch, 92, 94, 2, 1),
936 make_contributor(ContributorKind::Continue, 93, 93, 3, 2),
937 ];
938 let split = pick_deepest_nesting(&contributors, &span_for(89, 98)).expect("viable");
939 assert_eq!(split.line_range, LineRange::new(92, 94));
940 }
941
942 #[test]
945 fn pick_largest_subblock_picks_outer_if_over_inner() {
946 let contributors = vec![
947 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
948 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
949 ];
950 let split = pick_largest_subblock(&contributors, &span_for(16, 30)).expect("viable");
951 assert_eq!(split.line_range, LineRange::new(17, 25));
952 assert_eq!(split.kind, SplitKind::LargestSubblock);
953 }
954
955 #[test]
956 fn pick_largest_subblock_returns_none_when_range_equals_full_function() {
957 let contributors = vec![make_contributor(ContributorKind::IfBranch, 1, 10, 1, 0)];
960 assert!(pick_largest_subblock(&contributors, &span_for(1, 10)).is_none());
961 }
962
963 #[test]
966 fn pick_highest_branch_count_picks_outer_with_inner_contributors() {
967 let contributors = vec![
970 make_contributor(ContributorKind::IfBranch, 91, 96, 1, 0),
971 make_contributor(ContributorKind::IfBranch, 92, 94, 2, 1),
972 make_contributor(ContributorKind::Continue, 93, 93, 3, 2),
973 ];
974 let split = pick_highest_branch_count(&contributors, &span_for(89, 98)).expect("viable");
975 assert_eq!(split.line_range, LineRange::new(91, 96));
976 assert_eq!(split.kind, SplitKind::HighestBranchCount);
977 }
978
979 #[test]
980 fn pick_highest_branch_count_returns_none_when_no_nested_contributors() {
981 let contributors = vec![make_contributor(ContributorKind::IfBranch, 9, 13, 1, 0)];
983 assert!(pick_highest_branch_count(&contributors, &span_for(8, 14)).is_none());
984 }
985
986 #[test]
989 fn dedup_splits_keeps_highest_priority_for_duplicate_range() {
990 let range = LineRange::new(10, 20);
992 let dn = ProposedSplit {
993 line_range: range,
994 complexity_contribution: 4,
995 branch_path: "match".to_string(),
996 kind: SplitKind::DeepestNesting,
997 recommended: false,
998 };
999 let hbc = ProposedSplit {
1000 kind: SplitKind::HighestBranchCount,
1001 ..dn.clone()
1002 };
1003 let lsb = ProposedSplit {
1004 kind: SplitKind::LargestSubblock,
1005 ..dn.clone()
1006 };
1007 let result = dedup_splits(vec![lsb, dn.clone(), hbc]);
1008 assert_eq!(result.len(), 1);
1009 assert_eq!(result[0].kind, SplitKind::DeepestNesting);
1010 assert!(result[0].recommended);
1011 }
1012
1013 #[test]
1014 fn dedup_splits_keeps_highest_branch_count_over_largest_subblock() {
1015 let range = LineRange::new(10, 20);
1016 let hbc = ProposedSplit {
1017 line_range: range,
1018 complexity_contribution: 4,
1019 branch_path: String::new(),
1020 kind: SplitKind::HighestBranchCount,
1021 recommended: false,
1022 };
1023 let lsb = ProposedSplit {
1024 kind: SplitKind::LargestSubblock,
1025 ..hbc.clone()
1026 };
1027 let result = dedup_splits(vec![lsb, hbc]);
1028 assert_eq!(result.len(), 1);
1029 assert_eq!(result[0].kind, SplitKind::HighestBranchCount);
1030 assert!(result[0].recommended);
1031 }
1032
1033 #[test]
1034 fn dedup_splits_marks_exactly_one_recommended_when_distinct_ranges() {
1035 let s1 = ProposedSplit {
1036 line_range: LineRange::new(10, 20),
1037 complexity_contribution: 2,
1038 branch_path: String::new(),
1039 kind: SplitKind::DeepestNesting,
1040 recommended: false,
1041 };
1042 let s2 = ProposedSplit {
1043 line_range: LineRange::new(30, 40),
1044 kind: SplitKind::HighestBranchCount,
1045 ..s1.clone()
1046 };
1047 let s3 = ProposedSplit {
1048 line_range: LineRange::new(50, 60),
1049 kind: SplitKind::LargestSubblock,
1050 ..s1.clone()
1051 };
1052 let result = dedup_splits(vec![s3, s2, s1]);
1053 assert_eq!(result.len(), 3);
1054 let recommended_count = result.iter().filter(|s| s.recommended).count();
1055 assert_eq!(recommended_count, 1);
1056 assert!(result[0].recommended);
1058 assert_eq!(result[0].kind, SplitKind::DeepestNesting);
1059 }
1060
1061 #[test]
1062 fn dedup_splits_empty_input_yields_empty() {
1063 assert!(dedup_splits(vec![]).is_empty());
1064 }
1065
1066 #[test]
1069 fn pick_actions_low_coverage_only() {
1070 let gaps = vec![LineRange::new(5, 7)];
1071 let (actions, root) = pick_actions(&gaps, &[], &[]);
1072 assert_eq!(root, RootCause::LowCoverage);
1073 assert_eq!(actions.len(), 1);
1074 assert!(matches!(
1075 actions[0],
1076 SuggestedAction::AddTestsForLines { .. }
1077 ));
1078 }
1079
1080 #[test]
1081 fn pick_actions_high_complexity_only_via_extract_function() {
1082 let split = ProposedSplit {
1083 line_range: LineRange::new(10, 20),
1084 complexity_contribution: 3,
1085 branch_path: String::new(),
1086 kind: SplitKind::DeepestNesting,
1087 recommended: true,
1088 };
1089 let (actions, root) = pick_actions(&[], &[split], &[]);
1090 assert_eq!(root, RootCause::HighComplexity);
1091 assert_eq!(actions.len(), 1);
1092 assert!(matches!(
1093 actions[0],
1094 SuggestedAction::ExtractFunction { .. }
1095 ));
1096 }
1097
1098 #[test]
1099 fn pick_actions_both_emits_both_actions() {
1100 let gaps = vec![LineRange::new(5, 7)];
1101 let split = ProposedSplit {
1102 line_range: LineRange::new(10, 20),
1103 complexity_contribution: 3,
1104 branch_path: String::new(),
1105 kind: SplitKind::DeepestNesting,
1106 recommended: true,
1107 };
1108 let (actions, root) = pick_actions(&gaps, &[split], &[]);
1109 assert_eq!(root, RootCause::Both);
1110 assert_eq!(actions.len(), 2);
1111 assert!(
1112 actions
1113 .iter()
1114 .any(|a| matches!(a, SuggestedAction::AddTestsForLines { .. }))
1115 );
1116 assert!(
1117 actions
1118 .iter()
1119 .any(|a| matches!(a, SuggestedAction::ExtractFunction { .. }))
1120 );
1121 }
1122
1123 #[test]
1124 fn pick_actions_no_splits_no_gaps_falls_back_to_accept_inherent() {
1125 let (actions, root) = pick_actions(&[], &[], &[]);
1126 assert_eq!(root, RootCause::HighComplexity);
1127 assert_eq!(actions.len(), 1);
1128 assert!(matches!(
1129 actions[0],
1130 SuggestedAction::AcceptInherentComplexity { .. }
1131 ));
1132 }
1133
1134 #[test]
1135 fn pick_actions_dominant_kind_emits_simplify_branching_when_no_splits() {
1136 let mut contribs = vec![
1138 make_contributor(ContributorKind::IfBranch, 1, 1, 1, 0),
1139 make_contributor(ContributorKind::IfBranch, 2, 2, 1, 0),
1140 make_contributor(ContributorKind::IfBranch, 3, 3, 1, 0),
1141 make_contributor(ContributorKind::IfBranch, 4, 4, 1, 0),
1142 ];
1143 contribs.push(make_contributor(ContributorKind::Match, 5, 5, 1, 0));
1144 let (actions, root) = pick_actions(&[], &[], &contribs);
1145 assert_eq!(root, RootCause::HighComplexity);
1146 assert!(
1147 actions
1148 .iter()
1149 .any(|a| matches!(a, SuggestedAction::SimplifyBranching { .. }))
1150 );
1151 }
1152
1153 #[test]
1154 fn pick_actions_simplify_branching_with_low_coverage_yields_both() {
1155 let gaps = vec![LineRange::new(2, 3)];
1156 let contribs = vec![
1157 make_contributor(ContributorKind::IfBranch, 1, 1, 1, 0),
1158 make_contributor(ContributorKind::IfBranch, 2, 2, 1, 0),
1159 make_contributor(ContributorKind::IfBranch, 3, 3, 1, 0),
1160 make_contributor(ContributorKind::IfBranch, 4, 4, 1, 0),
1161 ];
1162 let (actions, root) = pick_actions(&gaps, &[], &contribs);
1163 assert_eq!(root, RootCause::Both);
1164 assert!(
1165 actions
1166 .iter()
1167 .any(|a| matches!(a, SuggestedAction::AddTestsForLines { .. }))
1168 );
1169 assert!(
1170 actions
1171 .iter()
1172 .any(|a| matches!(a, SuggestedAction::SimplifyBranching { .. }))
1173 );
1174 }
1175
1176 #[test]
1177 fn pick_actions_extract_function_takes_precedence_over_simplify() {
1178 let contribs = vec![
1181 make_contributor(ContributorKind::IfBranch, 1, 5, 1, 0),
1182 make_contributor(ContributorKind::IfBranch, 2, 4, 2, 1),
1183 ];
1184 let split = ProposedSplit {
1185 line_range: LineRange::new(2, 4),
1186 complexity_contribution: 2,
1187 branch_path: "if-branch".to_string(),
1188 kind: SplitKind::DeepestNesting,
1189 recommended: true,
1190 };
1191 let (actions, _) = pick_actions(&[], &[split], &contribs);
1192 assert!(
1193 !actions
1194 .iter()
1195 .any(|a| matches!(a, SuggestedAction::SimplifyBranching { .. }))
1196 );
1197 }
1198
1199 #[test]
1202 fn compute_diagnostic_returns_none_for_passing_verdict() {
1203 let verdict = make_verdict(vec![], span_for(1, 10), false);
1204 assert!(compute_diagnostic(&verdict, &[]).is_none());
1205 }
1206
1207 #[test]
1208 fn compute_diagnostic_returns_some_for_exceeding_verdict() {
1209 let verdict = make_verdict(
1210 vec![
1211 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
1212 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
1213 ],
1214 span_for(16, 26),
1215 true,
1216 );
1217 let diag = compute_diagnostic(&verdict, &[]).expect("diagnostic populated");
1218 assert!(diag.coverage_gaps.is_empty());
1219 assert_eq!(diag.complexity_drivers.len(), 2);
1220 assert!(!diag.suggested_actions.is_empty());
1221 }
1222
1223 #[test]
1224 fn compute_diagnostic_low_coverage_only_emits_add_tests() {
1225 let verdict = make_verdict(vec![], span_for(1, 10), true);
1228 let cov = vec![LineCoverage { line: 5, hits: 0 }];
1229 let diag = compute_diagnostic(&verdict, &cov).expect("populated");
1230 assert_eq!(diag.root_cause, RootCause::LowCoverage);
1231 assert_eq!(diag.coverage_gaps, vec![LineRange::new(5, 5)]);
1232 assert_eq!(diag.suggested_actions.len(), 1);
1233 assert!(matches!(
1234 diag.suggested_actions[0],
1235 SuggestedAction::AddTestsForLines { .. }
1236 ));
1237 }
1238
1239 #[test]
1240 fn compute_diagnostic_full_coverage_no_splits_falls_back_to_accept_inherent() {
1241 let verdict = make_verdict(vec![], span_for(1, 10), true);
1242 let diag = compute_diagnostic(&verdict, &[]).expect("populated");
1243 assert_eq!(diag.root_cause, RootCause::HighComplexity);
1244 assert_eq!(diag.suggested_actions.len(), 1);
1245 assert!(matches!(
1246 diag.suggested_actions[0],
1247 SuggestedAction::AcceptInherentComplexity { .. }
1248 ));
1249 }
1250
1251 #[test]
1252 fn compute_diagnostic_extract_function_carries_recommended_marker() {
1253 let verdict = make_verdict(
1254 vec![
1255 make_contributor(ContributorKind::IfBranch, 17, 25, 1, 0),
1256 make_contributor(ContributorKind::IfBranch, 18, 22, 2, 1),
1257 ],
1258 span_for(16, 30),
1259 true,
1260 );
1261 let diag = compute_diagnostic(&verdict, &[]).expect("populated");
1262 let ef = diag
1263 .suggested_actions
1264 .iter()
1265 .find_map(|a| match a {
1266 SuggestedAction::ExtractFunction { candidates, .. } => Some(candidates),
1267 _ => None,
1268 })
1269 .expect("ExtractFunction emitted");
1270 assert!(!ef.is_empty());
1271 let recommended_count = ef.iter().filter(|s| s.recommended).count();
1272 assert_eq!(recommended_count, 1);
1273 }
1274}
1275
1276#[cfg(test)]
1279mod proptests {
1280 use super::*;
1281 use proptest::prelude::*;
1282
1283 fn arb_split_kind() -> impl Strategy<Value = SplitKind> {
1284 prop_oneof![
1285 Just(SplitKind::DeepestNesting),
1286 Just(SplitKind::LargestSubblock),
1287 Just(SplitKind::HighestBranchCount),
1288 ]
1289 }
1290
1291 fn arb_proposed_split() -> impl Strategy<Value = ProposedSplit> {
1292 (1usize..200, 1usize..200, 0u32..50, arb_split_kind()).prop_map(
1293 |(start, len, contribution, kind)| ProposedSplit {
1294 line_range: LineRange::new(start, start + len),
1295 complexity_contribution: contribution,
1296 branch_path: String::new(),
1297 kind,
1298 recommended: false,
1299 },
1300 )
1301 }
1302
1303 proptest! {
1304 #![proptest_config(ProptestConfig::with_cases(256))]
1305
1306 #[test]
1309 fn dedup_splits_has_exactly_one_recommended_when_non_empty(
1310 splits in proptest::collection::vec(arb_proposed_split(), 1..10)
1311 ) {
1312 let result = dedup_splits(splits);
1313 if !result.is_empty() {
1314 let count = result.iter().filter(|s| s.recommended).count();
1315 prop_assert_eq!(count, 1);
1316 }
1317 }
1318
1319 #[test]
1322 fn dedup_splits_is_idempotent(
1323 splits in proptest::collection::vec(arb_proposed_split(), 0..10)
1324 ) {
1325 let once = dedup_splits(splits);
1326 let twice = dedup_splits(once.clone());
1327 prop_assert_eq!(once, twice);
1328 }
1329
1330 #[test]
1333 fn branch_path_carries_no_whitespace_or_commas(
1334 depths in proptest::collection::vec(0u32..6, 0..10)
1335 ) {
1336 let contributors: Vec<ComplexityContributor> = depths
1339 .iter()
1340 .enumerate()
1341 .map(|(i, depth)| {
1342 let start = 50 + i;
1343 ComplexityContributor {
1344 kind: ContributorKind::IfBranch,
1345 line: start,
1346 column: None,
1347 increment: 1,
1348 end_line: 200,
1349 nesting_depth: *depth,
1350 }
1351 })
1352 .collect();
1353 let path = derive_branch_path(&contributors, 100);
1354 prop_assert!(!path.contains(' '));
1355 prop_assert!(!path.contains(','));
1356 }
1357
1358 #[test]
1361 fn dedup_splits_sorts_by_priority_descending(
1362 splits in proptest::collection::vec(arb_proposed_split(), 1..10)
1363 ) {
1364 let result = dedup_splits(splits);
1365 for window in result.windows(2) {
1366 prop_assert!(
1367 split_kind_priority(window[0].kind)
1368 >= split_kind_priority(window[1].kind)
1369 );
1370 }
1371 }
1372 }
1373}