Skip to main content

crap_core/domain/
view.rs

1//! Domain-level View abstraction — the canonical pure-domain shaping
2//! primitive over `AnalysisResult`.
3//!
4//! ```text
5//! ViewSpec → view::apply(&result, spec) → AnalysisView<'_>
6//! ```
7//!
8//! # Input contract
9//!
10//! `apply` borrows an `AnalysisResult` (already-thresholded function
11//! verdicts plus a precomputed summary and gate verdict) and a
12//! by-value `ViewSpec` (filter set, sort key, optional row limit,
13//! optional group-by key). Both inputs are pure domain types — no I/O
14//! happens inside the View layer.
15//!
16//! # Pipeline order
17//!
18//! `apply` runs phases in a fixed order:
19//!
20//! ```text
21//! filter → group? → sort → truncate
22//! ```
23//!
24//! 1. **Filter** — `Filters` AND-compose; `apply_filters` returns the
25//!    eligible (post-filter, pre-shape) borrow vector.
26//! 2. **Group** (optional) — when `spec.group_by.is_some()`, eligible
27//!    rows fan into `GroupedView::files` (file-level aggregates that
28//!    are independently sorted and truncated). Function-level `shown`
29//!    retains the un-truncated eligible set under grouping for
30//!    drill-down ergonomics.
31//! 3. **Sort** — function-level (or file-level under grouping) by
32//!    `SortKey`. `sort_by` is *stable* — callers rely on input-order
33//!    preservation on tied keys (BDD: `view.feature:122-128`).
34//! 4. **Truncate** — `limit` applies to whichever level was sorted in
35//!    step 3. `Some(0)` and `None` are treated identically as "no limit"
36//!    (`--top 0` ergonomic, BDD: `view.feature:213`).
37//!
38//! # Gate keystone — unshapeable
39//!
40//! **The gate is unshapeable; only the display is shapeable.**
41//! `view.full` always borrows the original, unfiltered `AnalysisResult`,
42//! and exit-code logic must derive from `view.full.passed`, never from
43//! the post-shape `view.shown` or `view.shown_summary`. This invariant
44//! lets reporters ship `--top`, `--only-failing`, `--coverage-range`,
45//! and `--group-by` without ever changing CI's verdict.
46//!
47//! # Display predicate
48//!
49//! [`should_render_view_line`] returns true iff the shaped view
50//! materially differs from the underlying analysis (rows filtered out,
51//! function-level rows truncated, or grouped files truncated). Reporters
52//! consult this predicate to decide whether to emit a "View:" subtitle
53//! line — sort-only or default-spec invocations skip the subtitle.
54//!
55//! # `#[non_exhaustive]` extension policy
56//!
57//! Every public type in this module is `#[non_exhaustive]` so additive
58//! extensions (new `SortKey` variants, new `GroupKey` aggregations, new
59//! `Filters` predicates, new `AnalysisView` aggregates) ship as minor
60//! version bumps without requiring downstream consumers to update match
61//! arms or struct literals. Construct with `Default` + struct-update
62//! syntax (`ViewSpec { sort: SortKey::Coverage, ..Default::default() }`)
63//! to stay forward-compatible.
64//!
65//! # `crap-core` extraction
66//!
67//! Pure domain code — no I/O, no external crates beyond `serde` and
68//! `thiserror` (mirrors `domain::types`). LSP, web, and agent
69//! consumers all flow through `view::apply` as the canonical CRAP
70//! shaping surface.
71
72use crate::domain::summary::{FileSummary, compute_file_summaries, compute_summary};
73use crate::domain::types::{AnalysisResult, AnalysisSummary, FunctionVerdict};
74use serde::Serialize;
75
76// ── Spec types ───────────────────────────────────────────────────────
77
78/// Caller-supplied shape for the View pipeline.
79///
80/// `Default::default()` produces a no-op spec: no filtering, CRAP
81/// descending, no row limit, no grouping. Construct with struct-update
82/// syntax to stay forward-compatible with future fields:
83///
84/// ```ignore
85/// let spec = ViewSpec {
86///     filters: Filters { only_failing: true, ..Default::default() },
87///     sort: SortKey::Coverage,
88///     limit: Some(10),
89///     ..Default::default()
90/// };
91/// ```
92///
93/// `#[non_exhaustive]` reserves namespace for additive fields (e.g.,
94/// future `min_complexity`, `risk_floor`, secondary sort) without
95/// breaking downstream callers.
96#[non_exhaustive]
97#[derive(Debug, Clone, Default, Serialize)]
98pub struct ViewSpec {
99    /// Eligibility predicates — AND-composed. See [`Filters`].
100    pub filters: Filters,
101    /// Ordering key. See [`SortKey`].
102    pub sort: SortKey,
103    /// Maximum rows after sort. `None` and `Some(0)` mean "no limit"
104    /// (the `--top 0` ergonomic). When `group_by` is set, `limit`
105    /// shifts to the file level — function-level rows are not truncated.
106    pub limit: Option<usize>,
107    /// When set, the View carries a parallel per-key aggregation
108    /// (`AnalysisView::grouped`). The function-level row list
109    /// (`shown`) is *not* truncated under grouping — `limit` shifts to
110    /// the file level and applies to `grouped.files`. Today only
111    /// `Some(GroupKey::File)` is supported; `#[non_exhaustive]`
112    /// reserves namespace for `Risk` / `Module`.
113    pub group_by: Option<GroupKey>,
114}
115
116/// Aggregation key for the optional grouped block of an
117/// `AnalysisView`.
118///
119/// Only `File` is supported today. `#[non_exhaustive]`
120/// reserves namespace for `Risk` and `Module` as listed in the
121/// shaping doc — adding variants is additive on `crap-core`.
122#[non_exhaustive]
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
124#[serde(rename_all = "lowercase")]
125pub enum GroupKey {
126    /// Aggregate by `FunctionIdentity::file_path`.
127    File,
128}
129
130impl GroupKey {
131    /// Canonical wire string — equal to the serde JSON representation
132    /// (sans quotes). See
133    /// `crate::domain::types::ContributorKind::as_wire_str` for the
134    /// rationale; equality with serde is pinned in
135    /// `tests::wire_str_matches_serde`.
136    pub fn as_wire_str(&self) -> &'static str {
137        match self {
138            Self::File => "file",
139        }
140    }
141}
142
143/// Eligibility predicates over a `FunctionVerdict`. AND-composed: a
144/// verdict is eligible iff every active filter admits it.
145///
146/// `Default::default()` admits all verdicts (no filtering).
147/// `#[non_exhaustive]` reserves namespace for future predicates
148/// (`min_complexity`, `risk_floor`, `path_glob`, etc.) without breaking
149/// downstream construction.
150#[non_exhaustive]
151#[derive(Debug, Clone, Default, Serialize)]
152pub struct Filters {
153    /// When true, retain only verdicts where `exceeds == true`
154    /// (CRAP score strictly exceeds the threshold).
155    pub only_failing: bool,
156    /// Inclusive coverage band. Verdicts with `coverage_percent` outside
157    /// the band are excluded; non-finite coverage (NaN, ±∞) is excluded
158    /// regardless of the band.
159    pub coverage_range: Option<CoverageRange>,
160}
161
162/// Inclusive coverage range filter.
163///
164/// Both endpoints are validated to be in `[0.0, 100.0]` and `min <= max`
165/// at construction time; downstream consumers can rely on these
166/// invariants without re-checking. Construct via [`CoverageRange::new`]
167/// — direct field initialization is intentionally blocked by
168/// `#[non_exhaustive]`.
169#[non_exhaustive]
170#[derive(Debug, Clone, Copy, Serialize)]
171pub struct CoverageRange {
172    /// Lower bound (inclusive), in `[0.0, 100.0]`, finite, `<= max`.
173    pub min: f64,
174    /// Upper bound (inclusive), in `[0.0, 100.0]`, finite, `>= min`.
175    pub max: f64,
176}
177
178impl CoverageRange {
179    /// Construct a validated range. Returns [`CoverageRangeError`] when
180    /// either endpoint is outside `[0.0, 100.0]` (including non-finite),
181    /// or when `min > max`.
182    pub fn new(min: f64, max: f64) -> Result<Self, CoverageRangeError> {
183        if !is_in_unit_percent(min) {
184            return Err(CoverageRangeError::OutOfRange { value: min });
185        }
186        if !is_in_unit_percent(max) {
187            return Err(CoverageRangeError::OutOfRange { value: max });
188        }
189        if min > max {
190            return Err(CoverageRangeError::MinExceedsMax { min, max });
191        }
192        Ok(Self { min, max })
193    }
194}
195
196fn is_in_unit_percent(v: f64) -> bool {
197    v.is_finite() && (0.0..=100.0).contains(&v)
198}
199
200/// Tag-only error type — variants carry numeric context but no prose.
201/// The CLI translates these to user-facing messages so the domain stays
202/// language-agnostic for `crap-core` extraction.
203#[non_exhaustive]
204#[derive(Debug, Clone, Copy, thiserror::Error, PartialEq)]
205pub enum CoverageRangeError {
206    #[error("coverage value out of range: {value}")]
207    OutOfRange { value: f64 },
208    #[error("min ({min}) exceeds max ({max})")]
209    MinExceedsMax { min: f64, max: f64 },
210}
211
212/// Ordering key for the View pipeline's sort phase.
213///
214/// All sorts are *stable* (`sort_by`, not `sort_unstable_by`) so input
215/// order is preserved on tied keys. NaN-bearing keys (CRAP value,
216/// coverage percent) sort last under their respective orientation —
217/// non-NaN winners take the descending positions.
218///
219/// File-level interpretation under `--group-by file`:
220///
221/// | Variant       | File-level meaning              |
222/// |---------------|---------------------------------|
223/// | `Crap`        | `average_crap` descending       |
224/// | `Coverage`    | `average_coverage` ascending    |
225/// | `Complexity`  | `max_complexity` descending     |
226/// | `Path`        | `file_path` ascending           |
227///
228/// `#[non_exhaustive]` reserves namespace for future keys
229/// (e.g., risk-bucket, function-name) without forcing match-arm churn
230/// downstream.
231#[non_exhaustive]
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
233#[serde(rename_all = "lowercase")]
234pub enum SortKey {
235    /// CRAP score descending (matches the legacy table reporter's order).
236    #[default]
237    Crap,
238    /// Coverage ascending (low-coverage shown first — investigator's interest).
239    Coverage,
240    /// Complexity descending.
241    Complexity,
242    /// Alphabetical by `file_path`, then CRAP descending within file.
243    Path,
244}
245
246impl SortKey {
247    /// Canonical wire string — see `GroupKey::as_wire_str`.
248    pub fn as_wire_str(&self) -> &'static str {
249        match self {
250            Self::Crap => "crap",
251            Self::Coverage => "coverage",
252            Self::Complexity => "complexity",
253            Self::Path => "path",
254        }
255    }
256}
257
258// ── View output ──────────────────────────────────────────────────────
259
260/// The shaped result of applying a `ViewSpec` to an `AnalysisResult`.
261///
262/// `full` is borrow-only and elided from JSON output (the envelope's
263/// `result` field already carries the same data). All shaping happens
264/// over `shown`; `eligible_count` is the post-filter, pre-truncate
265/// count; `truncated` records whether `limit` reduced the row set.
266///
267/// **Gate keystone:** exit-code logic must derive from
268/// `view.full.passed`, never from `view.shown` or
269/// `view.shown_summary`. Reporters consult [`should_render_view_line`]
270/// to decide whether to emit a "View:" subtitle for the shaped output.
271///
272/// `#[non_exhaustive]` reserves namespace for future per-view aggregates
273/// (e.g., per-risk-bucket counts, per-module fan-in) without breaking
274/// downstream pattern matches.
275#[non_exhaustive]
276#[derive(Debug, Serialize)]
277pub struct AnalysisView<'a> {
278    /// Borrows the original analysis. `#[serde(skip)]` because the
279    /// envelope's `result` already serializes the full analysis.
280    /// **Gate source of truth** — exit-code logic uses `full.passed`.
281    #[serde(skip)]
282    pub full: &'a AnalysisResult,
283    /// The spec that produced this view (echoed for JSON consumers).
284    pub spec: ViewSpec,
285    /// Post-filter, pre-truncate row count. When grouping is active,
286    /// this is the function-level eligible count; the file-level
287    /// equivalent lives in [`GroupedView::eligible_count`].
288    pub eligible_count: usize,
289    /// True iff `limit` dropped function-level rows. When grouping is
290    /// active, this is forced false (the function-level row list is
291    /// not truncated under grouping); see [`GroupedView::truncated`].
292    pub truncated: bool,
293    /// Borrow vector over the shaped function rows. Order, count, and
294    /// truncation depend on `spec`.
295    pub shown: Vec<&'a FunctionVerdict>,
296    /// Summary computed over `shown` only — useful for reporters that
297    /// want a "selected subset" header. **Not** the gate source: use
298    /// `full.summary` and `full.passed` for verdict logic.
299    pub shown_summary: AnalysisSummary,
300    /// Optional parallel grouping. Present iff `spec.group_by.is_some()`.
301    /// When set, `shown` retains the *un-truncated* eligible function
302    /// rows (drill-down ergonomics) and `grouped.files` carries the
303    /// post-sort, post-truncate file list.
304    pub grouped: Option<GroupedView>,
305}
306
307/// File-level shaping over a `--group-by` view.
308///
309/// `eligible_count` and `truncated` mirror the function-level analogs
310/// but at the file level so consumers can render headers like
311/// "Showing 10 of 45 files" without recomputing.
312///
313/// `#[non_exhaustive]` reserves namespace for future per-group
314/// aggregates (e.g., risk-bucket totals, complexity histograms) without
315/// breaking downstream pattern matches.
316#[non_exhaustive]
317#[derive(Debug, Clone, Serialize)]
318pub struct GroupedView {
319    /// The key this view was grouped by (today: always `GroupKey::File`).
320    pub key: GroupKey,
321    /// Distinct files surviving the function-level filter pass —
322    /// before `limit` truncates the file list.
323    pub eligible_count: usize,
324    /// True iff `limit` reduced the file list.
325    pub truncated: bool,
326    /// Per-file aggregates, sorted and truncated per `spec.sort` and
327    /// `spec.limit` at the file level.
328    pub files: Vec<FileSummary>,
329}
330
331// ── apply: filter → sort → truncate ──────────────────────────────────
332
333/// Apply a `ViewSpec` to an `AnalysisResult`, producing the shaped
334/// `AnalysisView`.
335///
336/// Phases run in fixed order: **filter → group? → sort → truncate**.
337/// See the module-level docs for the full pipeline contract.
338///
339/// The returned view borrows from `result`; `view.full == &result` is
340/// guaranteed (pointer-equal). The gate verdict (`view.full.passed`,
341/// `view.full.summary`) is *unshapeable* — it always reflects the
342/// pre-shape analysis, regardless of how aggressively the spec
343/// filters or truncates.
344///
345/// Stable sort: input order is preserved on tied sort keys. NaN-bearing
346/// keys sort last under their orientation.
347///
348/// `apply` is total — it never panics, even on NaN coverage or empty
349/// inputs. See the BDD harness `tests/features/view.feature` for the
350/// full behavioral contract and the property-test suite at the bottom
351/// of this module for the order/identity/summary/display invariants.
352pub fn apply<'a>(result: &'a AnalysisResult, spec: ViewSpec) -> AnalysisView<'a> {
353    let eligible: Vec<&'a FunctionVerdict> = apply_filters(&result.functions, &spec.filters);
354    let eligible_count = eligible.len();
355
356    // Order of ops:
357    //   filter → group? → sort+truncate (function-level OR file-level)
358    //
359    // When grouping is active, `shown` carries the *un-truncated*
360    // eligible function rows for drill-down (the JSON consumer's
361    // `view.shown[] | select(...)` flow), and the function-level
362    // `truncated` flag is forced false because no function-level
363    // truncation took place. The file list carries its own
364    // `truncated` flag inside `GroupedView`.
365    let grouped = apply_grouping(&eligible, &spec);
366
367    let (shown, truncated) = if grouped.is_some() {
368        (eligible, false)
369    } else {
370        let mut shown = eligible;
371        sort_in_place(&mut shown, spec.sort);
372        let truncated = truncate_to(&mut shown, spec.limit);
373        (shown, truncated)
374    };
375
376    // `compute_summary` accepts any `IntoIterator<Item = &FunctionVerdict>`,
377    // so we feed it the borrowed `shown` directly — no per-`apply()` clone.
378    let shown_summary = compute_summary(shown.iter().copied());
379
380    AnalysisView {
381        full: result,
382        spec,
383        eligible_count,
384        truncated,
385        shown,
386        shown_summary,
387        grouped,
388    }
389}
390
391/// Build the optional `GroupedView` from the eligible (post-filter) row set.
392///
393/// Returns `None` iff `spec.group_by.is_none()` — the biconditional that
394/// keeps reporters' branching decisions simple. The returned files are
395/// sorted by the `spec.sort` key at the *file* level and truncated to
396/// `spec.limit` (if any). The function-level row list and gate are
397/// untouched: `view.shown` and `view.full.passed` still describe the
398/// underlying analysis.
399fn apply_grouping(eligible: &[&FunctionVerdict], spec: &ViewSpec) -> Option<GroupedView> {
400    let key = spec.group_by?;
401    let mut files = compute_file_summaries(eligible.iter().copied());
402    let eligible_count = files.len();
403    sort_files_in_place(&mut files, spec.sort);
404    let truncated = truncate_files_to(&mut files, spec.limit);
405    Some(GroupedView {
406        key,
407        eligible_count,
408        truncated,
409        files,
410    })
411}
412
413/// File-level sort. Mirrors the function-level `SortKey` semantics but
414/// applied to per-file aggregates:
415///
416/// | SortKey    | File-level interpretation                     |
417/// |------------|-----------------------------------------------|
418/// | `Crap`     | `average_crap` descending                     |
419/// | `Coverage` | `average_coverage` ascending                  |
420/// | `Complexity` | `max_complexity` descending                 |
421/// | `Path`     | `file_path` ascending                         |
422fn sort_files_in_place(files: &mut [FileSummary], key: SortKey) {
423    match key {
424        SortKey::Crap => files.sort_by(cmp_files_by_avg_crap),
425        SortKey::Coverage => files.sort_by(cmp_files_by_avg_coverage),
426        SortKey::Complexity => files.sort_by_key(|f| std::cmp::Reverse(f.max_complexity)),
427        SortKey::Path => files.sort_by(|a, b| a.file_path.cmp(&b.file_path)),
428    }
429}
430
431fn cmp_files_by_avg_crap(a: &FileSummary, b: &FileSummary) -> std::cmp::Ordering {
432    let (ax, bx) = (a.average_crap, b.average_crap);
433    match (ax.is_nan(), bx.is_nan()) {
434        (true, true) => std::cmp::Ordering::Equal,
435        (true, false) => std::cmp::Ordering::Greater,
436        (false, true) => std::cmp::Ordering::Less,
437        (false, false) => bx.partial_cmp(&ax).expect("non-NaN partial_cmp infallible"),
438    }
439}
440
441fn cmp_files_by_avg_coverage(a: &FileSummary, b: &FileSummary) -> std::cmp::Ordering {
442    let (ax, bx) = (a.average_coverage, b.average_coverage);
443    match (ax.is_nan(), bx.is_nan()) {
444        (true, true) => std::cmp::Ordering::Equal,
445        (true, false) => std::cmp::Ordering::Greater,
446        (false, true) => std::cmp::Ordering::Less,
447        (false, false) => ax.partial_cmp(&bx).expect("non-NaN partial_cmp infallible"),
448    }
449}
450
451fn truncate_files_to(files: &mut Vec<FileSummary>, limit: Option<usize>) -> bool {
452    match limit {
453        Some(n) if n > 0 && files.len() > n => {
454            files.truncate(n);
455            true
456        }
457        _ => false,
458    }
459}
460
461/// Filter pass — returns a vector of references that match every active filter.
462///
463/// AND-composes filters: a verdict is eligible iff every active filter
464/// admits it. The coverage-range branch uses `is_finite()` so non-finite
465/// coverage is excluded — NaN in practice (BDD: view.feature:231), and
466/// also ±∞ defensively. LCOV-derived percentages should never be infinite,
467/// but the wider check costs nothing and keeps the comparator total.
468fn apply_filters<'a>(
469    verdicts: &'a [FunctionVerdict],
470    filters: &Filters,
471) -> Vec<&'a FunctionVerdict> {
472    verdicts
473        .iter()
474        .filter(|v| !filters.only_failing || v.exceeds)
475        .filter(|v| match &filters.coverage_range {
476            Some(range) => matches_coverage_range(v.scored.coverage_percent, range),
477            None => true,
478        })
479        .collect()
480}
481
482fn matches_coverage_range(cov: f64, range: &CoverageRange) -> bool {
483    cov.is_finite() && cov >= range.min && cov <= range.max
484}
485
486// ── Sort dispatch + comparators ──────────────────────────────────────
487
488fn sort_in_place(shown: &mut [&FunctionVerdict], key: SortKey) {
489    // sort_by — stable. NOT sort_unstable_by: callers rely on input-order
490    // preservation for tied keys (BDD: view.feature:122-128).
491    match key {
492        SortKey::Crap => shown.sort_by(cmp_by_crap),
493        SortKey::Coverage => shown.sort_by(cmp_by_coverage),
494        SortKey::Complexity => shown.sort_by(cmp_by_complexity),
495        SortKey::Path => shown.sort_by(cmp_by_path),
496    }
497}
498
499/// CRAP descending. f64-bearing — NaN sorts last under the descending
500/// order (i.e., comparator returns `Less` for non-NaN vs NaN-second so
501/// non-NaN wins the descending position; symmetrically for NaN-first).
502fn cmp_by_crap(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
503    let (ax, bx) = (a.scored.crap.value, b.scored.crap.value);
504    match (ax.is_nan(), bx.is_nan()) {
505        (true, true) => std::cmp::Ordering::Equal,
506        (true, false) => std::cmp::Ordering::Greater,
507        (false, true) => std::cmp::Ordering::Less,
508        (false, false) => bx.partial_cmp(&ax).expect("non-NaN partial_cmp infallible"),
509    }
510}
511
512/// Coverage ascending. NaN sorts last (BDD: view.feature:237).
513fn cmp_by_coverage(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
514    let (ax, bx) = (a.scored.coverage_percent, b.scored.coverage_percent);
515    match (ax.is_nan(), bx.is_nan()) {
516        (true, true) => std::cmp::Ordering::Equal,
517        (true, false) => std::cmp::Ordering::Greater,
518        (false, true) => std::cmp::Ordering::Less,
519        (false, false) => ax.partial_cmp(&bx).expect("non-NaN partial_cmp infallible"),
520    }
521}
522
523/// Complexity descending. `u32` is `Ord`; no NaN concerns.
524fn cmp_by_complexity(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
525    b.scored.complexity.cmp(&a.scored.complexity)
526}
527
528/// Path alphabetical ascending; ties broken by CRAP descending.
529fn cmp_by_path(a: &&FunctionVerdict, b: &&FunctionVerdict) -> std::cmp::Ordering {
530    match a
531        .scored
532        .identity
533        .file_path
534        .cmp(&b.scored.identity.file_path)
535    {
536        std::cmp::Ordering::Equal => cmp_by_crap(a, b),
537        ord => ord,
538    }
539}
540
541// ── Truncate ─────────────────────────────────────────────────────────
542
543/// Truncate `shown` to `limit` entries. Returns whether any rows were
544/// dropped. `Some(0)` and `None` are treated identically as "no limit"
545/// (BDD: view.feature:213 — `--top 0` semantics).
546fn truncate_to(shown: &mut Vec<&FunctionVerdict>, limit: Option<usize>) -> bool {
547    match limit {
548        Some(n) if n > 0 && shown.len() > n => {
549            shown.truncate(n);
550            true
551        }
552        _ => false,
553    }
554}
555
556// ── Display predicate ────────────────────────────────────────────────
557
558/// True iff the shaped view materially differs from the underlying
559/// analysis. Returns `true` when any of:
560///
561/// - Filtering reduced the eligible row count
562///   (`eligible_count < full.functions.len()`),
563/// - The function-level `limit` truncated rows (`view.truncated`),
564/// - Grouping is active and the file-level `limit` truncated files.
565///
566/// Reporters use this to decide whether to emit a "View:" subtitle
567/// line. Default `ViewSpec` over a non-empty result returns `false` —
568/// the walking-skeleton invariant. Sort-only invocations also return
569/// `false`: changing order doesn't change information content.
570pub fn should_render_view_line(view: &AnalysisView<'_>) -> bool {
571    view.eligible_count < view.full.functions.len()
572        || view.truncated
573        || view.grouped.as_ref().is_some_and(|g| g.truncated)
574}
575
576// ── Tests ────────────────────────────────────────────────────────────
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use crate::domain::types::{
582        AnalysisSummary, ComplexityMetric, CrapScore, FunctionIdentity, FunctionVerdict,
583        RiskDistribution, ScoredFunction, SourceSpan,
584    };
585
586    // ── Fixture helpers ────────────────────────────────────────────
587
588    fn mk_verdict(
589        name: &str,
590        file: &str,
591        complexity: u32,
592        coverage: f64,
593        crap_value: f64,
594        threshold: f64,
595    ) -> FunctionVerdict {
596        let risk_level = crate::domain::crap::classify_risk(crap_value);
597        FunctionVerdict {
598            scored: ScoredFunction {
599                identity: FunctionIdentity {
600                    file_path: file.to_string(),
601                    qualified_name: name.to_string(),
602                    span: SourceSpan {
603                        start_line: 1,
604                        end_line: 10,
605                        start_column: 0,
606                        end_column: 0,
607                    },
608                },
609                complexity,
610                complexity_metric: ComplexityMetric::Cognitive,
611                coverage_percent: coverage,
612                // View-layer test fixtures don't exercise branch
613                // coverage today; branch_coverage_percent stays None so
614                // sort / filter / truncate invariants are isolated from
615                // the new field.
616                branch_coverage_percent: None,
617                crap: CrapScore {
618                    value: crap_value,
619                    risk_level,
620                },
621                contributors: vec![],
622            },
623            threshold,
624            exceeds: crap_value > threshold,
625            diagnostic: None,
626        }
627    }
628
629    /// Background fixture from view.feature ll. 9-17. Threshold 25.0.
630    fn background_fixture() -> AnalysisResult {
631        let verdicts = vec![
632            mk_verdict("parse_lcov", "src/adapters/lcov.rs", 12, 100.0, 12.00, 25.0),
633            mk_verdict("walk_ast", "src/adapters/syn.rs", 18, 75.0, 23.06, 25.0),
634            mk_verdict(
635                "render_table",
636                "src/adapters/table.rs",
637                9,
638                60.0,
639                14.18,
640                25.0,
641            ),
642            mk_verdict(
643                "apply_threshold",
644                "src/domain/threshold.rs",
645                4,
646                100.0,
647                4.00,
648                25.0,
649            ),
650            mk_verdict(
651                "sort_verdicts",
652                "src/adapters/table.rs",
653                6,
654                0.0,
655                42.00,
656                25.0,
657            ),
658            mk_verdict("parse_args", "src/cli/mod.rs", 22, 50.0, 63.50, 25.0),
659        ];
660        let summary = compute_summary(&verdicts);
661        let passed = verdicts.iter().all(|v| !v.exceeds);
662        AnalysisResult {
663            functions: verdicts,
664            summary,
665            passed,
666        }
667    }
668
669    fn empty_result() -> AnalysisResult {
670        AnalysisResult {
671            functions: vec![],
672            summary: AnalysisSummary {
673                total_functions: 0,
674                total_files: 0,
675                exceeding_threshold: 0,
676                average_crap: 0.0,
677                median_crap: 0.0,
678                max_crap: None,
679                worst_function: None,
680                distribution: RiskDistribution {
681                    low: 0,
682                    acceptable: 0,
683                    moderate: 0,
684                    high: 0,
685                },
686                ..Default::default()
687            },
688            passed: true,
689        }
690    }
691
692    // ── Default-spec invariants (Order, Identity, Summary, immutability) ───
693
694    #[test]
695    fn default_spec_is_noop_on_fixture() {
696        // view.feature ll. 25-31: default spec produces a no-op view in
697        // CRAP-descending order. Equivalent: shown contains every function;
698        // eligible_count equals total; truncated false; CRAP desc.
699        let r = background_fixture();
700        let view = apply(&r, ViewSpec::default());
701        assert_eq!(view.shown.len(), r.functions.len());
702        assert_eq!(view.eligible_count, r.functions.len());
703        assert!(!view.truncated);
704        // Pointer equality on `view.full` (no PartialEq derive needed)
705        assert!(std::ptr::eq(view.full, &r));
706        // Order: CRAP descending
707        for w in view.shown.windows(2) {
708            assert!(
709                w[0].scored.crap.value >= w[1].scored.crap.value,
710                "expected CRAP descending; got {} then {}",
711                w[0].scored.crap.value,
712                w[1].scored.crap.value
713            );
714        }
715    }
716
717    #[test]
718    fn default_spec_empty_input_is_empty_view() {
719        // view.feature l. 197.
720        let r = empty_result();
721        let view = apply(&r, ViewSpec::default());
722        assert!(view.shown.is_empty());
723        assert_eq!(view.eligible_count, 0);
724        assert!(!view.truncated);
725        assert!(view.full.passed);
726    }
727
728    #[test]
729    fn view_full_immutability_after_apply() {
730        // view.feature l. 221.
731        let r = background_fixture();
732        let crap_before: Vec<f64> = r.functions.iter().map(|v| v.scored.crap.value).collect();
733        let view = apply(&r, ViewSpec::default());
734        // view.full points at r
735        assert!(std::ptr::eq(view.full, &r));
736        // r itself is unchanged
737        let crap_after: Vec<f64> = r.functions.iter().map(|v| v.scored.crap.value).collect();
738        assert_eq!(crap_before, crap_after);
739    }
740
741    #[test]
742    fn default_spec_preserves_identity_set() {
743        // view.feature ll. 33-35.
744        let r = background_fixture();
745        let view = apply(&r, ViewSpec::default());
746        let shown_names: std::collections::HashSet<&String> = view
747            .shown
748            .iter()
749            .map(|v| &v.scored.identity.qualified_name)
750            .collect();
751        let original_names: std::collections::HashSet<&String> = r
752            .functions
753            .iter()
754            .map(|v| &v.scored.identity.qualified_name)
755            .collect();
756        assert_eq!(shown_names, original_names);
757    }
758
759    // ── CoverageRange constructor: 7-row table ─────────────────────
760
761    #[test]
762    fn coverage_range_new_validation_table() {
763        // view.feature ll. 79-91.
764        type Case = (f64, f64, Result<(f64, f64), ()>);
765        let cases: &[Case] = &[
766            (0.0, 100.0, Ok((0.0, 100.0))),
767            (50.0, 50.0, Ok((50.0, 50.0))),
768            (1.0, 90.0, Ok((1.0, 90.0))),
769            (-0.1, 50.0, Err(())),
770            (50.0, 100.1, Err(())),
771            (90.0, 50.0, Err(())),
772            (100.0, 0.0, Err(())),
773        ];
774        for (min, max, expect) in cases {
775            let got = CoverageRange::new(*min, *max);
776            match (got, expect) {
777                (Ok(r), Ok((emin, emax))) => {
778                    assert!(
779                        (r.min - emin).abs() < 1e-9 && (r.max - emax).abs() < 1e-9,
780                        "min={min}, max={max}: got {r:?}, expected ({emin}, {emax})"
781                    );
782                }
783                (Err(_), Err(())) => {} // good
784                (got, expect) => panic!("min={min}, max={max}: got {got:?}, expected {expect:?}"),
785            }
786        }
787    }
788
789    #[test]
790    fn coverage_range_error_variants() {
791        // out-of-range vs min-exceeds-max are distinct, tag-only variants.
792        let oor = CoverageRange::new(-1.0, 50.0).unwrap_err();
793        assert!(matches!(oor, CoverageRangeError::OutOfRange { .. }));
794        let mxm = CoverageRange::new(80.0, 20.0).unwrap_err();
795        assert!(matches!(mxm, CoverageRangeError::MinExceedsMax { .. }));
796    }
797
798    // ── Sort stability — the surgical mutation killer ───────────
799
800    #[test]
801    fn sort_stability_on_tied_crap() {
802        // view.feature ll. 122-128. Catches `sort_by → sort_unstable_by`.
803        // Hand-built deterministic [foo, bar] both at CRAP=12.0.
804        let foo = mk_verdict("foo", "src/a.rs", 5, 80.0, 12.0, 25.0);
805        let bar = mk_verdict("bar", "src/a.rs", 5, 80.0, 12.0, 25.0);
806        let r = AnalysisResult {
807            functions: vec![foo, bar],
808            summary: empty_result().summary, // unused
809            passed: true,
810        };
811        let view = apply(&r, ViewSpec::default());
812        // Input order [foo, bar] preserved on tied keys.
813        assert_eq!(
814            view.shown[0].scored.identity.qualified_name,
815            "foo",
816            "stable sort must preserve input order on ties; got {:?}",
817            view.shown
818                .iter()
819                .map(|v| &v.scored.identity.qualified_name)
820                .collect::<Vec<_>>()
821        );
822        assert_eq!(view.shown[1].scored.identity.qualified_name, "bar");
823    }
824
825    // ── Filters ────────────────────────────────────────────────────
826
827    #[test]
828    fn only_failing_filter_retains_only_exceeds_true() {
829        // view.feature l. 44.
830        let r = background_fixture();
831        let spec = ViewSpec {
832            filters: Filters {
833                only_failing: true,
834                ..Default::default()
835            },
836            ..Default::default()
837        };
838        let view = apply(&r, spec);
839        assert!(view.shown.iter().all(|v| v.exceeds));
840        // And every shown CRAP exceeds threshold
841        for v in &view.shown {
842            assert!(v.scored.crap.value > v.threshold);
843        }
844    }
845
846    #[test]
847    fn coverage_range_filter_inclusive() {
848        // view.feature l. 50.
849        let r = background_fixture();
850        let range = CoverageRange::new(50.0, 90.0).unwrap();
851        let spec = ViewSpec {
852            filters: Filters {
853                coverage_range: Some(range),
854                ..Default::default()
855            },
856            ..Default::default()
857        };
858        let view = apply(&r, spec);
859        assert!(view.shown.iter().all(|v| {
860            let cov = v.scored.coverage_percent;
861            cov.is_finite() && (50.0..=90.0).contains(&cov)
862        }));
863        let manual_count = r
864            .functions
865            .iter()
866            .filter(|v| v.scored.coverage_percent.is_finite())
867            .filter(|v| (50.0..=90.0).contains(&v.scored.coverage_percent))
868            .count();
869        assert_eq!(view.eligible_count, manual_count);
870    }
871
872    #[test]
873    fn coverage_range_boundary_inclusive_50_low() {
874        // view.feature ll. 56-68 row 1: cov=50.0 in 50..=90 → appears.
875        let v = mk_verdict("at50", "src/a.rs", 1, 50.0, 1.0, 25.0);
876        let r = AnalysisResult {
877            functions: vec![v],
878            summary: empty_result().summary,
879            passed: true,
880        };
881        let range = CoverageRange::new(50.0, 90.0).unwrap();
882        let spec = ViewSpec {
883            filters: Filters {
884                coverage_range: Some(range),
885                ..Default::default()
886            },
887            ..Default::default()
888        };
889        let view = apply(&r, spec);
890        assert_eq!(view.shown.len(), 1);
891    }
892
893    #[test]
894    fn coverage_range_boundary_inclusive_90_high() {
895        // row 2: cov=90.0 in 50..=90 → appears.
896        let v = mk_verdict("at90", "src/a.rs", 1, 90.0, 1.0, 25.0);
897        let r = AnalysisResult {
898            functions: vec![v],
899            summary: empty_result().summary,
900            passed: true,
901        };
902        let range = CoverageRange::new(50.0, 90.0).unwrap();
903        let spec = ViewSpec {
904            filters: Filters {
905                coverage_range: Some(range),
906                ..Default::default()
907            },
908            ..Default::default()
909        };
910        let view = apply(&r, spec);
911        assert_eq!(view.shown.len(), 1);
912    }
913
914    #[test]
915    fn coverage_range_boundary_inclusive_below_low() {
916        // row 3: cov=49.9 in 50..=90 → absent.
917        let v = mk_verdict("just_under", "src/a.rs", 1, 49.9, 1.0, 25.0);
918        let r = AnalysisResult {
919            functions: vec![v],
920            summary: empty_result().summary,
921            passed: true,
922        };
923        let range = CoverageRange::new(50.0, 90.0).unwrap();
924        let spec = ViewSpec {
925            filters: Filters {
926                coverage_range: Some(range),
927                ..Default::default()
928            },
929            ..Default::default()
930        };
931        let view = apply(&r, spec);
932        assert!(view.shown.is_empty());
933    }
934
935    #[test]
936    fn coverage_range_boundary_inclusive_above_high() {
937        // row 4: cov=90.1 in 50..=90 → absent.
938        let v = mk_verdict("just_over", "src/a.rs", 1, 90.1, 1.0, 25.0);
939        let r = AnalysisResult {
940            functions: vec![v],
941            summary: empty_result().summary,
942            passed: true,
943        };
944        let range = CoverageRange::new(50.0, 90.0).unwrap();
945        let spec = ViewSpec {
946            filters: Filters {
947                coverage_range: Some(range),
948                ..Default::default()
949            },
950            ..Default::default()
951        };
952        let view = apply(&r, spec);
953        assert!(view.shown.is_empty());
954    }
955
956    #[test]
957    fn coverage_range_boundary_inclusive_zero_singleton() {
958        // row 5: cov=0.0 in 0..=0 → appears.
959        let v = mk_verdict("zero", "src/a.rs", 1, 0.0, 1.0, 25.0);
960        let r = AnalysisResult {
961            functions: vec![v],
962            summary: empty_result().summary,
963            passed: true,
964        };
965        let range = CoverageRange::new(0.0, 0.0).unwrap();
966        let spec = ViewSpec {
967            filters: Filters {
968                coverage_range: Some(range),
969                ..Default::default()
970            },
971            ..Default::default()
972        };
973        let view = apply(&r, spec);
974        assert_eq!(view.shown.len(), 1);
975    }
976
977    #[test]
978    fn coverage_range_boundary_inclusive_hundred_singleton() {
979        // row 6: cov=100.0 in 100..=100 → appears.
980        let v = mk_verdict("full", "src/a.rs", 1, 100.0, 1.0, 25.0);
981        let r = AnalysisResult {
982            functions: vec![v],
983            summary: empty_result().summary,
984            passed: true,
985        };
986        let range = CoverageRange::new(100.0, 100.0).unwrap();
987        let spec = ViewSpec {
988            filters: Filters {
989                coverage_range: Some(range),
990                ..Default::default()
991            },
992            ..Default::default()
993        };
994        let view = apply(&r, spec);
995        assert_eq!(view.shown.len(), 1);
996    }
997
998    #[test]
999    fn filters_and_compose() {
1000        // view.feature l. 70: filters AND-compose.
1001        // only_failing AND coverage_range [50, 100] → both must hold.
1002        let r = background_fixture();
1003        let range = CoverageRange::new(50.0, 100.0).unwrap();
1004        let spec = ViewSpec {
1005            filters: Filters {
1006                only_failing: true,
1007                coverage_range: Some(range),
1008            },
1009            ..Default::default()
1010        };
1011        let view = apply(&r, spec);
1012        for v in &view.shown {
1013            assert!(v.exceeds);
1014            let cov = v.scored.coverage_percent;
1015            assert!((50.0..=100.0).contains(&cov));
1016        }
1017    }
1018
1019    #[test]
1020    fn nan_coverage_excluded_from_range_filter() {
1021        // view.feature l. 231: NaN coverage excluded from range filter.
1022        let v = mk_verdict("zero_lines", "src/a.rs", 1, f64::NAN, 1.0, 25.0);
1023        let r = AnalysisResult {
1024            functions: vec![v],
1025            summary: empty_result().summary,
1026            passed: true,
1027        };
1028        let range = CoverageRange::new(0.0, 100.0).unwrap();
1029        let spec = ViewSpec {
1030            filters: Filters {
1031                coverage_range: Some(range),
1032                ..Default::default()
1033            },
1034            ..Default::default()
1035        };
1036        let view = apply(&r, spec);
1037        assert!(view.shown.is_empty());
1038    }
1039
1040    // ── Sort ───────────────────────────────────────────────────────
1041
1042    #[test]
1043    fn sort_by_crap_descending() {
1044        // view.feature ll. 95-98.
1045        let r = background_fixture();
1046        let spec = ViewSpec {
1047            sort: SortKey::Crap,
1048            ..Default::default()
1049        };
1050        let view = apply(&r, spec);
1051        for w in view.shown.windows(2) {
1052            assert!(w[0].scored.crap.value >= w[1].scored.crap.value);
1053        }
1054    }
1055
1056    #[test]
1057    fn sort_by_coverage_ascending() {
1058        // view.feature ll. 100-103.
1059        let r = background_fixture();
1060        let spec = ViewSpec {
1061            sort: SortKey::Coverage,
1062            ..Default::default()
1063        };
1064        let view = apply(&r, spec);
1065        for w in view.shown.windows(2) {
1066            assert!(w[0].scored.coverage_percent <= w[1].scored.coverage_percent);
1067        }
1068    }
1069
1070    #[test]
1071    fn sort_by_complexity_descending() {
1072        // view.feature ll. 105-108.
1073        let r = background_fixture();
1074        let spec = ViewSpec {
1075            sort: SortKey::Complexity,
1076            ..Default::default()
1077        };
1078        let view = apply(&r, spec);
1079        for w in view.shown.windows(2) {
1080            assert!(w[0].scored.complexity >= w[1].scored.complexity);
1081        }
1082    }
1083
1084    #[test]
1085    fn sort_by_path_alphabetical_then_crap() {
1086        // view.feature ll. 110-114.
1087        let r = background_fixture();
1088        let spec = ViewSpec {
1089            sort: SortKey::Path,
1090            ..Default::default()
1091        };
1092        let view = apply(&r, spec);
1093        // Primary: file_path ascending.
1094        for w in view.shown.windows(2) {
1095            let (a, b) = (
1096                &w[0].scored.identity.file_path,
1097                &w[1].scored.identity.file_path,
1098            );
1099            assert!(a <= b, "files not in ascending order: {a} then {b}");
1100        }
1101        // Within each file: CRAP descending.
1102        for w in view.shown.windows(2) {
1103            if w[0].scored.identity.file_path == w[1].scored.identity.file_path {
1104                assert!(
1105                    w[0].scored.crap.value >= w[1].scored.crap.value,
1106                    "within file {}: CRAP not descending: {} then {}",
1107                    w[0].scored.identity.file_path,
1108                    w[0].scored.crap.value,
1109                    w[1].scored.crap.value
1110                );
1111            }
1112        }
1113    }
1114
1115    #[test]
1116    fn sort_by_path_secondary_multi_file() {
1117        // view.feature ll. 116-120: 3 files, 5 verdicts.
1118        // src/a.rs (5, 30), src/b.rs (10), src/c.rs (1, 50)
1119        // Expected: a.rs::30, a.rs::5, b.rs::10, c.rs::50, c.rs::1
1120        let verdicts = vec![
1121            mk_verdict("a_low", "src/a.rs", 1, 50.0, 5.0, 25.0),
1122            mk_verdict("a_high", "src/a.rs", 1, 50.0, 30.0, 25.0),
1123            mk_verdict("b_only", "src/b.rs", 1, 50.0, 10.0, 25.0),
1124            mk_verdict("c_low", "src/c.rs", 1, 50.0, 1.0, 25.0),
1125            mk_verdict("c_high", "src/c.rs", 1, 50.0, 50.0, 25.0),
1126        ];
1127        let r = AnalysisResult {
1128            functions: verdicts,
1129            summary: empty_result().summary,
1130            passed: true,
1131        };
1132        let spec = ViewSpec {
1133            sort: SortKey::Path,
1134            ..Default::default()
1135        };
1136        let view = apply(&r, spec);
1137        let names: Vec<&str> = view
1138            .shown
1139            .iter()
1140            .map(|v| v.scored.identity.qualified_name.as_str())
1141            .collect();
1142        assert_eq!(
1143            names,
1144            vec!["a_high", "a_low", "b_only", "c_high", "c_low"],
1145            "path sort with secondary CRAP-desc order wrong"
1146        );
1147    }
1148
1149    #[test]
1150    fn nan_coverage_sorts_last_under_coverage_ascending() {
1151        // view.feature ll. 237-242. Coverages: [10.0, NaN, 50.0, NaN, 90.0].
1152        let verdicts = vec![
1153            mk_verdict("c10", "src/a.rs", 1, 10.0, 1.0, 25.0),
1154            mk_verdict("nan1", "src/a.rs", 1, f64::NAN, 1.0, 25.0),
1155            mk_verdict("c50", "src/a.rs", 1, 50.0, 1.0, 25.0),
1156            mk_verdict("nan2", "src/a.rs", 1, f64::NAN, 1.0, 25.0),
1157            mk_verdict("c90", "src/a.rs", 1, 90.0, 1.0, 25.0),
1158        ];
1159        let r = AnalysisResult {
1160            functions: verdicts,
1161            summary: empty_result().summary,
1162            passed: true,
1163        };
1164        let spec = ViewSpec {
1165            sort: SortKey::Coverage,
1166            ..Default::default()
1167        };
1168        let view = apply(&r, spec);
1169        // First 3 are non-NaN ascending; last 2 are NaN.
1170        let coverages: Vec<f64> = view
1171            .shown
1172            .iter()
1173            .map(|v| v.scored.coverage_percent)
1174            .collect();
1175        assert_eq!(coverages[0], 10.0);
1176        assert_eq!(coverages[1], 50.0);
1177        assert_eq!(coverages[2], 90.0);
1178        assert!(coverages[3].is_nan());
1179        assert!(coverages[4].is_nan());
1180    }
1181
1182    // ── Truncate ───────────────────────────────────────────────────
1183
1184    #[test]
1185    fn limit_truncates() {
1186        // view.feature ll. 132-137. Background has 6 functions; limit=3.
1187        let r = background_fixture();
1188        let spec = ViewSpec {
1189            limit: Some(3),
1190            ..Default::default()
1191        };
1192        let view = apply(&r, spec);
1193        assert_eq!(view.shown.len(), 3);
1194        assert_eq!(view.eligible_count, 6);
1195        assert!(view.truncated);
1196    }
1197
1198    #[test]
1199    fn limit_greater_than_eligible() {
1200        // view.feature ll. 139-144.
1201        let r = background_fixture();
1202        let spec = ViewSpec {
1203            limit: Some(100),
1204            ..Default::default()
1205        };
1206        let view = apply(&r, spec);
1207        assert_eq!(view.shown.len(), 6);
1208        assert_eq!(view.eligible_count, 6);
1209        assert!(!view.truncated);
1210    }
1211
1212    #[test]
1213    fn limit_none() {
1214        // view.feature ll. 146-150.
1215        let r = background_fixture();
1216        let spec = ViewSpec {
1217            limit: None,
1218            ..Default::default()
1219        };
1220        let view = apply(&r, spec);
1221        assert_eq!(view.shown.len(), view.eligible_count);
1222        assert!(!view.truncated);
1223    }
1224
1225    #[test]
1226    fn limit_zero_treated_as_no_limit() {
1227        // view.feature l. 213. --top 0 ⇒ limit = None semantics.
1228        // Construct directly with Some(0); the code treats it as no-limit.
1229        let r = background_fixture();
1230        let spec = ViewSpec {
1231            limit: Some(0),
1232            ..Default::default()
1233        };
1234        let view = apply(&r, spec);
1235        assert_eq!(view.shown.len(), view.eligible_count);
1236        assert!(!view.truncated);
1237    }
1238
1239    #[test]
1240    fn limit_equal_to_eligible_does_not_mark_truncated() {
1241        // Boundary: shown.len() == limit. Mutation-killer for `>` vs `>=`
1242        // in `truncate_to`. The data is unchanged either way, but
1243        // `truncated` MUST stay false when nothing was actually dropped.
1244        let r = background_fixture();
1245        assert_eq!(r.functions.len(), 6, "background fixture sanity");
1246        let spec = ViewSpec {
1247            limit: Some(6),
1248            ..Default::default()
1249        };
1250        let view = apply(&r, spec);
1251        assert_eq!(view.shown.len(), 6);
1252        assert_eq!(view.eligible_count, 6);
1253        assert!(!view.truncated, "limit == eligible must NOT mark truncated");
1254    }
1255
1256    // ── Order of operations ────────────────────────────────────────
1257
1258    #[test]
1259    fn order_filter_then_sort_then_truncate() {
1260        // view.feature ll. 154-160.
1261        // only_failing AND sort=Coverage AND limit=2
1262        let r = background_fixture();
1263        let spec = ViewSpec {
1264            filters: Filters {
1265                only_failing: true,
1266                ..Default::default()
1267            },
1268            sort: SortKey::Coverage,
1269            limit: Some(2),
1270            ..Default::default()
1271        };
1272        let view = apply(&r, spec);
1273        assert_eq!(view.shown.len(), 2);
1274        for v in &view.shown {
1275            assert!(v.exceeds);
1276        }
1277        // Coverage ascending
1278        assert!(view.shown[0].scored.coverage_percent <= view.shown[1].scored.coverage_percent);
1279        // eligible_count = total failing functions before truncation
1280        let total_failing = r.functions.iter().filter(|v| v.exceeds).count();
1281        assert_eq!(view.eligible_count, total_failing);
1282    }
1283
1284    #[test]
1285    fn truncation_does_not_change_gate() {
1286        // view.feature ll. 162-168 — Given an analysis with 3 functions
1287        // exceeding threshold. Construct that fixture explicitly.
1288        let verdicts = vec![
1289            mk_verdict("ok", "src/a.rs", 1, 100.0, 1.0, 25.0),
1290            mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
1291            mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
1292            mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
1293        ];
1294        let summary = compute_summary(&verdicts);
1295        let passed = verdicts.iter().all(|v| !v.exceeds);
1296        let r = AnalysisResult {
1297            functions: verdicts,
1298            summary,
1299            passed,
1300        };
1301        let spec = ViewSpec {
1302            limit: Some(1),
1303            ..Default::default()
1304        };
1305        let view = apply(&r, spec);
1306        assert_eq!(view.shown.len(), 1);
1307        // gate = view.full.passed (false) and exceeding count unchanged.
1308        assert!(!view.full.passed);
1309        assert_eq!(view.full.summary.exceeding_threshold, 3);
1310    }
1311
1312    #[test]
1313    fn filtering_does_not_change_gate() {
1314        // view.feature ll. 170-175 — analysis with 3 exceeding, filter
1315        // excludes all of them.
1316        let verdicts = vec![
1317            mk_verdict("ok", "src/a.rs", 1, 100.0, 1.0, 25.0),
1318            mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
1319            mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
1320            mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
1321        ];
1322        let summary = compute_summary(&verdicts);
1323        let r = AnalysisResult {
1324            functions: verdicts,
1325            summary,
1326            passed: false,
1327        };
1328        // Range [99, 100] keeps "ok" only; excludes the 3 failing ones.
1329        let range = CoverageRange::new(99.0, 100.0).unwrap();
1330        let spec = ViewSpec {
1331            filters: Filters {
1332                coverage_range: Some(range),
1333                ..Default::default()
1334            },
1335            ..Default::default()
1336        };
1337        let view = apply(&r, spec);
1338        assert!(view.shown.iter().all(|v| !v.exceeds));
1339        assert!(!view.full.passed);
1340        assert_eq!(view.full.summary.exceeding_threshold, 3);
1341    }
1342
1343    // ── shown_summary ──────────────────────────────────────────────
1344
1345    #[test]
1346    fn shown_summary_over_shown_subset() {
1347        // view.feature ll. 179-186.
1348        let r = background_fixture();
1349        let spec = ViewSpec {
1350            filters: Filters {
1351                only_failing: true,
1352                ..Default::default()
1353            },
1354            ..Default::default()
1355        };
1356        let view = apply(&r, spec);
1357        assert_eq!(view.shown_summary.total_functions, view.shown.len());
1358        assert_eq!(
1359            view.shown_summary.exceeding_threshold,
1360            view.shown.len(),
1361            "every shown row exceeds, so shown_summary should report all"
1362        );
1363        // Manual check: avg
1364        let manual_avg: f64 =
1365            view.shown.iter().map(|v| v.scored.crap.value).sum::<f64>() / view.shown.len() as f64;
1366        assert!((view.shown_summary.average_crap - manual_avg).abs() < 1e-9);
1367    }
1368
1369    #[test]
1370    fn shown_summary_differs_from_full() {
1371        // view.feature ll. 188-193 — analysis with 6 functions, 3 exceeding.
1372        let verdicts = vec![
1373            mk_verdict("ok1", "src/a.rs", 1, 100.0, 1.0, 25.0),
1374            mk_verdict("ok2", "src/a.rs", 1, 100.0, 2.0, 25.0),
1375            mk_verdict("ok3", "src/a.rs", 1, 100.0, 3.0, 25.0),
1376            mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
1377            mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
1378            mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
1379        ];
1380        let summary = compute_summary(&verdicts);
1381        let r = AnalysisResult {
1382            functions: verdicts,
1383            summary,
1384            passed: false,
1385        };
1386        let spec = ViewSpec {
1387            filters: Filters {
1388                only_failing: true,
1389                ..Default::default()
1390            },
1391            ..Default::default()
1392        };
1393        let view = apply(&r, spec);
1394        assert_eq!(view.full.summary.total_functions, 6);
1395        assert_eq!(view.shown_summary.total_functions, 3);
1396    }
1397
1398    // ── Edge cases ─────────────────────────────────────────────────
1399
1400    #[test]
1401    fn all_filtered_out_produces_empty_shown() {
1402        // view.feature ll. 205-211.
1403        let v = mk_verdict("low_cov", "src/a.rs", 1, 50.0, 1.0, 25.0);
1404        let r = AnalysisResult {
1405            functions: vec![v],
1406            summary: empty_result().summary,
1407            passed: true,
1408        };
1409        let range = CoverageRange::new(95.0, 100.0).unwrap();
1410        let spec = ViewSpec {
1411            filters: Filters {
1412                coverage_range: Some(range),
1413                ..Default::default()
1414            },
1415            ..Default::default()
1416        };
1417        let view = apply(&r, spec);
1418        assert!(view.shown.is_empty());
1419        assert_eq!(view.eligible_count, 0);
1420        assert!(!view.truncated);
1421    }
1422
1423    // ── should_render_view_line — display predicate ────────────────
1424
1425    #[test]
1426    fn display_predicate_default_spec_is_false() {
1427        // Default invocation: no filtering, no truncation.
1428        let r = background_fixture();
1429        let view = apply(&r, ViewSpec::default());
1430        assert!(!should_render_view_line(&view));
1431    }
1432
1433    #[test]
1434    fn display_predicate_sort_only_is_false() {
1435        // Sort-only invocation: still false (no rows reduced).
1436        let r = background_fixture();
1437        let spec = ViewSpec {
1438            sort: SortKey::Coverage,
1439            ..Default::default()
1440        };
1441        let view = apply(&r, spec);
1442        assert!(!should_render_view_line(&view));
1443    }
1444
1445    #[test]
1446    fn display_predicate_top_truncating_is_true() {
1447        // --top truncating: true.
1448        let r = background_fixture();
1449        let spec = ViewSpec {
1450            limit: Some(2),
1451            ..Default::default()
1452        };
1453        let view = apply(&r, spec);
1454        assert!(should_render_view_line(&view));
1455    }
1456
1457    #[test]
1458    fn display_predicate_coverage_filter_excluding_is_true() {
1459        // Coverage filter that excludes: true.
1460        let r = background_fixture();
1461        let range = CoverageRange::new(99.0, 100.0).unwrap();
1462        let spec = ViewSpec {
1463            filters: Filters {
1464                coverage_range: Some(range),
1465                ..Default::default()
1466            },
1467            ..Default::default()
1468        };
1469        let view = apply(&r, spec);
1470        assert!(should_render_view_line(&view));
1471    }
1472
1473    #[test]
1474    fn display_predicate_only_failing_reducing_is_true() {
1475        // --only-failing with some passing rows: true.
1476        let r = background_fixture();
1477        let spec = ViewSpec {
1478            filters: Filters {
1479                only_failing: true,
1480                ..Default::default()
1481            },
1482            ..Default::default()
1483        };
1484        let view = apply(&r, spec);
1485        assert!(should_render_view_line(&view));
1486    }
1487
1488    // ── Grouping (`--group-by file`) ────────────────────────────────
1489
1490    #[test]
1491    fn no_group_by_means_no_grouped_block() {
1492        // Biconditional half: spec.group_by.is_none() ⇒ view.grouped.is_none()
1493        let r = background_fixture();
1494        let view = apply(&r, ViewSpec::default());
1495        assert!(view.grouped.is_none());
1496    }
1497
1498    #[test]
1499    fn group_by_file_populates_grouped_block() {
1500        // Biconditional half: spec.group_by.is_some() ⇒ view.grouped.is_some()
1501        let r = background_fixture();
1502        let spec = ViewSpec {
1503            group_by: Some(GroupKey::File),
1504            ..Default::default()
1505        };
1506        let view = apply(&r, spec);
1507        let grouped = view.grouped.as_ref().expect("grouped block expected");
1508        assert_eq!(grouped.key, GroupKey::File);
1509        // Background fixture: 5 distinct files (parse_args is in cli/mod.rs;
1510        // table.rs has two functions; lcov, syn, threshold one each).
1511        assert_eq!(grouped.files.len(), 5);
1512        assert_eq!(grouped.eligible_count, 5);
1513        assert!(!grouped.truncated);
1514    }
1515
1516    #[test]
1517    fn group_by_file_does_not_truncate_function_shown() {
1518        // Function-level shown is the un-truncated eligible set under grouping.
1519        let r = background_fixture();
1520        let spec = ViewSpec {
1521            group_by: Some(GroupKey::File),
1522            limit: Some(2),
1523            ..Default::default()
1524        };
1525        let view = apply(&r, spec);
1526        // limit=2 truncates files, not functions.
1527        assert_eq!(view.shown.len(), r.functions.len());
1528        assert!(!view.truncated);
1529        let grouped = view.grouped.as_ref().unwrap();
1530        assert_eq!(grouped.files.len(), 2);
1531        assert!(grouped.truncated);
1532        assert_eq!(grouped.eligible_count, 5);
1533    }
1534
1535    #[test]
1536    fn group_by_file_keeps_gate_unchanged() {
1537        // P6 (gate-vs-display): grouping does not change view.full.passed
1538        // or view.full.summary.
1539        let r = background_fixture();
1540        let baseline_passed = r.passed;
1541        let baseline_total = r.summary.total_functions;
1542        let baseline_exceeding = r.summary.exceeding_threshold;
1543        let spec = ViewSpec {
1544            group_by: Some(GroupKey::File),
1545            limit: Some(1),
1546            ..Default::default()
1547        };
1548        let view = apply(&r, spec);
1549        assert_eq!(view.full.passed, baseline_passed);
1550        assert_eq!(view.full.summary.total_functions, baseline_total);
1551        assert_eq!(view.full.summary.exceeding_threshold, baseline_exceeding);
1552    }
1553
1554    #[test]
1555    fn group_by_file_default_sort_is_avg_crap_desc() {
1556        let r = background_fixture();
1557        let spec = ViewSpec {
1558            group_by: Some(GroupKey::File),
1559            ..Default::default()
1560        };
1561        let view = apply(&r, spec);
1562        let files = &view.grouped.as_ref().unwrap().files;
1563        for w in files.windows(2) {
1564            assert!(
1565                w[0].average_crap >= w[1].average_crap,
1566                "files not in average_crap descending order"
1567            );
1568        }
1569    }
1570
1571    #[test]
1572    fn group_by_file_sort_by_coverage_ascending() {
1573        let r = background_fixture();
1574        let spec = ViewSpec {
1575            group_by: Some(GroupKey::File),
1576            sort: SortKey::Coverage,
1577            ..Default::default()
1578        };
1579        let view = apply(&r, spec);
1580        let files = &view.grouped.as_ref().unwrap().files;
1581        for w in files.windows(2) {
1582            assert!(w[0].average_coverage <= w[1].average_coverage);
1583        }
1584    }
1585
1586    #[test]
1587    fn group_by_file_sort_by_complexity_descending() {
1588        let r = background_fixture();
1589        let spec = ViewSpec {
1590            group_by: Some(GroupKey::File),
1591            sort: SortKey::Complexity,
1592            ..Default::default()
1593        };
1594        let view = apply(&r, spec);
1595        let files = &view.grouped.as_ref().unwrap().files;
1596        for w in files.windows(2) {
1597            assert!(w[0].max_complexity >= w[1].max_complexity);
1598        }
1599    }
1600
1601    #[test]
1602    fn group_by_file_sort_by_path_alphabetical() {
1603        let r = background_fixture();
1604        let spec = ViewSpec {
1605            group_by: Some(GroupKey::File),
1606            sort: SortKey::Path,
1607            ..Default::default()
1608        };
1609        let view = apply(&r, spec);
1610        let files = &view.grouped.as_ref().unwrap().files;
1611        for w in files.windows(2) {
1612            assert!(w[0].file_path <= w[1].file_path);
1613        }
1614    }
1615
1616    #[test]
1617    fn group_by_file_truncate_files() {
1618        let r = background_fixture();
1619        let spec = ViewSpec {
1620            group_by: Some(GroupKey::File),
1621            limit: Some(3),
1622            ..Default::default()
1623        };
1624        let view = apply(&r, spec);
1625        let grouped = view.grouped.as_ref().unwrap();
1626        assert_eq!(grouped.files.len(), 3);
1627        assert!(grouped.truncated);
1628        assert_eq!(grouped.eligible_count, 5);
1629    }
1630
1631    #[test]
1632    fn group_by_file_filters_compose_before_grouping() {
1633        // only_failing + group_by file: grouped.files reflect only files
1634        // that have a failing function.
1635        let r = background_fixture();
1636        let spec = ViewSpec {
1637            filters: Filters {
1638                only_failing: true,
1639                ..Default::default()
1640            },
1641            group_by: Some(GroupKey::File),
1642            ..Default::default()
1643        };
1644        let view = apply(&r, spec);
1645        let grouped = view.grouped.as_ref().unwrap();
1646        // Background fixture: failing functions are sort_verdicts (table.rs)
1647        // CRAP=42 and parse_args (cli/mod.rs) CRAP=63.5 → 2 distinct files.
1648        assert_eq!(grouped.files.len(), 2);
1649        // Every file has at least one exceeding function.
1650        for f in &grouped.files {
1651            assert!(f.exceeding_count >= 1);
1652        }
1653    }
1654
1655    #[test]
1656    fn group_by_file_empty_input_produces_empty_files() {
1657        let r = empty_result();
1658        let spec = ViewSpec {
1659            group_by: Some(GroupKey::File),
1660            ..Default::default()
1661        };
1662        let view = apply(&r, spec);
1663        let grouped = view.grouped.as_ref().unwrap();
1664        assert!(grouped.files.is_empty());
1665        assert_eq!(grouped.eligible_count, 0);
1666        assert!(!grouped.truncated);
1667    }
1668
1669    #[test]
1670    fn display_predicate_group_by_only_default_input_is_false() {
1671        // Grouping without filtering or truncating: all distinct files
1672        // appear in the grouped block, so the predicate returns false
1673        // (no rows reduced, no files reduced).
1674        let r = background_fixture();
1675        let spec = ViewSpec {
1676            group_by: Some(GroupKey::File),
1677            ..Default::default()
1678        };
1679        let view = apply(&r, spec);
1680        assert!(!should_render_view_line(&view));
1681    }
1682
1683    #[test]
1684    fn display_predicate_group_by_truncating_files_is_true() {
1685        let r = background_fixture();
1686        let spec = ViewSpec {
1687            group_by: Some(GroupKey::File),
1688            limit: Some(2),
1689            ..Default::default()
1690        };
1691        let view = apply(&r, spec);
1692        assert!(should_render_view_line(&view));
1693    }
1694
1695    // ── Mutation killers for truncate_files_to ─────────────────────
1696    //
1697    // truncate_files_to has a tight guard: `Some(n) if n > 0 && files.len() > n`.
1698    // The three tests below pin each clause:
1699    //  - L265:22 `n > 0`        — proven by `--top 0` non-empty case
1700    //  - L265:41 `files.len() > n` — proven by `files.len() == n` case
1701    //  - L265:26 `&&` operator   — proven by `--top 0` non-empty case
1702    //  - L265:20 whole guard    — proven by both cases above
1703
1704    #[test]
1705    fn group_by_file_top_zero_is_no_limit() {
1706        // limit=Some(0) with non-empty files MUST NOT truncate; truncated=false.
1707        // Mirrors the `--top 0` ergonomic where 0 means "no limit".
1708        let r = background_fixture();
1709        let spec = ViewSpec {
1710            group_by: Some(GroupKey::File),
1711            limit: Some(0),
1712            ..Default::default()
1713        };
1714        let view = apply(&r, spec);
1715        let grouped = view.grouped.as_ref().expect("grouping active");
1716        assert!(!grouped.truncated);
1717        // All 5 distinct files must be present.
1718        assert_eq!(grouped.files.len(), 5);
1719    }
1720
1721    #[test]
1722    fn group_by_file_limit_equal_to_file_count_is_not_truncated() {
1723        // When limit exactly matches file count, truncated MUST be false.
1724        // Distinguishes `files.len() > n` (correct) from `files.len() >= n`
1725        // (would set truncated=true for an effectively no-op truncate).
1726        let r = background_fixture();
1727        let spec = ViewSpec {
1728            group_by: Some(GroupKey::File),
1729            limit: Some(5),
1730            ..Default::default()
1731        };
1732        let view = apply(&r, spec);
1733        let grouped = view.grouped.as_ref().expect("grouping active");
1734        assert!(!grouped.truncated);
1735        assert_eq!(grouped.files.len(), 5);
1736    }
1737
1738    // ── Mutation killers for distinct_files ────────────────────────
1739    //
1740    // `should_render_view_line` calls `distinct_files(view.full)` to decide
1741    // whether grouping reduced the file count. Mutants replacing the body
1742    // with `0` or `1` constants are killed by these tests:
1743    //  - replace -> 0: filtering excludes some files; eligible_count < distinct
1744    //                  must NOT trigger when all files survive (background = 5)
1745    //  - replace -> 1: with 5 distinct files, predicate must reflect that
1746
1747    #[test]
1748    fn display_predicate_full_grouping_no_reduction_is_false() {
1749        // With grouping active but no filter/truncate, view line MUST NOT
1750        // render. This requires distinct_files == eligible_count == 5
1751        // (replace-with-0 would make distinct=0, predicate fires; killed.)
1752        let r = background_fixture();
1753        let spec = ViewSpec {
1754            group_by: Some(GroupKey::File),
1755            ..Default::default()
1756        };
1757        let view = apply(&r, spec);
1758        assert!(!should_render_view_line(&view));
1759    }
1760
1761    #[test]
1762    fn display_predicate_grouping_reduces_files_is_true() {
1763        // Filter excludes 4 of 5 files; eligible_count=1 < distinct=5.
1764        // Predicate must fire. Replace-with-1 would yield distinct=1=eligible
1765        // and predicate would NOT fire — killed.
1766        let r = background_fixture();
1767        let spec = ViewSpec {
1768            group_by: Some(GroupKey::File),
1769            filters: Filters {
1770                only_failing: true,
1771                ..Default::default()
1772            },
1773            ..Default::default()
1774        };
1775        let view = apply(&r, spec);
1776        assert!(should_render_view_line(&view));
1777    }
1778}
1779
1780#[cfg(test)]
1781mod proptests {
1782    use super::*;
1783    use crate::test_strategies::{arb_analysis_result, arb_verdict_with_nan_coverage};
1784    use proptest::prelude::*;
1785
1786    /// Mirror the legacy `format_table` sort: CRAP descending with stable
1787    /// fallback to input order on ties.
1788    fn legacy_sort_order(result: &AnalysisResult) -> Vec<&FunctionVerdict> {
1789        let mut sorted: Vec<&FunctionVerdict> = result.functions.iter().collect();
1790        sorted.sort_by(|a, b| {
1791            b.scored
1792                .crap
1793                .value
1794                .partial_cmp(&a.scored.crap.value)
1795                .unwrap_or(std::cmp::Ordering::Equal)
1796        });
1797        sorted
1798    }
1799
1800    proptest! {
1801        #![proptest_config(ProptestConfig::with_cases(256))]
1802
1803        /// Invariant 1 (Order): `apply(r, ViewSpec::default()).shown` matches
1804        /// the legacy sort: CRAP descending, stable on ties.
1805        ///
1806        /// Both sides borrow from `result.functions`, so pointer equality is
1807        /// the strictest possible witness of stable-sort agreement and is
1808        /// immune to any duplicate `qualified_name` the strategy might
1809        /// produce (CodeRabbit CR-N7).
1810        #[test]
1811        fn prop_default_spec_order_matches_legacy_sort(result in arb_analysis_result()) {
1812            let view = apply(&result, ViewSpec::default());
1813            let legacy = legacy_sort_order(&result);
1814            prop_assert_eq!(view.shown.len(), legacy.len());
1815            for (a, b) in view.shown.iter().zip(legacy.iter()) {
1816                prop_assert!(std::ptr::eq(*a, *b));
1817            }
1818        }
1819
1820        /// Invariant 2 (Identity): the set of identities is preserved.
1821        #[test]
1822        fn prop_default_spec_preserves_identity(result in arb_analysis_result()) {
1823            let view = apply(&result, ViewSpec::default());
1824            let shown_identities: std::collections::HashSet<&crate::domain::types::FunctionIdentity> =
1825                view.shown.iter().map(|v| &v.scored.identity).collect();
1826            let original_identities: std::collections::HashSet<&crate::domain::types::FunctionIdentity> =
1827                result.functions.iter().map(|v| &v.scored.identity).collect();
1828            prop_assert_eq!(shown_identities, original_identities);
1829        }
1830
1831        /// Invariant 3 (Summary): `view.full` borrows the original result.
1832        /// Stronger than equals — pointer equality.
1833        #[test]
1834        fn prop_default_spec_preserves_summary(result in arb_analysis_result()) {
1835            let view = apply(&result, ViewSpec::default());
1836            prop_assert!(std::ptr::eq(view.full, &result));
1837            // and shape: total_functions agrees
1838            prop_assert_eq!(view.full.summary.total_functions, result.summary.total_functions);
1839        }
1840
1841        /// Invariant 4 (Display): biconditional of the predicate.
1842        #[test]
1843        fn prop_display_predicate_biconditional(result in arb_analysis_result()) {
1844            // Default spec: predicate must be false (no rows reduced).
1845            let view = apply(&result, ViewSpec::default());
1846            let computed = should_render_view_line(&view);
1847            let expected = view.eligible_count < view.full.functions.len() || view.truncated;
1848            prop_assert_eq!(computed, expected);
1849            // Default invocation reduces nothing → both sides false.
1850            prop_assert!(!computed);
1851        }
1852
1853        /// `apply` never panics on NaN-coverage inputs.
1854        #[test]
1855        fn prop_apply_never_panics_with_nan_coverage(
1856            verdicts in prop::collection::vec(arb_verdict_with_nan_coverage(), 0..50)
1857        ) {
1858            let result = AnalysisResult {
1859                functions: verdicts.clone(),
1860                summary: crate::domain::types::AnalysisSummary {
1861                    total_functions: verdicts.len(),
1862                    total_files: verdicts.len(),
1863                    exceeding_threshold: 0,
1864                    average_crap: 0.0,
1865                    median_crap: 0.0,
1866                    max_crap: None,
1867                    worst_function: None,
1868                    distribution: crate::domain::types::RiskDistribution {
1869                        low: 0, acceptable: 0, moderate: 0, high: 0,
1870                    },
1871                    ..Default::default()
1872                },
1873                passed: true,
1874            };
1875            // Try every SortKey + a coverage-range filter.
1876            for sort in [SortKey::Crap, SortKey::Coverage, SortKey::Complexity, SortKey::Path] {
1877                let spec = ViewSpec {
1878                    filters: Filters {
1879                        coverage_range: Some(CoverageRange::new(0.0, 100.0).unwrap()),
1880                        ..Default::default()
1881                    },
1882                    sort,
1883                    limit: Some(10),
1884                    ..Default::default()
1885                };
1886                let _ = apply(&result, spec);
1887            }
1888        }
1889    }
1890}