Skip to main content

crap_core/domain/
delta.rs

1//! Domain-level Delta abstraction — pure-domain comparison primitive
2//! between two `AnalysisResult` values (baseline + current).
3//!
4//! ```text
5//! delta::compute(baseline, current) → AnalysisDelta
6//! ```
7//!
8//! Sibling to [`crate::domain::view`] (ADR D7 §DeltaView). The Delta
9//! identifies functions Added / Removed / Modified across two analyses
10//! and computes summary aggregates including a *new-violations* count
11//! that surfaces only the threshold breaches this delta introduced —
12//! pre-existing debt is not double-counted as a new regression.
13//!
14//! Pure domain code — no I/O, no `syn`, no `PathBuf` semantics. Future
15//! `crap-core` extraction takes this module whole.
16
17use crate::domain::types::{AnalysisResult, FunctionIdentity, FunctionVerdict};
18use serde::Serialize;
19use std::cmp::Ordering;
20use std::collections::{BTreeSet, HashMap};
21
22// ── Change kinds ─────────────────────────────────────────────────────
23
24/// Classification of a single function across the baseline → current
25/// transition.
26///
27/// Tag-only enum used in `DeltaSummary` and the JSON envelope. The
28/// per-function payload lives on [`FunctionChange`]; this enum is for
29/// shaping (filter / sort) and presentation. `#[non_exhaustive]`
30/// reserves namespace for future variants like `Renamed` (ADR D7
31/// §Future Compat — rename detection is a v0.3.0 candidate).
32#[non_exhaustive]
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ChangeKind {
36    Added,
37    Removed,
38    Modified,
39}
40
41impl ChangeKind {
42    /// All known variants, in canonical order. Used by CLI parsers and
43    /// serializers that enumerate the universe.
44    pub const ALL: [ChangeKind; 3] = [ChangeKind::Added, ChangeKind::Removed, ChangeKind::Modified];
45
46    pub fn as_str(&self) -> &'static str {
47        self.as_wire_str()
48    }
49
50    /// Canonical wire string — equal to the serde JSON representation
51    /// (sans quotes). See `ContributorKind::as_wire_str` for the
52    /// 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            ChangeKind::Added => "added",
57            ChangeKind::Removed => "removed",
58            ChangeKind::Modified => "modified",
59        }
60    }
61}
62
63// ── Per-function change payload ──────────────────────────────────────
64
65/// A single function's change between baseline and current.
66///
67/// Variants carry the full [`FunctionVerdict`] for both sides where
68/// applicable so reporters can render baseline / current scores side by
69/// side without re-querying the original `AnalysisResult`s. The
70/// [`Modified`](FunctionChange::Modified) variant is the dominant case
71/// for established codebases; `Added` and `Removed` track structural
72/// drift.
73///
74/// `Unchanged` is *not* a variant. The View pipeline only surfaces
75/// changes; if downstream tooling needs to enumerate all current
76/// functions it consumes `current.functions` directly.
77#[non_exhaustive]
78#[derive(Debug, Clone, Serialize)]
79#[serde(tag = "kind", rename_all = "lowercase")]
80pub enum FunctionChange {
81    Added {
82        current: FunctionVerdict,
83    },
84    Removed {
85        baseline: FunctionVerdict,
86    },
87    Modified {
88        baseline: FunctionVerdict,
89        current: FunctionVerdict,
90    },
91}
92
93impl FunctionChange {
94    pub fn kind(&self) -> ChangeKind {
95        match self {
96            FunctionChange::Added { .. } => ChangeKind::Added,
97            FunctionChange::Removed { .. } => ChangeKind::Removed,
98            FunctionChange::Modified { .. } => ChangeKind::Modified,
99        }
100    }
101
102    pub fn current_score(&self) -> Option<f64> {
103        match self {
104            FunctionChange::Added { current } => Some(current.scored.crap.value),
105            FunctionChange::Modified { current, .. } => Some(current.scored.crap.value),
106            FunctionChange::Removed { .. } => None,
107        }
108    }
109
110    pub fn baseline_score(&self) -> Option<f64> {
111        match self {
112            FunctionChange::Removed { baseline } => Some(baseline.scored.crap.value),
113            FunctionChange::Modified { baseline, .. } => Some(baseline.scored.crap.value),
114            FunctionChange::Added { .. } => None,
115        }
116    }
117
118    /// `current - baseline` for `Modified`; `None` for `Added` / `Removed`.
119    pub fn score_delta(&self) -> Option<f64> {
120        match self {
121            FunctionChange::Modified { baseline, current } => {
122                Some(current.scored.crap.value - baseline.scored.crap.value)
123            }
124            _ => None,
125        }
126    }
127
128    /// File path associated with this change. For Modified, baseline
129    /// and current share the same path (matching keys on file_path).
130    pub fn file_path(&self) -> &str {
131        match self {
132            FunctionChange::Added { current } => &current.scored.identity.file_path,
133            FunctionChange::Removed { baseline } => &baseline.scored.identity.file_path,
134            FunctionChange::Modified { current, .. } => &current.scored.identity.file_path,
135        }
136    }
137
138    /// Qualified name associated with this change.
139    pub fn qualified_name(&self) -> &str {
140        match self {
141            FunctionChange::Added { current } => &current.scored.identity.qualified_name,
142            FunctionChange::Removed { baseline } => &baseline.scored.identity.qualified_name,
143            FunctionChange::Modified { current, .. } => &current.scored.identity.qualified_name,
144        }
145    }
146}
147
148// ── Summary ──────────────────────────────────────────────────────────
149
150/// Aggregate counts over a set of [`FunctionChange`]s.
151///
152/// `passed` is the **delta gate**: true iff `new_violations == 0`. The
153/// CLI gates exit code on this only when `--delta-gate` is passed;
154/// otherwise the delta is informational and `result.passed` alone
155/// drives the exit code.
156#[non_exhaustive]
157#[derive(Debug, Clone, Copy, Default, Serialize)]
158pub struct DeltaSummary {
159    pub added: u32,
160    pub removed: u32,
161    pub modified: u32,
162    /// Modified rows where `score_delta > 0` (current got worse).
163    pub regressions: u32,
164    /// Modified rows where `score_delta < 0` (current got better).
165    pub improvements: u32,
166    /// Threshold breaches *introduced* by this delta:
167    /// - `Added` rows whose `current.exceeds == true`
168    /// - `Modified` rows where `baseline.exceeds == false` AND `current.exceeds == true`
169    ///
170    /// Pre-existing violations (Modified rows where `baseline.exceeds`
171    /// was already true) do NOT contribute. This distinction matters
172    /// for the delta gate — we want to fail PRs that *introduce* risk,
173    /// not PRs that merely touch already-failing functions.
174    pub new_violations: u32,
175    /// `new_violations == 0`. Drives the optional `--delta-gate`.
176    pub passed: bool,
177}
178
179impl DeltaSummary {
180    pub fn compute(changes: &[FunctionChange]) -> Self {
181        let mut summary = Self::default();
182        for change in changes {
183            tally(&mut summary, change);
184        }
185        summary.passed = summary.new_violations == 0;
186        summary
187    }
188}
189
190fn tally(summary: &mut DeltaSummary, change: &FunctionChange) {
191    match change {
192        FunctionChange::Added { current } => {
193            summary.added += 1;
194            if current.exceeds {
195                summary.new_violations += 1;
196            }
197        }
198        FunctionChange::Removed { .. } => {
199            summary.removed += 1;
200        }
201        FunctionChange::Modified { baseline, current } => {
202            summary.modified += 1;
203            tally_modified(summary, baseline, current);
204        }
205    }
206}
207
208fn tally_modified(
209    summary: &mut DeltaSummary,
210    baseline: &FunctionVerdict,
211    current: &FunctionVerdict,
212) {
213    let delta = current.scored.crap.value - baseline.scored.crap.value;
214    if delta > 0.0 {
215        summary.regressions += 1;
216    } else if delta < 0.0 {
217        summary.improvements += 1;
218    }
219    if !baseline.exceeds && current.exceeds {
220        summary.new_violations += 1;
221    }
222}
223
224// ── AnalysisDelta ────────────────────────────────────────────────────
225
226/// The product of comparing two [`AnalysisResult`]s.
227///
228/// `baseline` and `current` are owned (consumed by [`compute`]) so
229/// downstream borrows remain valid for the whole `AnalysisDelta`
230/// lifetime. The `changes` vector contains every Added / Removed /
231/// Modified function — never `Unchanged`. Reporter consumers iterate
232/// `changes`; the delta gate consumes `summary` (via
233/// [`DeltaSummary::compute`]) once the changes are known.
234#[non_exhaustive]
235#[derive(Debug, Clone, Serialize)]
236pub struct AnalysisDelta {
237    /// Owned baseline analysis. `#[serde(skip)]` because the JSON
238    /// envelope's `result_baseline` (future work) or the existing
239    /// `result` block carry the canonical baseline / current data;
240    /// double-emitting it inside `delta` would bloat the payload.
241    /// In-memory consumers (reporters) borrow it.
242    #[serde(skip)]
243    pub baseline: AnalysisResult,
244    #[serde(skip)]
245    pub current: AnalysisResult,
246    pub changes: Vec<FunctionChange>,
247    /// Aggregate counts over `changes`. Computed once at construction
248    /// (in [`compute`]) so reporters and the delta gate share a
249    /// single source of truth — pre-shape, view-independent.
250    pub summary: DeltaSummary,
251}
252
253// ── compute: pair → classify ────────────────────────────────────────
254
255/// Identity tuple used to match functions across the baseline → current
256/// transition. **Span (line range) is intentionally excluded** — line
257/// numbers shift when surrounding code is edited, and we want a
258/// minor edit to `foo` to register as `Modified`, not `Removed` +
259/// `Added`. Renames within a file (different `qualified_name`) and
260/// moves across files (different `file_path`) both fall through as
261/// `Removed` + `Added` until rename detection ships (v0.3.0+).
262type IdentityKey<'a> = (&'a str, &'a str);
263
264fn identity_key(identity: &FunctionIdentity) -> IdentityKey<'_> {
265    (&identity.file_path, &identity.qualified_name)
266}
267
268/// Compare two analyses, classifying every function as Added, Removed,
269/// or Modified. Stable: `current`'s order is preserved for matched +
270/// added rows, then baseline-only (`Removed`) rows trail.
271///
272/// Decomposed into a private helper `pair_identities` to keep the
273/// public surface declarative and to localize the HashMap construction
274/// for testing.
275pub fn compute(baseline: AnalysisResult, current: AnalysisResult) -> AnalysisDelta {
276    let changes = pair_identities(&baseline, &current);
277    let summary = DeltaSummary::compute(&changes);
278    AnalysisDelta {
279        baseline,
280        current,
281        changes,
282        summary,
283    }
284}
285
286fn pair_identities(baseline: &AnalysisResult, current: &AnalysisResult) -> Vec<FunctionChange> {
287    // Index baseline by identity key, single pass. We track which
288    // baseline entries we've matched so the leftover can be emitted as
289    // `Removed` rows after the current sweep.
290    let mut baseline_index: HashMap<IdentityKey<'_>, &FunctionVerdict> =
291        HashMap::with_capacity(baseline.functions.len());
292    for verdict in &baseline.functions {
293        baseline_index.insert(identity_key(&verdict.scored.identity), verdict);
294    }
295
296    let mut changes: Vec<FunctionChange> =
297        Vec::with_capacity(current.functions.len() + baseline.functions.len());
298
299    for current_verdict in &current.functions {
300        let key = identity_key(&current_verdict.scored.identity);
301        match baseline_index.remove(&key) {
302            Some(baseline_verdict) => changes.push(FunctionChange::Modified {
303                baseline: baseline_verdict.clone(),
304                current: current_verdict.clone(),
305            }),
306            None => changes.push(FunctionChange::Added {
307                current: current_verdict.clone(),
308            }),
309        }
310    }
311
312    // Leftover baseline entries are Removed. `HashMap` iteration order
313    // is unspecified, so we sort by identity key before emission —
314    // otherwise consumers that iterate `delta.changes` directly (or
315    // apply a sort that doesn't break ties on identity) observe
316    // run-to-run flakiness. Identity-key sort is cheap, deterministic,
317    // and gives a stable presentation order that mirrors the lexical
318    // ordering most operators expect.
319    let mut leftover: Vec<&FunctionVerdict> = baseline_index.into_values().collect();
320    leftover
321        .sort_by(|a, b| identity_key(&a.scored.identity).cmp(&identity_key(&b.scored.identity)));
322    for baseline_verdict in leftover {
323        changes.push(FunctionChange::Removed {
324            baseline: baseline_verdict.clone(),
325        });
326    }
327
328    changes
329}
330
331// ── DeltaView (filter / sort / truncate) ─────────────────────────────
332
333/// Spec describing how to shape the per-change row list for display.
334///
335/// Mirrors [`crate::domain::view::ViewSpec`] in structure but with
336/// delta-specific filter and sort dimensions. The summary on the
337/// underlying [`AnalysisDelta`] is *not* re-derived from the shaped
338/// row list — it always reflects the unshaped change set so the gate
339/// keystone holds.
340#[non_exhaustive]
341#[derive(Debug, Clone, Default, Serialize)]
342pub struct DeltaViewSpec {
343    pub filters: DeltaFilters,
344    pub sort: DeltaSortKey,
345    pub limit: Option<usize>,
346}
347
348#[non_exhaustive]
349#[derive(Debug, Clone, Default, Serialize)]
350pub struct DeltaFilters {
351    /// When `Some`, retain only changes whose [`ChangeKind`] is in the
352    /// set. `None` means "all kinds." A `Some(empty)` set retains
353    /// nothing — that's a valid (if pointless) configuration the
354    /// shape pipeline doesn't second-guess.
355    pub change_kinds: Option<BTreeSet<ChangeKind>>,
356    /// Inclusive lower bound on `score_delta`. `None` = no bound.
357    /// Only applies to [`FunctionChange::Modified`] entries —
358    /// `Added` / `Removed` have no score_delta and pass the bound
359    /// check unconditionally.
360    pub min_score_delta: Option<f64>,
361    /// Inclusive upper bound on `score_delta`. Same conventions as
362    /// `min_score_delta`.
363    pub max_score_delta: Option<f64>,
364}
365
366/// Sort key for the displayed delta view.
367///
368/// `ScoreDelta` (default) ranks rows by *signed impact*, descending —
369/// regressions first. `Modified` uses `current - baseline`; `Added`
370/// uses `+current.crap` (a new function exists where there was none —
371/// pure load); `Removed` uses `-baseline.crap` (a function went
372/// away — pure relief). Sort descending: regressions and risky
373/// additions land at the top of the scorecard, improvements and
374/// benign removals at the bottom.
375#[non_exhaustive]
376#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
377#[serde(rename_all = "snake_case")]
378pub enum DeltaSortKey {
379    /// Magnitude of change descending (regressions first).
380    #[default]
381    ScoreDelta,
382    /// Current CRAP score descending. `Removed` rows (no current
383    /// score) sort last.
384    CurrentCrap,
385    /// Baseline CRAP score descending. `Added` rows sort last.
386    BaselineCrap,
387    /// Alphabetical by `file_path`, then `qualified_name`.
388    Path,
389}
390
391impl DeltaSortKey {
392    /// Canonical wire string — see `ContributorKind::as_wire_str`.
393    pub fn as_wire_str(&self) -> &'static str {
394        match self {
395            Self::ScoreDelta => "score_delta",
396            Self::CurrentCrap => "current_crap",
397            Self::BaselineCrap => "baseline_crap",
398            Self::Path => "path",
399        }
400    }
401}
402
403/// Shaped view over an [`AnalysisDelta`].
404///
405/// `full` borrows the parent delta — the gate keystone: shaping never
406/// mutates the underlying delta, and the summary surfaced through
407/// reporters always derives from `full.summary`, not `shown`.
408#[non_exhaustive]
409#[derive(Debug, Serialize)]
410pub struct DeltaView<'a> {
411    /// Borrow of the parent. Skipped in serialization — the JSON
412    /// envelope already carries the summary + full change list under
413    /// the `delta` block.
414    #[serde(skip)]
415    pub full: &'a AnalysisDelta,
416    pub spec: DeltaViewSpec,
417    /// Post-filter, pre-truncate count. Combined with `truncated`,
418    /// lets consumers render "Showing N of M (filtered from K)".
419    pub eligible_count: usize,
420    pub truncated: bool,
421    pub shown: Vec<&'a FunctionChange>,
422}
423
424/// Shape an [`AnalysisDelta`] into a [`DeltaView`].
425///
426/// Order of operations: filter → sort → truncate. Mirrors the
427/// `view::apply` pattern. The full delta and its summary are
428/// untouched.
429pub fn apply<'a>(delta: &'a AnalysisDelta, spec: DeltaViewSpec) -> DeltaView<'a> {
430    let mut shown: Vec<&'a FunctionChange> = apply_filters(&delta.changes, &spec.filters);
431    let eligible_count = shown.len();
432    sort_in_place(&mut shown, spec.sort);
433    let truncated = truncate_to(&mut shown, spec.limit);
434    DeltaView {
435        full: delta,
436        spec,
437        eligible_count,
438        truncated,
439        shown,
440    }
441}
442
443fn apply_filters<'a>(
444    changes: &'a [FunctionChange],
445    filters: &DeltaFilters,
446) -> Vec<&'a FunctionChange> {
447    changes
448        .iter()
449        .filter(|c| {
450            filters
451                .change_kinds
452                .as_ref()
453                .is_none_or(|kinds| kinds.contains(&c.kind()))
454        })
455        .filter(|c| matches_score_delta_range(c, filters))
456        .collect()
457}
458
459fn matches_score_delta_range(change: &FunctionChange, filters: &DeltaFilters) -> bool {
460    let Some(delta) = change.score_delta() else {
461        // Added / Removed have no score_delta — pass through both
462        // bounds (filtering them out is the operator's job via
463        // `change_kinds`).
464        return true;
465    };
466    let bounded = filters.min_score_delta.is_some() || filters.max_score_delta.is_some();
467    if bounded && !delta.is_finite() {
468        // Reject non-finite deltas only when a bound is in play —
469        // otherwise a corrupt baseline silently disappears from the
470        // delta view while still counting in the summary. Without
471        // bounds, pass everything through; with bounds, the
472        // comparison would be undefined anyway.
473        return false;
474    }
475    if filters.min_score_delta.is_some_and(|min| delta < min) {
476        return false;
477    }
478    if filters.max_score_delta.is_some_and(|max| delta > max) {
479        return false;
480    }
481    true
482}
483
484fn sort_in_place(shown: &mut [&FunctionChange], key: DeltaSortKey) {
485    match key {
486        DeltaSortKey::ScoreDelta => shown.sort_by(cmp_by_score_delta_desc),
487        DeltaSortKey::CurrentCrap => shown.sort_by(cmp_by_current_crap_desc),
488        DeltaSortKey::BaselineCrap => shown.sort_by(cmp_by_baseline_crap_desc),
489        DeltaSortKey::Path => shown.sort_by(cmp_by_path),
490    }
491}
492
493/// Signed-impact ordering: regressions and risky additions sort to the
494/// top, improvements and benign removals to the bottom. `Modified`
495/// uses `current - baseline`; `Added` is treated as `+current.crap`
496/// (introducing load); `Removed` is treated as `-baseline.crap`
497/// (shedding load).
498fn cmp_by_score_delta_desc(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
499    cmp_f64_desc(signed_impact(a), signed_impact(b))
500}
501
502fn signed_impact(change: &FunctionChange) -> f64 {
503    match change {
504        FunctionChange::Modified { baseline, current } => {
505            current.scored.crap.value - baseline.scored.crap.value
506        }
507        FunctionChange::Added { current } => current.scored.crap.value,
508        FunctionChange::Removed { baseline } => -baseline.scored.crap.value,
509    }
510}
511
512fn cmp_by_current_crap_desc(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
513    // Removed entries (no current score) sort last under any
514    // current-crap ordering. `Option::None` < `Some(_)` ascending,
515    // so we invert by mapping Some → 0 (front) and None → 1 (back).
516    let (rank_a, score_a) = current_score_rank(a);
517    let (rank_b, score_b) = current_score_rank(b);
518    rank_a.cmp(&rank_b).then(cmp_f64_desc(score_a, score_b))
519}
520
521fn current_score_rank(change: &FunctionChange) -> (u8, f64) {
522    match change.current_score() {
523        Some(s) => (0, s),
524        None => (1, 0.0),
525    }
526}
527
528fn cmp_by_baseline_crap_desc(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
529    let (rank_a, score_a) = baseline_score_rank(a);
530    let (rank_b, score_b) = baseline_score_rank(b);
531    rank_a.cmp(&rank_b).then(cmp_f64_desc(score_a, score_b))
532}
533
534fn baseline_score_rank(change: &FunctionChange) -> (u8, f64) {
535    match change.baseline_score() {
536        Some(s) => (0, s),
537        None => (1, 0.0),
538    }
539}
540
541fn cmp_by_path(a: &&FunctionChange, b: &&FunctionChange) -> Ordering {
542    a.file_path()
543        .cmp(b.file_path())
544        .then_with(|| a.qualified_name().cmp(b.qualified_name()))
545}
546
547/// Total f64 ordering, descending. NaN sorts last so non-finite
548/// scores never break the comparator.
549fn cmp_f64_desc(a: f64, b: f64) -> Ordering {
550    match (a.is_nan(), b.is_nan()) {
551        (true, true) => Ordering::Equal,
552        (true, false) => Ordering::Greater,
553        (false, true) => Ordering::Less,
554        (false, false) => b.partial_cmp(&a).expect("non-NaN partial_cmp infallible"),
555    }
556}
557
558fn truncate_to(shown: &mut Vec<&FunctionChange>, limit: Option<usize>) -> bool {
559    match limit {
560        Some(n) if n > 0 && shown.len() > n => {
561            shown.truncate(n);
562            true
563        }
564        _ => false,
565    }
566}
567
568// ── Tests ────────────────────────────────────────────────────────────
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::domain::types::{
574        AnalysisSummary, ComplexityMetric, CrapScore, FunctionIdentity, FunctionVerdict,
575        RiskDistribution, RiskLevel, ScoredFunction, SourceSpan,
576    };
577
578    fn make_verdict(file: &str, name: &str, score: f64, exceeds: bool) -> FunctionVerdict {
579        FunctionVerdict {
580            scored: ScoredFunction {
581                identity: FunctionIdentity {
582                    file_path: file.to_string(),
583                    qualified_name: name.to_string(),
584                    span: SourceSpan {
585                        start_line: 1,
586                        end_line: 5,
587                        start_column: 0,
588                        end_column: 0,
589                    },
590                },
591                complexity: 5,
592                complexity_metric: ComplexityMetric::Cognitive,
593                coverage_percent: 50.0,
594                crap: CrapScore {
595                    value: score,
596                    risk_level: if score > 30.0 {
597                        RiskLevel::High
598                    } else if score > 8.0 {
599                        RiskLevel::Moderate
600                    } else if score > 5.0 {
601                        RiskLevel::Acceptable
602                    } else {
603                        RiskLevel::Low
604                    },
605                },
606                contributors: vec![],
607            },
608            threshold: 25.0,
609            exceeds,
610            diagnostic: None,
611        }
612    }
613
614    fn make_result(verdicts: Vec<FunctionVerdict>) -> AnalysisResult {
615        let exceeding = verdicts.iter().filter(|v| v.exceeds).count();
616        let total = verdicts.len();
617        AnalysisResult {
618            functions: verdicts,
619            summary: AnalysisSummary {
620                total_functions: total,
621                total_files: 1,
622                exceeding_threshold: exceeding,
623                average_crap: 0.0,
624                median_crap: 0.0,
625                max_crap: None,
626                worst_function: None,
627                distribution: RiskDistribution {
628                    low: 0,
629                    acceptable: 0,
630                    moderate: 0,
631                    high: 0,
632                },
633                ..Default::default()
634            },
635            passed: exceeding == 0,
636        }
637    }
638
639    // ── classification ──
640
641    #[test]
642    fn compute_identity_yields_all_modified_zero_delta() {
643        let result = make_result(vec![
644            make_verdict("a.rs", "alpha", 5.0, false),
645            make_verdict("a.rs", "beta", 12.0, false),
646            make_verdict("b.rs", "gamma", 47.0, true),
647        ]);
648        let delta = compute(result.clone(), result);
649        assert_eq!(delta.changes.len(), 3);
650        for change in &delta.changes {
651            assert!(matches!(change, FunctionChange::Modified { .. }));
652            assert_eq!(change.score_delta(), Some(0.0));
653        }
654    }
655
656    #[test]
657    fn compute_classifies_added_function() {
658        let baseline = make_result(vec![]);
659        let current = make_result(vec![make_verdict("a.rs", "new_fn", 10.0, false)]);
660        let delta = compute(baseline, current);
661        assert_eq!(delta.changes.len(), 1);
662        assert!(matches!(delta.changes[0], FunctionChange::Added { .. }));
663        assert_eq!(delta.changes[0].current_score(), Some(10.0));
664        assert_eq!(delta.changes[0].baseline_score(), None);
665        assert_eq!(delta.changes[0].score_delta(), None);
666    }
667
668    #[test]
669    fn compute_classifies_removed_function() {
670        let baseline = make_result(vec![make_verdict("a.rs", "old_fn", 8.0, false)]);
671        let current = make_result(vec![]);
672        let delta = compute(baseline, current);
673        assert_eq!(delta.changes.len(), 1);
674        assert!(matches!(delta.changes[0], FunctionChange::Removed { .. }));
675        assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
676        assert_eq!(delta.changes[0].current_score(), None);
677    }
678
679    #[test]
680    fn compute_classifies_modified_function() {
681        let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 8.0, false)]);
682        let current = make_result(vec![make_verdict("a.rs", "fn_a", 24.0, false)]);
683        let delta = compute(baseline, current);
684        assert_eq!(delta.changes.len(), 1);
685        assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
686        assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
687        assert_eq!(delta.changes[0].current_score(), Some(24.0));
688        assert_eq!(delta.changes[0].score_delta(), Some(16.0));
689    }
690
691    #[test]
692    fn compute_same_name_different_files_are_separate() {
693        let baseline = make_result(vec![make_verdict("a.rs", "log", 5.0, false)]);
694        let current = make_result(vec![make_verdict("b.rs", "log", 5.0, false)]);
695        let delta = compute(baseline, current);
696        assert_eq!(delta.changes.len(), 2);
697        let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
698        assert!(kinds.contains(&ChangeKind::Added));
699        assert!(kinds.contains(&ChangeKind::Removed));
700    }
701
702    #[test]
703    fn compute_same_file_rename_produces_add_remove() {
704        let baseline = make_result(vec![make_verdict("a.rs", "v1", 5.0, false)]);
705        let current = make_result(vec![make_verdict("a.rs", "v2", 5.0, false)]);
706        let delta = compute(baseline, current);
707        assert_eq!(delta.changes.len(), 2);
708        let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
709        assert!(kinds.contains(&ChangeKind::Added));
710        assert!(kinds.contains(&ChangeKind::Removed));
711    }
712
713    #[test]
714    fn compute_ignores_span_when_matching() {
715        // Same identity (file, name), different spans -> Modified, not Add+Remove
716        let mut baseline_v = make_verdict("a.rs", "fn_a", 5.0, false);
717        baseline_v.scored.identity.span = SourceSpan {
718            start_line: 1,
719            end_line: 5,
720            start_column: 0,
721            end_column: 0,
722        };
723        let mut current_v = make_verdict("a.rs", "fn_a", 5.0, false);
724        current_v.scored.identity.span = SourceSpan {
725            start_line: 100,
726            end_line: 105,
727            start_column: 0,
728            end_column: 0,
729        };
730        let delta = compute(make_result(vec![baseline_v]), make_result(vec![current_v]));
731        assert_eq!(delta.changes.len(), 1);
732        assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
733    }
734
735    #[test]
736    fn removed_rows_are_emitted_in_identity_key_order() {
737        // Removed-row determinism (CodeRabbit PR #97): pair_identities
738        // collects leftover baseline entries from a HashMap; iterating
739        // the map directly produces non-deterministic order. Sort by
740        // (file_path, qualified_name) before emission so consumers
741        // that don't apply a tie-breaking sort see a stable order.
742        let baseline = make_result(vec![
743            make_verdict("zeta.rs", "zeta_fn", 5.0, false),
744            make_verdict("alpha.rs", "alpha_fn", 5.0, false),
745            make_verdict("beta.rs", "beta_fn", 5.0, false),
746        ]);
747        // Empty current → all baseline entries are Removed.
748        let current = make_result(vec![]);
749        let delta = compute(baseline, current);
750
751        // Should be sorted by (file_path, qualified_name) ascending —
752        // alpha.rs < beta.rs < zeta.rs.
753        assert_eq!(delta.changes.len(), 3);
754        assert_eq!(delta.changes[0].file_path(), "alpha.rs");
755        assert_eq!(delta.changes[1].file_path(), "beta.rs");
756        assert_eq!(delta.changes[2].file_path(), "zeta.rs");
757    }
758
759    // ── summary counts ──
760
761    #[test]
762    fn summary_counts_added_removed_modified() {
763        let changes = vec![
764            FunctionChange::Added {
765                current: make_verdict("a.rs", "new", 5.0, false),
766            },
767            FunctionChange::Removed {
768                baseline: make_verdict("a.rs", "old", 5.0, false),
769            },
770            FunctionChange::Modified {
771                baseline: make_verdict("a.rs", "fn_a", 5.0, false),
772                current: make_verdict("a.rs", "fn_a", 8.0, false),
773            },
774        ];
775        let summary = DeltaSummary::compute(&changes);
776        assert_eq!(summary.added, 1);
777        assert_eq!(summary.removed, 1);
778        assert_eq!(summary.modified, 1);
779    }
780
781    #[test]
782    fn summary_regressions_are_modified_with_positive_delta() {
783        let changes = vec![FunctionChange::Modified {
784            baseline: make_verdict("a.rs", "fn_a", 5.0, false),
785            current: make_verdict("a.rs", "fn_a", 10.0, false),
786        }];
787        let summary = DeltaSummary::compute(&changes);
788        assert_eq!(summary.regressions, 1);
789        assert_eq!(summary.improvements, 0);
790    }
791
792    #[test]
793    fn summary_improvements_are_modified_with_negative_delta() {
794        let changes = vec![FunctionChange::Modified {
795            baseline: make_verdict("a.rs", "fn_a", 47.0, true),
796            current: make_verdict("a.rs", "fn_a", 12.0, false),
797        }];
798        let summary = DeltaSummary::compute(&changes);
799        assert_eq!(summary.regressions, 0);
800        assert_eq!(summary.improvements, 1);
801    }
802
803    #[test]
804    fn summary_zero_delta_neither_regression_nor_improvement() {
805        let changes = vec![FunctionChange::Modified {
806            baseline: make_verdict("a.rs", "fn_a", 5.0, false),
807            current: make_verdict("a.rs", "fn_a", 5.0, false),
808        }];
809        let summary = DeltaSummary::compute(&changes);
810        assert_eq!(summary.regressions, 0);
811        assert_eq!(summary.improvements, 0);
812    }
813
814    #[test]
815    fn summary_new_violation_added_function_failing() {
816        let changes = vec![FunctionChange::Added {
817            current: make_verdict("a.rs", "new_bad", 31.0, true),
818        }];
819        let summary = DeltaSummary::compute(&changes);
820        assert_eq!(summary.new_violations, 1);
821        assert!(!summary.passed);
822    }
823
824    #[test]
825    fn summary_new_violation_modified_crossing_threshold() {
826        let changes = vec![FunctionChange::Modified {
827            baseline: make_verdict("a.rs", "fn_a", 8.0, false),
828            current: make_verdict("a.rs", "fn_a", 47.0, true),
829        }];
830        let summary = DeltaSummary::compute(&changes);
831        assert_eq!(summary.new_violations, 1);
832        assert_eq!(summary.regressions, 1);
833    }
834
835    #[test]
836    fn summary_no_new_violation_when_modified_still_passing() {
837        let changes = vec![FunctionChange::Modified {
838            baseline: make_verdict("a.rs", "fn_a", 8.0, false),
839            current: make_verdict("a.rs", "fn_a", 20.0, false),
840        }];
841        let summary = DeltaSummary::compute(&changes);
842        assert_eq!(summary.regressions, 1);
843        assert_eq!(summary.new_violations, 0);
844        assert!(summary.passed);
845    }
846
847    #[test]
848    fn summary_pre_existing_violation_does_not_count_as_new() {
849        let changes = vec![FunctionChange::Modified {
850            baseline: make_verdict("a.rs", "fn_a", 47.0, true),
851            current: make_verdict("a.rs", "fn_a", 60.0, true),
852        }];
853        let summary = DeltaSummary::compute(&changes);
854        assert_eq!(summary.regressions, 1);
855        assert_eq!(summary.new_violations, 0);
856        assert!(summary.passed);
857    }
858
859    #[test]
860    fn summary_added_passing_function_not_a_new_violation() {
861        let changes = vec![FunctionChange::Added {
862            current: make_verdict("a.rs", "new_good", 5.0, false),
863        }];
864        let summary = DeltaSummary::compute(&changes);
865        assert_eq!(summary.added, 1);
866        assert_eq!(summary.new_violations, 0);
867    }
868
869    #[test]
870    fn summary_removed_function_never_counts_as_new_violation() {
871        let changes = vec![FunctionChange::Removed {
872            baseline: make_verdict("a.rs", "old_bad", 47.0, true),
873        }];
874        let summary = DeltaSummary::compute(&changes);
875        assert_eq!(summary.removed, 1);
876        assert_eq!(summary.new_violations, 0);
877        assert!(summary.passed);
878    }
879
880    #[test]
881    fn summary_passed_iff_new_violations_zero() {
882        let zero = DeltaSummary::compute(&[]);
883        assert!(zero.passed);
884
885        let with_new = DeltaSummary::compute(&[FunctionChange::Added {
886            current: make_verdict("a.rs", "bad", 31.0, true),
887        }]);
888        assert!(!with_new.passed);
889    }
890
891    // ── change accessors ──
892
893    #[test]
894    fn change_kind_serializes_lowercase() {
895        assert_eq!(
896            serde_json::to_string(&ChangeKind::Added).unwrap(),
897            "\"added\""
898        );
899        assert_eq!(
900            serde_json::to_string(&ChangeKind::Modified).unwrap(),
901            "\"modified\""
902        );
903        assert_eq!(
904            serde_json::to_string(&ChangeKind::Removed).unwrap(),
905            "\"removed\""
906        );
907    }
908
909    #[test]
910    fn change_kind_all_contains_every_variant() {
911        assert_eq!(ChangeKind::ALL.len(), 3);
912        assert!(ChangeKind::ALL.contains(&ChangeKind::Added));
913        assert!(ChangeKind::ALL.contains(&ChangeKind::Removed));
914        assert!(ChangeKind::ALL.contains(&ChangeKind::Modified));
915    }
916
917    #[test]
918    fn change_kind_as_str_matches_serde() {
919        for kind in ChangeKind::ALL {
920            let json = serde_json::to_string(&kind).unwrap();
921            let stripped = json.trim_matches('"');
922            assert_eq!(kind.as_str(), stripped);
923        }
924    }
925
926    #[test]
927    fn change_file_path_and_qualified_name_accessors() {
928        let added = FunctionChange::Added {
929            current: make_verdict("src/foo.rs", "module::fn_a", 5.0, false),
930        };
931        assert_eq!(added.file_path(), "src/foo.rs");
932        assert_eq!(added.qualified_name(), "module::fn_a");
933
934        let removed = FunctionChange::Removed {
935            baseline: make_verdict("src/bar.rs", "module::fn_b", 5.0, false),
936        };
937        assert_eq!(removed.file_path(), "src/bar.rs");
938
939        let modified = FunctionChange::Modified {
940            baseline: make_verdict("src/baz.rs", "module::fn_c", 5.0, false),
941            current: make_verdict("src/baz.rs", "module::fn_c", 10.0, false),
942        };
943        assert_eq!(modified.file_path(), "src/baz.rs");
944    }
945
946    // ── envelope ──
947
948    #[test]
949    fn analysis_delta_carries_baseline_current_and_changes() {
950        let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
951        let current = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
952        let delta = compute(baseline.clone(), current.clone());
953        assert_eq!(delta.baseline.functions.len(), baseline.functions.len());
954        assert_eq!(delta.current.functions.len(), current.functions.len());
955        assert_eq!(delta.changes.len(), 1);
956    }
957
958    #[test]
959    fn empty_inputs_produce_empty_delta() {
960        let delta = compute(make_result(vec![]), make_result(vec![]));
961        assert!(delta.changes.is_empty());
962        let summary = DeltaSummary::compute(&delta.changes);
963        assert_eq!(summary.added, 0);
964        assert_eq!(summary.removed, 0);
965        assert_eq!(summary.modified, 0);
966        assert!(summary.passed);
967    }
968
969    // ── DeltaView / apply ──
970
971    fn delta_with_changes(changes: Vec<FunctionChange>) -> AnalysisDelta {
972        AnalysisDelta {
973            baseline: make_result(vec![]),
974            current: make_result(vec![]),
975            summary: DeltaSummary::compute(&changes),
976            changes,
977        }
978    }
979
980    #[test]
981    fn apply_default_spec_returns_all_changes() {
982        let delta = delta_with_changes(vec![
983            FunctionChange::Added {
984                current: make_verdict("a.rs", "x", 31.0, true),
985            },
986            FunctionChange::Modified {
987                baseline: make_verdict("a.rs", "y", 5.0, false),
988                current: make_verdict("a.rs", "y", 10.0, false),
989            },
990        ]);
991        let view = apply(&delta, DeltaViewSpec::default());
992        assert_eq!(view.shown.len(), 2);
993        assert_eq!(view.eligible_count, 2);
994        assert!(!view.truncated);
995    }
996
997    #[test]
998    fn apply_default_sorts_by_signed_impact_descending() {
999        // Signed impacts: small_mod=+1, big_mod=+20, big_added=+31
1000        let delta = delta_with_changes(vec![
1001            FunctionChange::Modified {
1002                baseline: make_verdict("a.rs", "small_mod", 5.0, false),
1003                current: make_verdict("a.rs", "small_mod", 6.0, false),
1004            },
1005            FunctionChange::Modified {
1006                baseline: make_verdict("a.rs", "big_mod", 5.0, false),
1007                current: make_verdict("a.rs", "big_mod", 25.0, false),
1008            },
1009            FunctionChange::Added {
1010                current: make_verdict("a.rs", "big_added", 31.0, true),
1011            },
1012        ]);
1013        let view = apply(&delta, DeltaViewSpec::default());
1014        assert_eq!(view.shown[0].qualified_name(), "big_added");
1015        assert_eq!(view.shown[1].qualified_name(), "big_mod");
1016        assert_eq!(view.shown[2].qualified_name(), "small_mod");
1017    }
1018
1019    #[test]
1020    fn apply_default_sort_puts_regressions_above_improvements() {
1021        // Signed impacts: big_improvement=-25, small_regression=+5,
1022        // big_removed=-30 (Removed is treated as -baseline.crap),
1023        // big_added=+10. Ranking descending must be:
1024        //   small_regression (+5) > big_added (+10? no, +10 > +5)
1025        // Wait: +10 > +5, so big_added first, then small_regression,
1026        // then big_improvement (-25), then big_removed (-30).
1027        let delta = delta_with_changes(vec![
1028            FunctionChange::Modified {
1029                baseline: make_verdict("a.rs", "big_improvement", 30.0, true),
1030                current: make_verdict("a.rs", "big_improvement", 5.0, false),
1031            },
1032            FunctionChange::Modified {
1033                baseline: make_verdict("a.rs", "small_regression", 5.0, false),
1034                current: make_verdict("a.rs", "small_regression", 10.0, false),
1035            },
1036            FunctionChange::Removed {
1037                baseline: make_verdict("a.rs", "big_removed", 30.0, true),
1038            },
1039            FunctionChange::Added {
1040                current: make_verdict("a.rs", "big_added", 10.0, false),
1041            },
1042        ]);
1043        let view = apply(&delta, DeltaViewSpec::default());
1044        assert_eq!(view.shown[0].qualified_name(), "big_added"); // +10
1045        assert_eq!(view.shown[1].qualified_name(), "small_regression"); // +5
1046        assert_eq!(view.shown[2].qualified_name(), "big_improvement"); // -25
1047        assert_eq!(view.shown[3].qualified_name(), "big_removed"); // -30
1048    }
1049
1050    #[test]
1051    fn apply_filter_change_kinds_added_only() {
1052        let delta = delta_with_changes(vec![
1053            FunctionChange::Added {
1054                current: make_verdict("a.rs", "added_one", 5.0, false),
1055            },
1056            FunctionChange::Removed {
1057                baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1058            },
1059            FunctionChange::Modified {
1060                baseline: make_verdict("a.rs", "mod_one", 5.0, false),
1061                current: make_verdict("a.rs", "mod_one", 6.0, false),
1062            },
1063        ]);
1064        let mut kinds = BTreeSet::new();
1065        kinds.insert(ChangeKind::Added);
1066        let spec = DeltaViewSpec {
1067            filters: DeltaFilters {
1068                change_kinds: Some(kinds),
1069                ..Default::default()
1070            },
1071            ..Default::default()
1072        };
1073        let view = apply(&delta, spec);
1074        assert_eq!(view.shown.len(), 1);
1075        assert_eq!(view.shown[0].kind(), ChangeKind::Added);
1076    }
1077
1078    #[test]
1079    fn apply_filter_score_delta_min_excludes_below() {
1080        let delta = delta_with_changes(vec![
1081            FunctionChange::Modified {
1082                baseline: make_verdict("a.rs", "tiny", 5.0, false),
1083                current: make_verdict("a.rs", "tiny", 6.0, false),
1084            },
1085            FunctionChange::Modified {
1086                baseline: make_verdict("a.rs", "big", 5.0, false),
1087                current: make_verdict("a.rs", "big", 25.0, false),
1088            },
1089        ]);
1090        let spec = DeltaViewSpec {
1091            filters: DeltaFilters {
1092                min_score_delta: Some(10.0),
1093                ..Default::default()
1094            },
1095            ..Default::default()
1096        };
1097        let view = apply(&delta, spec);
1098        assert_eq!(view.shown.len(), 1);
1099        assert_eq!(view.shown[0].qualified_name(), "big");
1100    }
1101
1102    #[test]
1103    fn apply_filter_score_delta_passes_added_and_removed() {
1104        // Added/Removed have no score_delta — bound check shouldn't drop them
1105        let delta = delta_with_changes(vec![
1106            FunctionChange::Added {
1107                current: make_verdict("a.rs", "added_one", 5.0, false),
1108            },
1109            FunctionChange::Removed {
1110                baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1111            },
1112        ]);
1113        let spec = DeltaViewSpec {
1114            filters: DeltaFilters {
1115                min_score_delta: Some(100.0),
1116                ..Default::default()
1117            },
1118            ..Default::default()
1119        };
1120        let view = apply(&delta, spec);
1121        assert_eq!(view.shown.len(), 2);
1122    }
1123
1124    #[test]
1125    fn apply_sort_current_crap_descending_removed_last() {
1126        let delta = delta_with_changes(vec![
1127            FunctionChange::Modified {
1128                baseline: make_verdict("a.rs", "modlow", 50.0, true),
1129                current: make_verdict("a.rs", "modlow", 5.0, false),
1130            },
1131            FunctionChange::Removed {
1132                baseline: make_verdict("a.rs", "removed_top", 999.0, true),
1133            },
1134            FunctionChange::Added {
1135                current: make_verdict("a.rs", "added_high", 47.0, true),
1136            },
1137        ]);
1138        let spec = DeltaViewSpec {
1139            sort: DeltaSortKey::CurrentCrap,
1140            ..Default::default()
1141        };
1142        let view = apply(&delta, spec);
1143        assert_eq!(view.shown[0].qualified_name(), "added_high"); // 47 (current)
1144        assert_eq!(view.shown[1].qualified_name(), "modlow"); // 5 (current)
1145        assert_eq!(view.shown[2].qualified_name(), "removed_top"); // None — last
1146    }
1147
1148    #[test]
1149    fn apply_sort_path_alphabetical() {
1150        let delta = delta_with_changes(vec![
1151            FunctionChange::Modified {
1152                baseline: make_verdict("zzz.rs", "z", 5.0, false),
1153                current: make_verdict("zzz.rs", "z", 6.0, false),
1154            },
1155            FunctionChange::Modified {
1156                baseline: make_verdict("aaa.rs", "a", 5.0, false),
1157                current: make_verdict("aaa.rs", "a", 6.0, false),
1158            },
1159            FunctionChange::Modified {
1160                baseline: make_verdict("mmm.rs", "m", 5.0, false),
1161                current: make_verdict("mmm.rs", "m", 6.0, false),
1162            },
1163        ]);
1164        let spec = DeltaViewSpec {
1165            sort: DeltaSortKey::Path,
1166            ..Default::default()
1167        };
1168        let view = apply(&delta, spec);
1169        assert_eq!(view.shown[0].file_path(), "aaa.rs");
1170        assert_eq!(view.shown[1].file_path(), "mmm.rs");
1171        assert_eq!(view.shown[2].file_path(), "zzz.rs");
1172    }
1173
1174    #[test]
1175    fn apply_truncate_marks_truncated_true() {
1176        let changes: Vec<FunctionChange> = (0..10)
1177            .map(|i| FunctionChange::Modified {
1178                baseline: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1179                current: make_verdict("a.rs", &format!("fn_{i}"), 5.0 + i as f64, false),
1180            })
1181            .collect();
1182        let delta = delta_with_changes(changes);
1183        let spec = DeltaViewSpec {
1184            limit: Some(3),
1185            ..Default::default()
1186        };
1187        let view = apply(&delta, spec);
1188        assert_eq!(view.shown.len(), 3);
1189        assert_eq!(view.eligible_count, 10);
1190        assert!(view.truncated);
1191    }
1192
1193    #[test]
1194    fn apply_truncate_zero_means_no_limit() {
1195        let changes: Vec<FunctionChange> = (0..3)
1196            .map(|i| FunctionChange::Added {
1197                current: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1198            })
1199            .collect();
1200        let delta = delta_with_changes(changes);
1201        let spec = DeltaViewSpec {
1202            limit: Some(0),
1203            ..Default::default()
1204        };
1205        let view = apply(&delta, spec);
1206        assert_eq!(view.shown.len(), 3);
1207        assert!(!view.truncated);
1208    }
1209
1210    #[test]
1211    fn apply_view_full_borrows_underlying_delta() {
1212        let delta = delta_with_changes(vec![]);
1213        let view = apply(&delta, DeltaViewSpec::default());
1214        assert!(std::ptr::eq(view.full, &delta));
1215    }
1216}
1217
1218// ── Property tests ───────────────────────────────────────────────────
1219
1220#[cfg(test)]
1221mod proptests {
1222    use super::*;
1223    use crate::test_strategies::arb_analysis_result;
1224    use proptest::prelude::*;
1225
1226    proptest! {
1227        /// `compute(r, r)` produces all-Modified, all-zero-delta, all-passing.
1228        /// The most fundamental invariant: a no-op delta is well-formed.
1229        #[test]
1230        fn prop_compute_identity_yields_all_modified_zero(result in arb_analysis_result()) {
1231            let n = result.functions.len();
1232            let delta = compute(result.clone(), result);
1233            prop_assert_eq!(delta.changes.len(), n);
1234            for change in &delta.changes {
1235                let is_modified = matches!(change, FunctionChange::Modified { .. });
1236                prop_assert!(is_modified);
1237                prop_assert_eq!(change.score_delta(), Some(0.0));
1238            }
1239            let summary = DeltaSummary::compute(&delta.changes);
1240            prop_assert_eq!(summary.added, 0);
1241            prop_assert_eq!(summary.removed, 0);
1242            prop_assert_eq!(summary.modified, n as u32);
1243            prop_assert_eq!(summary.regressions, 0);
1244            prop_assert_eq!(summary.improvements, 0);
1245            prop_assert_eq!(summary.new_violations, 0);
1246            prop_assert!(summary.passed);
1247        }
1248
1249        /// Length of `changes` is matched + baseline-only + current-only;
1250        /// matched is bounded by min(|baseline|, |current|).
1251        #[test]
1252        fn prop_changes_count_bounded(
1253            baseline in arb_analysis_result(),
1254            current in arb_analysis_result(),
1255        ) {
1256            let baseline_len = baseline.functions.len();
1257            let current_len = current.functions.len();
1258            let delta = compute(baseline, current);
1259            let n = delta.changes.len();
1260            // Every change is one of the three; the union bound is
1261            // max + min + max = baseline + current. (Concretely:
1262            // matched can be 0; both-only appears as Add+Remove which
1263            // sums to baseline+current.)
1264            prop_assert!(n <= baseline_len + current_len);
1265            // Modified can't exceed either side.
1266            let modified_count = delta
1267                .changes
1268                .iter()
1269                .filter(|c| matches!(c, FunctionChange::Modified { .. }))
1270                .count();
1271            prop_assert!(modified_count <= baseline_len);
1272            prop_assert!(modified_count <= current_len);
1273        }
1274
1275        /// new_violations is bounded by the count of Added rows that
1276        /// exceed plus Modified rows that crossed the threshold.
1277        #[test]
1278        fn prop_new_violations_well_bounded(
1279            baseline in arb_analysis_result(),
1280            current in arb_analysis_result(),
1281        ) {
1282            let delta = compute(baseline, current);
1283            let summary = DeltaSummary::compute(&delta.changes);
1284            prop_assert!(summary.new_violations <= summary.added + summary.modified);
1285            prop_assert_eq!(summary.passed, summary.new_violations == 0);
1286        }
1287
1288        /// View shaping never adds rows. `view.shown.len() <= eligible_count
1289        /// <= delta.changes.len()`. Truncation flag is true iff
1290        /// `shown.len() < eligible_count`.
1291        #[test]
1292        fn prop_view_shown_subset_of_changes(
1293            baseline in arb_analysis_result(),
1294            current in arb_analysis_result(),
1295        ) {
1296            let delta = compute(baseline, current);
1297            let view = apply(&delta, DeltaViewSpec::default());
1298            prop_assert!(view.shown.len() <= view.eligible_count);
1299            prop_assert!(view.eligible_count <= delta.changes.len());
1300            prop_assert_eq!(view.shown.len() == view.eligible_count, !view.truncated);
1301        }
1302
1303        /// Delta gate is unshapeable. `apply` does not mutate
1304        /// `full.summary.passed`.
1305        #[test]
1306        fn prop_apply_does_not_mutate_summary(
1307            baseline in arb_analysis_result(),
1308            current in arb_analysis_result(),
1309        ) {
1310            let delta = compute(baseline, current);
1311            let original_passed = delta.summary.passed;
1312            let view = apply(&delta, DeltaViewSpec::default());
1313            prop_assert_eq!(view.full.summary.passed, original_passed);
1314        }
1315    }
1316}