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                branch_coverage_percent: None,
595                crap: CrapScore {
596                    value: score,
597                    risk_level: if score > 30.0 {
598                        RiskLevel::High
599                    } else if score > 8.0 {
600                        RiskLevel::Moderate
601                    } else if score > 5.0 {
602                        RiskLevel::Acceptable
603                    } else {
604                        RiskLevel::Low
605                    },
606                },
607                contributors: vec![],
608            },
609            threshold: 25.0,
610            exceeds,
611            diagnostic: None,
612        }
613    }
614
615    fn make_result(verdicts: Vec<FunctionVerdict>) -> AnalysisResult {
616        let exceeding = verdicts.iter().filter(|v| v.exceeds).count();
617        let total = verdicts.len();
618        AnalysisResult {
619            functions: verdicts,
620            summary: AnalysisSummary {
621                total_functions: total,
622                total_files: 1,
623                exceeding_threshold: exceeding,
624                average_crap: 0.0,
625                median_crap: 0.0,
626                max_crap: None,
627                worst_function: None,
628                distribution: RiskDistribution {
629                    low: 0,
630                    acceptable: 0,
631                    moderate: 0,
632                    high: 0,
633                },
634                ..Default::default()
635            },
636            passed: exceeding == 0,
637        }
638    }
639
640    // ── classification ──
641
642    #[test]
643    fn compute_identity_yields_all_modified_zero_delta() {
644        let result = make_result(vec![
645            make_verdict("a.rs", "alpha", 5.0, false),
646            make_verdict("a.rs", "beta", 12.0, false),
647            make_verdict("b.rs", "gamma", 47.0, true),
648        ]);
649        let delta = compute(result.clone(), result);
650        assert_eq!(delta.changes.len(), 3);
651        for change in &delta.changes {
652            assert!(matches!(change, FunctionChange::Modified { .. }));
653            assert_eq!(change.score_delta(), Some(0.0));
654        }
655    }
656
657    #[test]
658    fn compute_classifies_added_function() {
659        let baseline = make_result(vec![]);
660        let current = make_result(vec![make_verdict("a.rs", "new_fn", 10.0, false)]);
661        let delta = compute(baseline, current);
662        assert_eq!(delta.changes.len(), 1);
663        assert!(matches!(delta.changes[0], FunctionChange::Added { .. }));
664        assert_eq!(delta.changes[0].current_score(), Some(10.0));
665        assert_eq!(delta.changes[0].baseline_score(), None);
666        assert_eq!(delta.changes[0].score_delta(), None);
667    }
668
669    #[test]
670    fn compute_classifies_removed_function() {
671        let baseline = make_result(vec![make_verdict("a.rs", "old_fn", 8.0, false)]);
672        let current = make_result(vec![]);
673        let delta = compute(baseline, current);
674        assert_eq!(delta.changes.len(), 1);
675        assert!(matches!(delta.changes[0], FunctionChange::Removed { .. }));
676        assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
677        assert_eq!(delta.changes[0].current_score(), None);
678    }
679
680    #[test]
681    fn compute_classifies_modified_function() {
682        let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 8.0, false)]);
683        let current = make_result(vec![make_verdict("a.rs", "fn_a", 24.0, false)]);
684        let delta = compute(baseline, current);
685        assert_eq!(delta.changes.len(), 1);
686        assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
687        assert_eq!(delta.changes[0].baseline_score(), Some(8.0));
688        assert_eq!(delta.changes[0].current_score(), Some(24.0));
689        assert_eq!(delta.changes[0].score_delta(), Some(16.0));
690    }
691
692    #[test]
693    fn compute_same_name_different_files_are_separate() {
694        let baseline = make_result(vec![make_verdict("a.rs", "log", 5.0, false)]);
695        let current = make_result(vec![make_verdict("b.rs", "log", 5.0, false)]);
696        let delta = compute(baseline, current);
697        assert_eq!(delta.changes.len(), 2);
698        let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
699        assert!(kinds.contains(&ChangeKind::Added));
700        assert!(kinds.contains(&ChangeKind::Removed));
701    }
702
703    #[test]
704    fn compute_same_file_rename_produces_add_remove() {
705        let baseline = make_result(vec![make_verdict("a.rs", "v1", 5.0, false)]);
706        let current = make_result(vec![make_verdict("a.rs", "v2", 5.0, false)]);
707        let delta = compute(baseline, current);
708        assert_eq!(delta.changes.len(), 2);
709        let kinds: Vec<_> = delta.changes.iter().map(|c| c.kind()).collect();
710        assert!(kinds.contains(&ChangeKind::Added));
711        assert!(kinds.contains(&ChangeKind::Removed));
712    }
713
714    #[test]
715    fn compute_ignores_span_when_matching() {
716        // Same identity (file, name), different spans -> Modified, not Add+Remove
717        let mut baseline_v = make_verdict("a.rs", "fn_a", 5.0, false);
718        baseline_v.scored.identity.span = SourceSpan {
719            start_line: 1,
720            end_line: 5,
721            start_column: 0,
722            end_column: 0,
723        };
724        let mut current_v = make_verdict("a.rs", "fn_a", 5.0, false);
725        current_v.scored.identity.span = SourceSpan {
726            start_line: 100,
727            end_line: 105,
728            start_column: 0,
729            end_column: 0,
730        };
731        let delta = compute(make_result(vec![baseline_v]), make_result(vec![current_v]));
732        assert_eq!(delta.changes.len(), 1);
733        assert!(matches!(delta.changes[0], FunctionChange::Modified { .. }));
734    }
735
736    #[test]
737    fn removed_rows_are_emitted_in_identity_key_order() {
738        // Removed-row determinism: pair_identities collects leftover
739        // baseline entries from a HashMap; iterating the map directly
740        // produces non-deterministic order. Sort by (file_path,
741        // qualified_name) before emission so consumers that don't
742        // apply a tie-breaking sort see a stable order.
743        let baseline = make_result(vec![
744            make_verdict("zeta.rs", "zeta_fn", 5.0, false),
745            make_verdict("alpha.rs", "alpha_fn", 5.0, false),
746            make_verdict("beta.rs", "beta_fn", 5.0, false),
747        ]);
748        // Empty current → all baseline entries are Removed.
749        let current = make_result(vec![]);
750        let delta = compute(baseline, current);
751
752        // Should be sorted by (file_path, qualified_name) ascending —
753        // alpha.rs < beta.rs < zeta.rs.
754        assert_eq!(delta.changes.len(), 3);
755        assert_eq!(delta.changes[0].file_path(), "alpha.rs");
756        assert_eq!(delta.changes[1].file_path(), "beta.rs");
757        assert_eq!(delta.changes[2].file_path(), "zeta.rs");
758    }
759
760    // ── summary counts ──
761
762    #[test]
763    fn summary_counts_added_removed_modified() {
764        let changes = vec![
765            FunctionChange::Added {
766                current: make_verdict("a.rs", "new", 5.0, false),
767            },
768            FunctionChange::Removed {
769                baseline: make_verdict("a.rs", "old", 5.0, false),
770            },
771            FunctionChange::Modified {
772                baseline: make_verdict("a.rs", "fn_a", 5.0, false),
773                current: make_verdict("a.rs", "fn_a", 8.0, false),
774            },
775        ];
776        let summary = DeltaSummary::compute(&changes);
777        assert_eq!(summary.added, 1);
778        assert_eq!(summary.removed, 1);
779        assert_eq!(summary.modified, 1);
780    }
781
782    #[test]
783    fn summary_regressions_are_modified_with_positive_delta() {
784        let changes = vec![FunctionChange::Modified {
785            baseline: make_verdict("a.rs", "fn_a", 5.0, false),
786            current: make_verdict("a.rs", "fn_a", 10.0, false),
787        }];
788        let summary = DeltaSummary::compute(&changes);
789        assert_eq!(summary.regressions, 1);
790        assert_eq!(summary.improvements, 0);
791    }
792
793    #[test]
794    fn summary_improvements_are_modified_with_negative_delta() {
795        let changes = vec![FunctionChange::Modified {
796            baseline: make_verdict("a.rs", "fn_a", 47.0, true),
797            current: make_verdict("a.rs", "fn_a", 12.0, false),
798        }];
799        let summary = DeltaSummary::compute(&changes);
800        assert_eq!(summary.regressions, 0);
801        assert_eq!(summary.improvements, 1);
802    }
803
804    #[test]
805    fn summary_zero_delta_neither_regression_nor_improvement() {
806        let changes = vec![FunctionChange::Modified {
807            baseline: make_verdict("a.rs", "fn_a", 5.0, false),
808            current: make_verdict("a.rs", "fn_a", 5.0, false),
809        }];
810        let summary = DeltaSummary::compute(&changes);
811        assert_eq!(summary.regressions, 0);
812        assert_eq!(summary.improvements, 0);
813    }
814
815    #[test]
816    fn summary_new_violation_added_function_failing() {
817        let changes = vec![FunctionChange::Added {
818            current: make_verdict("a.rs", "new_bad", 31.0, true),
819        }];
820        let summary = DeltaSummary::compute(&changes);
821        assert_eq!(summary.new_violations, 1);
822        assert!(!summary.passed);
823    }
824
825    #[test]
826    fn summary_new_violation_modified_crossing_threshold() {
827        let changes = vec![FunctionChange::Modified {
828            baseline: make_verdict("a.rs", "fn_a", 8.0, false),
829            current: make_verdict("a.rs", "fn_a", 47.0, true),
830        }];
831        let summary = DeltaSummary::compute(&changes);
832        assert_eq!(summary.new_violations, 1);
833        assert_eq!(summary.regressions, 1);
834    }
835
836    #[test]
837    fn summary_no_new_violation_when_modified_still_passing() {
838        let changes = vec![FunctionChange::Modified {
839            baseline: make_verdict("a.rs", "fn_a", 8.0, false),
840            current: make_verdict("a.rs", "fn_a", 20.0, false),
841        }];
842        let summary = DeltaSummary::compute(&changes);
843        assert_eq!(summary.regressions, 1);
844        assert_eq!(summary.new_violations, 0);
845        assert!(summary.passed);
846    }
847
848    #[test]
849    fn summary_pre_existing_violation_does_not_count_as_new() {
850        let changes = vec![FunctionChange::Modified {
851            baseline: make_verdict("a.rs", "fn_a", 47.0, true),
852            current: make_verdict("a.rs", "fn_a", 60.0, true),
853        }];
854        let summary = DeltaSummary::compute(&changes);
855        assert_eq!(summary.regressions, 1);
856        assert_eq!(summary.new_violations, 0);
857        assert!(summary.passed);
858    }
859
860    #[test]
861    fn summary_added_passing_function_not_a_new_violation() {
862        let changes = vec![FunctionChange::Added {
863            current: make_verdict("a.rs", "new_good", 5.0, false),
864        }];
865        let summary = DeltaSummary::compute(&changes);
866        assert_eq!(summary.added, 1);
867        assert_eq!(summary.new_violations, 0);
868    }
869
870    #[test]
871    fn summary_removed_function_never_counts_as_new_violation() {
872        let changes = vec![FunctionChange::Removed {
873            baseline: make_verdict("a.rs", "old_bad", 47.0, true),
874        }];
875        let summary = DeltaSummary::compute(&changes);
876        assert_eq!(summary.removed, 1);
877        assert_eq!(summary.new_violations, 0);
878        assert!(summary.passed);
879    }
880
881    #[test]
882    fn summary_passed_iff_new_violations_zero() {
883        let zero = DeltaSummary::compute(&[]);
884        assert!(zero.passed);
885
886        let with_new = DeltaSummary::compute(&[FunctionChange::Added {
887            current: make_verdict("a.rs", "bad", 31.0, true),
888        }]);
889        assert!(!with_new.passed);
890    }
891
892    // ── change accessors ──
893
894    #[test]
895    fn change_kind_serializes_lowercase() {
896        assert_eq!(
897            serde_json::to_string(&ChangeKind::Added).unwrap(),
898            "\"added\""
899        );
900        assert_eq!(
901            serde_json::to_string(&ChangeKind::Modified).unwrap(),
902            "\"modified\""
903        );
904        assert_eq!(
905            serde_json::to_string(&ChangeKind::Removed).unwrap(),
906            "\"removed\""
907        );
908    }
909
910    #[test]
911    fn change_kind_all_contains_every_variant() {
912        assert_eq!(ChangeKind::ALL.len(), 3);
913        assert!(ChangeKind::ALL.contains(&ChangeKind::Added));
914        assert!(ChangeKind::ALL.contains(&ChangeKind::Removed));
915        assert!(ChangeKind::ALL.contains(&ChangeKind::Modified));
916    }
917
918    #[test]
919    fn change_kind_as_str_matches_serde() {
920        for kind in ChangeKind::ALL {
921            let json = serde_json::to_string(&kind).unwrap();
922            let stripped = json.trim_matches('"');
923            assert_eq!(kind.as_str(), stripped);
924        }
925    }
926
927    #[test]
928    fn change_file_path_and_qualified_name_accessors() {
929        let added = FunctionChange::Added {
930            current: make_verdict("src/foo.rs", "module::fn_a", 5.0, false),
931        };
932        assert_eq!(added.file_path(), "src/foo.rs");
933        assert_eq!(added.qualified_name(), "module::fn_a");
934
935        let removed = FunctionChange::Removed {
936            baseline: make_verdict("src/bar.rs", "module::fn_b", 5.0, false),
937        };
938        assert_eq!(removed.file_path(), "src/bar.rs");
939
940        let modified = FunctionChange::Modified {
941            baseline: make_verdict("src/baz.rs", "module::fn_c", 5.0, false),
942            current: make_verdict("src/baz.rs", "module::fn_c", 10.0, false),
943        };
944        assert_eq!(modified.file_path(), "src/baz.rs");
945    }
946
947    // ── envelope ──
948
949    #[test]
950    fn analysis_delta_carries_baseline_current_and_changes() {
951        let baseline = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
952        let current = make_result(vec![make_verdict("a.rs", "fn_a", 5.0, false)]);
953        let delta = compute(baseline.clone(), current.clone());
954        assert_eq!(delta.baseline.functions.len(), baseline.functions.len());
955        assert_eq!(delta.current.functions.len(), current.functions.len());
956        assert_eq!(delta.changes.len(), 1);
957    }
958
959    #[test]
960    fn empty_inputs_produce_empty_delta() {
961        let delta = compute(make_result(vec![]), make_result(vec![]));
962        assert!(delta.changes.is_empty());
963        let summary = DeltaSummary::compute(&delta.changes);
964        assert_eq!(summary.added, 0);
965        assert_eq!(summary.removed, 0);
966        assert_eq!(summary.modified, 0);
967        assert!(summary.passed);
968    }
969
970    // ── DeltaView / apply ──
971
972    fn delta_with_changes(changes: Vec<FunctionChange>) -> AnalysisDelta {
973        AnalysisDelta {
974            baseline: make_result(vec![]),
975            current: make_result(vec![]),
976            summary: DeltaSummary::compute(&changes),
977            changes,
978        }
979    }
980
981    #[test]
982    fn apply_default_spec_returns_all_changes() {
983        let delta = delta_with_changes(vec![
984            FunctionChange::Added {
985                current: make_verdict("a.rs", "x", 31.0, true),
986            },
987            FunctionChange::Modified {
988                baseline: make_verdict("a.rs", "y", 5.0, false),
989                current: make_verdict("a.rs", "y", 10.0, false),
990            },
991        ]);
992        let view = apply(&delta, DeltaViewSpec::default());
993        assert_eq!(view.shown.len(), 2);
994        assert_eq!(view.eligible_count, 2);
995        assert!(!view.truncated);
996    }
997
998    #[test]
999    fn apply_default_sorts_by_signed_impact_descending() {
1000        // Signed impacts: small_mod=+1, big_mod=+20, big_added=+31
1001        let delta = delta_with_changes(vec![
1002            FunctionChange::Modified {
1003                baseline: make_verdict("a.rs", "small_mod", 5.0, false),
1004                current: make_verdict("a.rs", "small_mod", 6.0, false),
1005            },
1006            FunctionChange::Modified {
1007                baseline: make_verdict("a.rs", "big_mod", 5.0, false),
1008                current: make_verdict("a.rs", "big_mod", 25.0, false),
1009            },
1010            FunctionChange::Added {
1011                current: make_verdict("a.rs", "big_added", 31.0, true),
1012            },
1013        ]);
1014        let view = apply(&delta, DeltaViewSpec::default());
1015        assert_eq!(view.shown[0].qualified_name(), "big_added");
1016        assert_eq!(view.shown[1].qualified_name(), "big_mod");
1017        assert_eq!(view.shown[2].qualified_name(), "small_mod");
1018    }
1019
1020    #[test]
1021    fn apply_default_sort_puts_regressions_above_improvements() {
1022        // Signed impacts: big_improvement=-25, small_regression=+5,
1023        // big_removed=-30 (Removed is treated as -baseline.crap),
1024        // big_added=+10. Ranking descending must be:
1025        //   small_regression (+5) > big_added (+10? no, +10 > +5)
1026        // Wait: +10 > +5, so big_added first, then small_regression,
1027        // then big_improvement (-25), then big_removed (-30).
1028        let delta = delta_with_changes(vec![
1029            FunctionChange::Modified {
1030                baseline: make_verdict("a.rs", "big_improvement", 30.0, true),
1031                current: make_verdict("a.rs", "big_improvement", 5.0, false),
1032            },
1033            FunctionChange::Modified {
1034                baseline: make_verdict("a.rs", "small_regression", 5.0, false),
1035                current: make_verdict("a.rs", "small_regression", 10.0, false),
1036            },
1037            FunctionChange::Removed {
1038                baseline: make_verdict("a.rs", "big_removed", 30.0, true),
1039            },
1040            FunctionChange::Added {
1041                current: make_verdict("a.rs", "big_added", 10.0, false),
1042            },
1043        ]);
1044        let view = apply(&delta, DeltaViewSpec::default());
1045        assert_eq!(view.shown[0].qualified_name(), "big_added"); // +10
1046        assert_eq!(view.shown[1].qualified_name(), "small_regression"); // +5
1047        assert_eq!(view.shown[2].qualified_name(), "big_improvement"); // -25
1048        assert_eq!(view.shown[3].qualified_name(), "big_removed"); // -30
1049    }
1050
1051    #[test]
1052    fn apply_filter_change_kinds_added_only() {
1053        let delta = delta_with_changes(vec![
1054            FunctionChange::Added {
1055                current: make_verdict("a.rs", "added_one", 5.0, false),
1056            },
1057            FunctionChange::Removed {
1058                baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1059            },
1060            FunctionChange::Modified {
1061                baseline: make_verdict("a.rs", "mod_one", 5.0, false),
1062                current: make_verdict("a.rs", "mod_one", 6.0, false),
1063            },
1064        ]);
1065        let mut kinds = BTreeSet::new();
1066        kinds.insert(ChangeKind::Added);
1067        let spec = DeltaViewSpec {
1068            filters: DeltaFilters {
1069                change_kinds: Some(kinds),
1070                ..Default::default()
1071            },
1072            ..Default::default()
1073        };
1074        let view = apply(&delta, spec);
1075        assert_eq!(view.shown.len(), 1);
1076        assert_eq!(view.shown[0].kind(), ChangeKind::Added);
1077    }
1078
1079    #[test]
1080    fn apply_filter_score_delta_min_excludes_below() {
1081        let delta = delta_with_changes(vec![
1082            FunctionChange::Modified {
1083                baseline: make_verdict("a.rs", "tiny", 5.0, false),
1084                current: make_verdict("a.rs", "tiny", 6.0, false),
1085            },
1086            FunctionChange::Modified {
1087                baseline: make_verdict("a.rs", "big", 5.0, false),
1088                current: make_verdict("a.rs", "big", 25.0, false),
1089            },
1090        ]);
1091        let spec = DeltaViewSpec {
1092            filters: DeltaFilters {
1093                min_score_delta: Some(10.0),
1094                ..Default::default()
1095            },
1096            ..Default::default()
1097        };
1098        let view = apply(&delta, spec);
1099        assert_eq!(view.shown.len(), 1);
1100        assert_eq!(view.shown[0].qualified_name(), "big");
1101    }
1102
1103    #[test]
1104    fn apply_filter_score_delta_passes_added_and_removed() {
1105        // Added/Removed have no score_delta — bound check shouldn't drop them
1106        let delta = delta_with_changes(vec![
1107            FunctionChange::Added {
1108                current: make_verdict("a.rs", "added_one", 5.0, false),
1109            },
1110            FunctionChange::Removed {
1111                baseline: make_verdict("a.rs", "removed_one", 5.0, false),
1112            },
1113        ]);
1114        let spec = DeltaViewSpec {
1115            filters: DeltaFilters {
1116                min_score_delta: Some(100.0),
1117                ..Default::default()
1118            },
1119            ..Default::default()
1120        };
1121        let view = apply(&delta, spec);
1122        assert_eq!(view.shown.len(), 2);
1123    }
1124
1125    #[test]
1126    fn apply_sort_current_crap_descending_removed_last() {
1127        let delta = delta_with_changes(vec![
1128            FunctionChange::Modified {
1129                baseline: make_verdict("a.rs", "modlow", 50.0, true),
1130                current: make_verdict("a.rs", "modlow", 5.0, false),
1131            },
1132            FunctionChange::Removed {
1133                baseline: make_verdict("a.rs", "removed_top", 999.0, true),
1134            },
1135            FunctionChange::Added {
1136                current: make_verdict("a.rs", "added_high", 47.0, true),
1137            },
1138        ]);
1139        let spec = DeltaViewSpec {
1140            sort: DeltaSortKey::CurrentCrap,
1141            ..Default::default()
1142        };
1143        let view = apply(&delta, spec);
1144        assert_eq!(view.shown[0].qualified_name(), "added_high"); // 47 (current)
1145        assert_eq!(view.shown[1].qualified_name(), "modlow"); // 5 (current)
1146        assert_eq!(view.shown[2].qualified_name(), "removed_top"); // None — last
1147    }
1148
1149    #[test]
1150    fn apply_sort_path_alphabetical() {
1151        let delta = delta_with_changes(vec![
1152            FunctionChange::Modified {
1153                baseline: make_verdict("zzz.rs", "z", 5.0, false),
1154                current: make_verdict("zzz.rs", "z", 6.0, false),
1155            },
1156            FunctionChange::Modified {
1157                baseline: make_verdict("aaa.rs", "a", 5.0, false),
1158                current: make_verdict("aaa.rs", "a", 6.0, false),
1159            },
1160            FunctionChange::Modified {
1161                baseline: make_verdict("mmm.rs", "m", 5.0, false),
1162                current: make_verdict("mmm.rs", "m", 6.0, false),
1163            },
1164        ]);
1165        let spec = DeltaViewSpec {
1166            sort: DeltaSortKey::Path,
1167            ..Default::default()
1168        };
1169        let view = apply(&delta, spec);
1170        assert_eq!(view.shown[0].file_path(), "aaa.rs");
1171        assert_eq!(view.shown[1].file_path(), "mmm.rs");
1172        assert_eq!(view.shown[2].file_path(), "zzz.rs");
1173    }
1174
1175    #[test]
1176    fn apply_truncate_marks_truncated_true() {
1177        let changes: Vec<FunctionChange> = (0..10)
1178            .map(|i| FunctionChange::Modified {
1179                baseline: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1180                current: make_verdict("a.rs", &format!("fn_{i}"), 5.0 + i as f64, false),
1181            })
1182            .collect();
1183        let delta = delta_with_changes(changes);
1184        let spec = DeltaViewSpec {
1185            limit: Some(3),
1186            ..Default::default()
1187        };
1188        let view = apply(&delta, spec);
1189        assert_eq!(view.shown.len(), 3);
1190        assert_eq!(view.eligible_count, 10);
1191        assert!(view.truncated);
1192    }
1193
1194    #[test]
1195    fn apply_truncate_zero_means_no_limit() {
1196        let changes: Vec<FunctionChange> = (0..3)
1197            .map(|i| FunctionChange::Added {
1198                current: make_verdict("a.rs", &format!("fn_{i}"), 5.0, false),
1199            })
1200            .collect();
1201        let delta = delta_with_changes(changes);
1202        let spec = DeltaViewSpec {
1203            limit: Some(0),
1204            ..Default::default()
1205        };
1206        let view = apply(&delta, spec);
1207        assert_eq!(view.shown.len(), 3);
1208        assert!(!view.truncated);
1209    }
1210
1211    #[test]
1212    fn apply_view_full_borrows_underlying_delta() {
1213        let delta = delta_with_changes(vec![]);
1214        let view = apply(&delta, DeltaViewSpec::default());
1215        assert!(std::ptr::eq(view.full, &delta));
1216    }
1217}
1218
1219// ── Property tests ───────────────────────────────────────────────────
1220
1221#[cfg(test)]
1222mod proptests {
1223    use super::*;
1224    use crate::test_strategies::arb_analysis_result;
1225    use proptest::prelude::*;
1226
1227    proptest! {
1228        /// `compute(r, r)` produces all-Modified, all-zero-delta, all-passing.
1229        /// The most fundamental invariant: a no-op delta is well-formed.
1230        #[test]
1231        fn prop_compute_identity_yields_all_modified_zero(result in arb_analysis_result()) {
1232            let n = result.functions.len();
1233            let delta = compute(result.clone(), result);
1234            prop_assert_eq!(delta.changes.len(), n);
1235            for change in &delta.changes {
1236                let is_modified = matches!(change, FunctionChange::Modified { .. });
1237                prop_assert!(is_modified);
1238                prop_assert_eq!(change.score_delta(), Some(0.0));
1239            }
1240            let summary = DeltaSummary::compute(&delta.changes);
1241            prop_assert_eq!(summary.added, 0);
1242            prop_assert_eq!(summary.removed, 0);
1243            prop_assert_eq!(summary.modified, n as u32);
1244            prop_assert_eq!(summary.regressions, 0);
1245            prop_assert_eq!(summary.improvements, 0);
1246            prop_assert_eq!(summary.new_violations, 0);
1247            prop_assert!(summary.passed);
1248        }
1249
1250        /// Length of `changes` is matched + baseline-only + current-only;
1251        /// matched is bounded by min(|baseline|, |current|).
1252        #[test]
1253        fn prop_changes_count_bounded(
1254            baseline in arb_analysis_result(),
1255            current in arb_analysis_result(),
1256        ) {
1257            let baseline_len = baseline.functions.len();
1258            let current_len = current.functions.len();
1259            let delta = compute(baseline, current);
1260            let n = delta.changes.len();
1261            // Every change is one of the three; the union bound is
1262            // max + min + max = baseline + current. (Concretely:
1263            // matched can be 0; both-only appears as Add+Remove which
1264            // sums to baseline+current.)
1265            prop_assert!(n <= baseline_len + current_len);
1266            // Modified can't exceed either side.
1267            let modified_count = delta
1268                .changes
1269                .iter()
1270                .filter(|c| matches!(c, FunctionChange::Modified { .. }))
1271                .count();
1272            prop_assert!(modified_count <= baseline_len);
1273            prop_assert!(modified_count <= current_len);
1274        }
1275
1276        /// new_violations is bounded by the count of Added rows that
1277        /// exceed plus Modified rows that crossed the threshold.
1278        #[test]
1279        fn prop_new_violations_well_bounded(
1280            baseline in arb_analysis_result(),
1281            current in arb_analysis_result(),
1282        ) {
1283            let delta = compute(baseline, current);
1284            let summary = DeltaSummary::compute(&delta.changes);
1285            prop_assert!(summary.new_violations <= summary.added + summary.modified);
1286            prop_assert_eq!(summary.passed, summary.new_violations == 0);
1287        }
1288
1289        /// View shaping never adds rows. `view.shown.len() <= eligible_count
1290        /// <= delta.changes.len()`. Truncation flag is true iff
1291        /// `shown.len() < eligible_count`.
1292        #[test]
1293        fn prop_view_shown_subset_of_changes(
1294            baseline in arb_analysis_result(),
1295            current in arb_analysis_result(),
1296        ) {
1297            let delta = compute(baseline, current);
1298            let view = apply(&delta, DeltaViewSpec::default());
1299            prop_assert!(view.shown.len() <= view.eligible_count);
1300            prop_assert!(view.eligible_count <= delta.changes.len());
1301            prop_assert_eq!(view.shown.len() == view.eligible_count, !view.truncated);
1302        }
1303
1304        /// Delta gate is unshapeable. `apply` does not mutate
1305        /// `full.summary.passed`.
1306        #[test]
1307        fn prop_apply_does_not_mutate_summary(
1308            baseline in arb_analysis_result(),
1309            current in arb_analysis_result(),
1310        ) {
1311            let delta = compute(baseline, current);
1312            let original_passed = delta.summary.passed;
1313            let view = apply(&delta, DeltaViewSpec::default());
1314            prop_assert_eq!(view.full.summary.passed, original_passed);
1315        }
1316    }
1317}