Skip to main content

crap_core/domain/
diagnostic.rs

1//! Structured remediation hints (`Diagnostic`) attached to over-threshold
2//! verdicts. Pure domain — no `syn`, no LCOV, no I/O.
3
4use serde::{Deserialize, Serialize};
5
6use crate::domain::types::{
7    ComplexityContributor, ContributorKind, FunctionVerdict, LineCoverage, SourceSpan,
8};
9
10// ── Line range ──────────────────────────────────────────────────────
11
12/// Inclusive 1-based line range. Mirrors `SourceSpan`'s end-inclusive
13/// convention (per `.claude/rules/domain.md` §5) so coverage gaps and
14/// proposed splits address the same line space as `ComplexityContributor`.
15#[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    /// True when `line` falls inside `[start, end]` (inclusive).
28    pub fn contains(&self, line: usize) -> bool {
29        self.start <= line && line <= self.end
30    }
31}
32
33// ── Root cause ──────────────────────────────────────────────────────
34
35/// Deterministic single-token classification of why a verdict exceeded
36/// the threshold. `LowCoverage` when the only action is `AddTestsForLines`;
37/// `HighComplexity` when the only actions are split/simplify/accept;
38/// `Both` when both kinds of action coexist.
39#[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    /// Canonical wire string — equal to the serde JSON representation
51    /// (sans quotes). See `crate::domain::types::ContributorKind::as_wire_str`
52    /// for the rationale; equality with serde is pinned in
53    /// `tests::wire_str_matches_serde`.
54    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// ── Applicability ───────────────────────────────────────────────────
64
65/// Confidence in a `SuggestedAction`, matching `rustc`'s `Applicability`
66/// taxonomy so agents using rustc-shaped tooling can interpret crap4rs
67/// suggestions without translation. The default is `Unspecified` because
68/// crap4rs does not verify the suggested change.
69#[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    /// Canonical wire string — see `RootCause::as_wire_str`.
82    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// ── SplitKind ───────────────────────────────────────────────────────
93
94/// Which strategy produced a `ProposedSplit`. Priority order
95/// `DeepestNesting > HighestBranchCount > LargestSubblock` is enforced
96/// at dedup time. The default variant is `DeepestNesting` because that
97/// is the highest-priority strategy and the most useful candidate when
98/// only one is needed.
99#[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    /// Canonical wire string — see `RootCause::as_wire_str`.
111    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// ── ProposedSplit ───────────────────────────────────────────────────
121
122/// One AST-derived candidate for `extract_function`. The split is named
123/// only by its line range — agents do prose, the CLI does coordinates.
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125#[non_exhaustive]
126pub struct ProposedSplit {
127    pub line_range: LineRange,
128    /// Sum of contributor increments inside `line_range` (cognitive or
129    /// cyclomatic — the metric is recorded on `ScoredFunction`).
130    pub complexity_contribution: u32,
131    /// `/`-joined chain of `ContributorKind` Display strings, ascending
132    /// by nesting up to the split's `start_line`. AST-only, no prose.
133    pub branch_path: String,
134    pub kind: SplitKind,
135    /// Exactly one entry per non-empty candidate set carries
136    /// `recommended: true`.
137    pub recommended: bool,
138}
139
140// ── SuggestedAction ─────────────────────────────────────────────────
141
142/// One remediation step. Tagged-enum serialization (`{"kind": "…", …}`)
143/// keeps additive variants forward-compatible under `#[non_exhaustive]`.
144#[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// ── Diagnostic ──────────────────────────────────────────────────────
166
167/// Structured remediation hint attached to an over-threshold
168/// `FunctionVerdict` when `--format advice` (or `--format sarif`) is
169/// requested. Type-level `#[serde(default)]` lets older payloads
170/// deserialize when fields are added under `#[non_exhaustive]`.
171#[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
181// ── Helpers: compute_diagnostic + sub-helpers ───────────────────────
182//
183// All helpers are pure: no I/O, no `syn`, no LCOV. They consume
184// already-parsed `FunctionVerdict` + `LineCoverage` slices and emit
185// AST-derived advice. Callers keep `compute_diagnostic` as the single
186// entry point; the others are `pub(crate)` so tests can pin them.
187
188/// Effective inclusive end line for a contributor. Older payloads may
189/// carry `end_line == 0`; fall back to `line` so the construct is
190/// treated as a single-line atomic.
191fn 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
199/// True when the construct spans multiple source lines.
200fn is_compound(c: &ComplexityContributor) -> bool {
201    effective_end_line(c) > c.line
202}
203
204/// Sum of contributor increments inside `range`. Each contributor is
205/// counted iff its `line` falls inside `[range.start, range.end]`.
206fn 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
214/// Count of contributors whose `line` falls strictly inside `range` —
215/// used by `pick_highest_branch_count` to score how many sub-decisions
216/// a compound contributor encloses.
217fn 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            // Skip the contributor itself.
226            !(c.line == outer.line && c.kind == outer.kind && c.column == outer.column)
227                && outer_range.contains(c.line)
228        })
229        .count()
230}
231
232/// True when a candidate range describes a meaningful extract target
233/// inside `span` — must be multi-line, strictly within the function,
234/// and carry at least one contributor.
235fn 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
243/// `/`-joined chain of `ContributorKind` Display strings for every
244/// contributor that **strictly encloses** `start_line` (i.e., starts
245/// before `start_line` and ends at or after it). Sorted by
246/// `nesting_depth` ascending so the path reads outermost → innermost.
247/// AST-derived only — never carries prose.
248pub(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
264/// Coalesce uncovered (`hits == 0`) lines inside `span` into inclusive
265/// ranges. Pre-condition: `line_coverage` may be unsorted; we sort by
266/// line before coalescing so adapters don't have to.
267pub(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
289/// Pick the deepest-nested compound contributor as a split candidate.
290/// Tiebreaker: lowest `line` wins so the result is deterministic.
291pub(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
313/// Pick the compound contributor covering the largest source span.
314/// Tiebreaker: lowest `line` wins.
315pub(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
340/// Pick the compound contributor that encloses the most other
341/// contributors (densest decision cluster). Tiebreaker: lowest `line`.
342pub(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    // Reject degenerate "no inner contributors" picks — that's a
354    // single-construct function, not a branch cluster.
355    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
372/// Run all three selectors, dedup overlapping ranges by `SplitKind`
373/// and mark exactly one survivor `recommended: true`.
374pub(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
389/// Priority weight for `SplitKind` (higher = wins under dedup).
390fn split_kind_priority(kind: SplitKind) -> u8 {
391    match kind {
392        SplitKind::DeepestNesting => 3,
393        SplitKind::HighestBranchCount => 2,
394        SplitKind::LargestSubblock => 1,
395    }
396}
397
398/// Dedup `splits` by `line_range`. When two candidates share the same
399/// range, the highest-priority `kind` wins
400/// (`DeepestNesting > HighestBranchCount > LargestSubblock`). Within
401/// the same kind, the lowest `start_line` wins. Exactly one survivor
402/// is marked `recommended: true` (highest priority overall).
403pub(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
434/// Strict-majority dominant contributor kind: returns `Some(kind)` when
435/// one kind accounts for **strictly more than 70%** of contributor
436/// count. Returns `None` for a flat function (zero contributors).
437///
438/// Linear scan rather than `HashMap` keeps `ContributorKind`'s public
439/// derives unchanged (no `Hash` requirement leaks into other crates).
440fn 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
459/// Build the `(suggested_actions, root_cause)` pair from gaps + splits
460/// + contributors. Action gating:
461///
462/// 1. `gaps non-empty` → `AddTestsForLines`
463/// 2. `splits non-empty` → `ExtractFunction`
464/// 3. `splits empty AND >70% dominant kind` → `SimplifyBranching`
465/// 4. `no other action emitted` → `AcceptInherentComplexity`
466pub(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
516/// Build a structured remediation hint for `verdict`. Returns `None`
517/// when the verdict is below threshold (no diagnostic for
518/// passing functions). When the verdict exceeds, the diagnostic is
519/// always populated with all four fields.
520///
521/// The `line_coverage` slice should hold every `DA:` entry from the
522/// LCOV file scoped to the verdict's source file; callers in
523/// `core::analyze` already have this in hand. Entries outside
524/// `verdict.scored.identity.span` are filtered out by
525/// `derive_coverage_gaps`.
526pub 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// ── Tests: serde round-trip + default ───────────────────────────────
550
551#[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        // Type-level `#[serde(default)]` means `{}` round-trips through
577        // `Diagnostic::default()`. Pins the additive convention so future
578        // payloads adding fields don't break older readers.
579        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        // Round-trip every variant to lock the tagged serde shape.
703        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    // ── Helper tests ───────────────────────────────────────────────
750
751    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    // derive_branch_path
811
812    #[test]
813    fn derive_branch_path_empty_for_top_level_construct() {
814        // Mirrors `single_if_fn`: a single if-branch at top level. Path is
815        // empty because nothing encloses line 9.
816        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        // Outer if [17, 25] depth 0 + inner if [18, 22] depth 1.
823        // start_line=18 picks the outer (encloses 18) but not the inner
824        // (it starts AT 18, doesn't enclose).
825        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        // Mirrors `for_with_continue_fn`: ForLoop > IfBranch > Continue.
835        // Path for line 93 (the Continue) ascends ForLoop → IfBranch.
836        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        // R6.3: no human prose, only `/`-joined ContributorKind discriminants.
847        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    // derive_coverage_gaps
861
862    #[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    // pick_deepest_nesting
907
908    #[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); // marked by dedup, not the selector
920    }
921
922    #[test]
923    fn pick_deepest_nesting_returns_none_for_flat_function() {
924        // Single top-level if has no nested constructs — DeepestNesting
925        // requires depth > 0.
926        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        // for_with_continue_fn: Continue is depth 2 but single-line.
933        // Selector falls back to the deepest *compound* contributor.
934        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    // pick_largest_subblock
944
945    #[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        // The sole compound construct covers the whole function — no
959        // viable extraction (would just be a self-rename).
960        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    // pick_highest_branch_count
965
966    #[test]
967    fn pick_highest_branch_count_picks_outer_with_inner_contributors() {
968        // outer if encloses inner if + inner continue = 2 inner;
969        // inner if encloses 1; continue encloses 0. Outer wins.
970        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        // Single compound with nothing inside → degenerate, no candidate.
983        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    // dedup_splits
988
989    #[test]
990    fn dedup_splits_keeps_highest_priority_for_duplicate_range() {
991        // Same range, three kinds. DeepestNesting wins by priority.
992        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        // Highest priority survivor = DeepestNesting; sort puts it first.
1058        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    // pick_actions — covers the root_cause Examples table
1068
1069    #[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        // Four IfBranch contributors out of five (80%) — dominant.
1138        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        // Even though IfBranch is 100% dominant, splits are non-empty so
1180        // we emit ExtractFunction (concrete) and skip SimplifyBranching.
1181        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    // compute_diagnostic — orchestrator
1201
1202    #[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        // Flat function (no contributors) with uncovered lines — only
1227        // path is AddTestsForLines.
1228        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// ── Property tests ──────────────────────────────────────────────────
1278
1279#[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        /// Non-empty `dedup_splits` output always has exactly one
1308        /// recommended entry.
1309        #[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        /// `dedup_splits` is idempotent — running it again on its own
1321        /// output produces the same result.
1322        #[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        /// `derive_branch_path` produces no whitespace and no commas —
1332        /// only `/` as separator (AST-derived chains only).
1333        #[test]
1334        fn branch_path_carries_no_whitespace_or_commas(
1335            depths in proptest::collection::vec(0u32..6, 0..10)
1336        ) {
1337            // Synthesize a fake nested chain: each contributor encloses
1338            // line 100 with strictly lower start lines.
1339            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        /// `dedup_splits` sorts by priority (highest first), so
1360        /// `result[0].kind` always has the maximum priority.
1361        #[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}