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]` paused for v0.5 (see types::SourceSpan). Restored at v1.0.
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]` paused for v0.5 (see types::SourceSpan). Restored at v1.0.
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]` paused for v0.5 (see types::SourceSpan). Restored at v1.0.
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                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    // derive_branch_path
810
811    #[test]
812    fn derive_branch_path_empty_for_top_level_construct() {
813        // Mirrors `single_if_fn`: a single if-branch at top level. Path is
814        // empty because nothing encloses line 9.
815        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        // Outer if [17, 25] depth 0 + inner if [18, 22] depth 1.
822        // start_line=18 picks the outer (encloses 18) but not the inner
823        // (it starts AT 18, doesn't enclose).
824        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        // Mirrors `for_with_continue_fn`: ForLoop > IfBranch > Continue.
834        // Path for line 93 (the Continue) ascends ForLoop → IfBranch.
835        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        // R6.3: no human prose, only `/`-joined ContributorKind discriminants.
846        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    // derive_coverage_gaps
860
861    #[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    // pick_deepest_nesting
906
907    #[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); // marked by dedup, not the selector
919    }
920
921    #[test]
922    fn pick_deepest_nesting_returns_none_for_flat_function() {
923        // Single top-level if has no nested constructs — DeepestNesting
924        // requires depth > 0.
925        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        // for_with_continue_fn: Continue is depth 2 but single-line.
932        // Selector falls back to the deepest *compound* contributor.
933        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    // pick_largest_subblock
943
944    #[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        // The sole compound construct covers the whole function — no
958        // viable extraction (would just be a self-rename).
959        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    // pick_highest_branch_count
964
965    #[test]
966    fn pick_highest_branch_count_picks_outer_with_inner_contributors() {
967        // outer if encloses inner if + inner continue = 2 inner;
968        // inner if encloses 1; continue encloses 0. Outer wins.
969        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        // Single compound with nothing inside → degenerate, no candidate.
982        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    // dedup_splits
987
988    #[test]
989    fn dedup_splits_keeps_highest_priority_for_duplicate_range() {
990        // Same range, three kinds. DeepestNesting wins by priority.
991        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        // Highest priority survivor = DeepestNesting; sort puts it first.
1057        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    // pick_actions — covers the root_cause Examples table
1067
1068    #[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        // Four IfBranch contributors out of five (80%) — dominant.
1137        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        // Even though IfBranch is 100% dominant, splits are non-empty so
1179        // we emit ExtractFunction (concrete) and skip SimplifyBranching.
1180        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    // compute_diagnostic — orchestrator
1200
1201    #[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        // Flat function (no contributors) with uncovered lines — only
1226        // path is AddTestsForLines.
1227        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// ── Property tests ──────────────────────────────────────────────────
1277
1278#[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        /// Non-empty `dedup_splits` output always has exactly one
1307        /// recommended entry.
1308        #[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        /// `dedup_splits` is idempotent — running it again on its own
1320        /// output produces the same result.
1321        #[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        /// `derive_branch_path` produces no whitespace and no commas —
1331        /// only `/` as separator (AST-derived chains only).
1332        #[test]
1333        fn branch_path_carries_no_whitespace_or_commas(
1334            depths in proptest::collection::vec(0u32..6, 0..10)
1335        ) {
1336            // Synthesize a fake nested chain: each contributor encloses
1337            // line 100 with strictly lower start lines.
1338            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        /// `dedup_splits` sorts by priority (highest first), so
1359        /// `result[0].kind` always has the maximum priority.
1360        #[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}