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`). Future `crap-core` extraction
69//! (ops#231) takes this module whole; LSP, web, and agent consumers all
70//! flow through `view::apply` as the canonical CRAP 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]` paused for v0.5 (see types::SourceSpan). Restored at v1.0.
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 (issue #64). `#[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]` paused for v0.5 (see types::SourceSpan). Restored at v1.0.
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                crap: CrapScore {
613                    value: crap_value,
614                    risk_level,
615                },
616                contributors: vec![],
617            },
618            threshold,
619            exceeds: crap_value > threshold,
620            diagnostic: None,
621        }
622    }
623
624    /// Background fixture from view.feature ll. 9-17. Threshold 25.0.
625    fn background_fixture() -> AnalysisResult {
626        let verdicts = vec![
627            mk_verdict("parse_lcov", "src/adapters/lcov.rs", 12, 100.0, 12.00, 25.0),
628            mk_verdict("walk_ast", "src/adapters/syn.rs", 18, 75.0, 23.06, 25.0),
629            mk_verdict(
630                "render_table",
631                "src/adapters/table.rs",
632                9,
633                60.0,
634                14.18,
635                25.0,
636            ),
637            mk_verdict(
638                "apply_threshold",
639                "src/domain/threshold.rs",
640                4,
641                100.0,
642                4.00,
643                25.0,
644            ),
645            mk_verdict(
646                "sort_verdicts",
647                "src/adapters/table.rs",
648                6,
649                0.0,
650                42.00,
651                25.0,
652            ),
653            mk_verdict("parse_args", "src/cli/mod.rs", 22, 50.0, 63.50, 25.0),
654        ];
655        let summary = compute_summary(&verdicts);
656        let passed = verdicts.iter().all(|v| !v.exceeds);
657        AnalysisResult {
658            functions: verdicts,
659            summary,
660            passed,
661        }
662    }
663
664    fn empty_result() -> AnalysisResult {
665        AnalysisResult {
666            functions: vec![],
667            summary: AnalysisSummary {
668                total_functions: 0,
669                total_files: 0,
670                exceeding_threshold: 0,
671                average_crap: 0.0,
672                median_crap: 0.0,
673                max_crap: None,
674                worst_function: None,
675                distribution: RiskDistribution {
676                    low: 0,
677                    acceptable: 0,
678                    moderate: 0,
679                    high: 0,
680                },
681                ..Default::default()
682            },
683            passed: true,
684        }
685    }
686
687    // ── Default-spec invariants (Order, Identity, Summary, immutability) ───
688
689    #[test]
690    fn default_spec_is_noop_on_fixture() {
691        // view.feature ll. 25-31: default spec produces a no-op view in
692        // CRAP-descending order. Equivalent: shown contains every function;
693        // eligible_count equals total; truncated false; CRAP desc.
694        let r = background_fixture();
695        let view = apply(&r, ViewSpec::default());
696        assert_eq!(view.shown.len(), r.functions.len());
697        assert_eq!(view.eligible_count, r.functions.len());
698        assert!(!view.truncated);
699        // Pointer equality on `view.full` (no PartialEq derive needed)
700        assert!(std::ptr::eq(view.full, &r));
701        // Order: CRAP descending
702        for w in view.shown.windows(2) {
703            assert!(
704                w[0].scored.crap.value >= w[1].scored.crap.value,
705                "expected CRAP descending; got {} then {}",
706                w[0].scored.crap.value,
707                w[1].scored.crap.value
708            );
709        }
710    }
711
712    #[test]
713    fn default_spec_empty_input_is_empty_view() {
714        // view.feature l. 197.
715        let r = empty_result();
716        let view = apply(&r, ViewSpec::default());
717        assert!(view.shown.is_empty());
718        assert_eq!(view.eligible_count, 0);
719        assert!(!view.truncated);
720        assert!(view.full.passed);
721    }
722
723    #[test]
724    fn view_full_immutability_after_apply() {
725        // view.feature l. 221.
726        let r = background_fixture();
727        let crap_before: Vec<f64> = r.functions.iter().map(|v| v.scored.crap.value).collect();
728        let view = apply(&r, ViewSpec::default());
729        // view.full points at r
730        assert!(std::ptr::eq(view.full, &r));
731        // r itself is unchanged
732        let crap_after: Vec<f64> = r.functions.iter().map(|v| v.scored.crap.value).collect();
733        assert_eq!(crap_before, crap_after);
734    }
735
736    #[test]
737    fn default_spec_preserves_identity_set() {
738        // view.feature ll. 33-35.
739        let r = background_fixture();
740        let view = apply(&r, ViewSpec::default());
741        let shown_names: std::collections::HashSet<&String> = view
742            .shown
743            .iter()
744            .map(|v| &v.scored.identity.qualified_name)
745            .collect();
746        let original_names: std::collections::HashSet<&String> = r
747            .functions
748            .iter()
749            .map(|v| &v.scored.identity.qualified_name)
750            .collect();
751        assert_eq!(shown_names, original_names);
752    }
753
754    // ── CoverageRange constructor: 7-row table ─────────────────────
755
756    #[test]
757    fn coverage_range_new_validation_table() {
758        // view.feature ll. 79-91.
759        type Case = (f64, f64, Result<(f64, f64), ()>);
760        let cases: &[Case] = &[
761            (0.0, 100.0, Ok((0.0, 100.0))),
762            (50.0, 50.0, Ok((50.0, 50.0))),
763            (1.0, 90.0, Ok((1.0, 90.0))),
764            (-0.1, 50.0, Err(())),
765            (50.0, 100.1, Err(())),
766            (90.0, 50.0, Err(())),
767            (100.0, 0.0, Err(())),
768        ];
769        for (min, max, expect) in cases {
770            let got = CoverageRange::new(*min, *max);
771            match (got, expect) {
772                (Ok(r), Ok((emin, emax))) => {
773                    assert!(
774                        (r.min - emin).abs() < 1e-9 && (r.max - emax).abs() < 1e-9,
775                        "min={min}, max={max}: got {r:?}, expected ({emin}, {emax})"
776                    );
777                }
778                (Err(_), Err(())) => {} // good
779                (got, expect) => panic!("min={min}, max={max}: got {got:?}, expected {expect:?}"),
780            }
781        }
782    }
783
784    #[test]
785    fn coverage_range_error_variants() {
786        // out-of-range vs min-exceeds-max are distinct, tag-only variants.
787        let oor = CoverageRange::new(-1.0, 50.0).unwrap_err();
788        assert!(matches!(oor, CoverageRangeError::OutOfRange { .. }));
789        let mxm = CoverageRange::new(80.0, 20.0).unwrap_err();
790        assert!(matches!(mxm, CoverageRangeError::MinExceedsMax { .. }));
791    }
792
793    // ── Sort stability — the surgical mutation killer ───────────
794
795    #[test]
796    fn sort_stability_on_tied_crap() {
797        // view.feature ll. 122-128. Catches `sort_by → sort_unstable_by`.
798        // Hand-built deterministic [foo, bar] both at CRAP=12.0.
799        let foo = mk_verdict("foo", "src/a.rs", 5, 80.0, 12.0, 25.0);
800        let bar = mk_verdict("bar", "src/a.rs", 5, 80.0, 12.0, 25.0);
801        let r = AnalysisResult {
802            functions: vec![foo, bar],
803            summary: empty_result().summary, // unused
804            passed: true,
805        };
806        let view = apply(&r, ViewSpec::default());
807        // Input order [foo, bar] preserved on tied keys.
808        assert_eq!(
809            view.shown[0].scored.identity.qualified_name,
810            "foo",
811            "stable sort must preserve input order on ties; got {:?}",
812            view.shown
813                .iter()
814                .map(|v| &v.scored.identity.qualified_name)
815                .collect::<Vec<_>>()
816        );
817        assert_eq!(view.shown[1].scored.identity.qualified_name, "bar");
818    }
819
820    // ── Filters ────────────────────────────────────────────────────
821
822    #[test]
823    fn only_failing_filter_retains_only_exceeds_true() {
824        // view.feature l. 44.
825        let r = background_fixture();
826        let spec = ViewSpec {
827            filters: Filters {
828                only_failing: true,
829                ..Default::default()
830            },
831            ..Default::default()
832        };
833        let view = apply(&r, spec);
834        assert!(view.shown.iter().all(|v| v.exceeds));
835        // And every shown CRAP exceeds threshold
836        for v in &view.shown {
837            assert!(v.scored.crap.value > v.threshold);
838        }
839    }
840
841    #[test]
842    fn coverage_range_filter_inclusive() {
843        // view.feature l. 50.
844        let r = background_fixture();
845        let range = CoverageRange::new(50.0, 90.0).unwrap();
846        let spec = ViewSpec {
847            filters: Filters {
848                coverage_range: Some(range),
849                ..Default::default()
850            },
851            ..Default::default()
852        };
853        let view = apply(&r, spec);
854        assert!(view.shown.iter().all(|v| {
855            let cov = v.scored.coverage_percent;
856            cov.is_finite() && (50.0..=90.0).contains(&cov)
857        }));
858        let manual_count = r
859            .functions
860            .iter()
861            .filter(|v| v.scored.coverage_percent.is_finite())
862            .filter(|v| (50.0..=90.0).contains(&v.scored.coverage_percent))
863            .count();
864        assert_eq!(view.eligible_count, manual_count);
865    }
866
867    #[test]
868    fn coverage_range_boundary_inclusive_50_low() {
869        // view.feature ll. 56-68 row 1: cov=50.0 in 50..=90 → appears.
870        let v = mk_verdict("at50", "src/a.rs", 1, 50.0, 1.0, 25.0);
871        let r = AnalysisResult {
872            functions: vec![v],
873            summary: empty_result().summary,
874            passed: true,
875        };
876        let range = CoverageRange::new(50.0, 90.0).unwrap();
877        let spec = ViewSpec {
878            filters: Filters {
879                coverage_range: Some(range),
880                ..Default::default()
881            },
882            ..Default::default()
883        };
884        let view = apply(&r, spec);
885        assert_eq!(view.shown.len(), 1);
886    }
887
888    #[test]
889    fn coverage_range_boundary_inclusive_90_high() {
890        // row 2: cov=90.0 in 50..=90 → appears.
891        let v = mk_verdict("at90", "src/a.rs", 1, 90.0, 1.0, 25.0);
892        let r = AnalysisResult {
893            functions: vec![v],
894            summary: empty_result().summary,
895            passed: true,
896        };
897        let range = CoverageRange::new(50.0, 90.0).unwrap();
898        let spec = ViewSpec {
899            filters: Filters {
900                coverage_range: Some(range),
901                ..Default::default()
902            },
903            ..Default::default()
904        };
905        let view = apply(&r, spec);
906        assert_eq!(view.shown.len(), 1);
907    }
908
909    #[test]
910    fn coverage_range_boundary_inclusive_below_low() {
911        // row 3: cov=49.9 in 50..=90 → absent.
912        let v = mk_verdict("just_under", "src/a.rs", 1, 49.9, 1.0, 25.0);
913        let r = AnalysisResult {
914            functions: vec![v],
915            summary: empty_result().summary,
916            passed: true,
917        };
918        let range = CoverageRange::new(50.0, 90.0).unwrap();
919        let spec = ViewSpec {
920            filters: Filters {
921                coverage_range: Some(range),
922                ..Default::default()
923            },
924            ..Default::default()
925        };
926        let view = apply(&r, spec);
927        assert!(view.shown.is_empty());
928    }
929
930    #[test]
931    fn coverage_range_boundary_inclusive_above_high() {
932        // row 4: cov=90.1 in 50..=90 → absent.
933        let v = mk_verdict("just_over", "src/a.rs", 1, 90.1, 1.0, 25.0);
934        let r = AnalysisResult {
935            functions: vec![v],
936            summary: empty_result().summary,
937            passed: true,
938        };
939        let range = CoverageRange::new(50.0, 90.0).unwrap();
940        let spec = ViewSpec {
941            filters: Filters {
942                coverage_range: Some(range),
943                ..Default::default()
944            },
945            ..Default::default()
946        };
947        let view = apply(&r, spec);
948        assert!(view.shown.is_empty());
949    }
950
951    #[test]
952    fn coverage_range_boundary_inclusive_zero_singleton() {
953        // row 5: cov=0.0 in 0..=0 → appears.
954        let v = mk_verdict("zero", "src/a.rs", 1, 0.0, 1.0, 25.0);
955        let r = AnalysisResult {
956            functions: vec![v],
957            summary: empty_result().summary,
958            passed: true,
959        };
960        let range = CoverageRange::new(0.0, 0.0).unwrap();
961        let spec = ViewSpec {
962            filters: Filters {
963                coverage_range: Some(range),
964                ..Default::default()
965            },
966            ..Default::default()
967        };
968        let view = apply(&r, spec);
969        assert_eq!(view.shown.len(), 1);
970    }
971
972    #[test]
973    fn coverage_range_boundary_inclusive_hundred_singleton() {
974        // row 6: cov=100.0 in 100..=100 → appears.
975        let v = mk_verdict("full", "src/a.rs", 1, 100.0, 1.0, 25.0);
976        let r = AnalysisResult {
977            functions: vec![v],
978            summary: empty_result().summary,
979            passed: true,
980        };
981        let range = CoverageRange::new(100.0, 100.0).unwrap();
982        let spec = ViewSpec {
983            filters: Filters {
984                coverage_range: Some(range),
985                ..Default::default()
986            },
987            ..Default::default()
988        };
989        let view = apply(&r, spec);
990        assert_eq!(view.shown.len(), 1);
991    }
992
993    #[test]
994    fn filters_and_compose() {
995        // view.feature l. 70: filters AND-compose.
996        // only_failing AND coverage_range [50, 100] → both must hold.
997        let r = background_fixture();
998        let range = CoverageRange::new(50.0, 100.0).unwrap();
999        let spec = ViewSpec {
1000            filters: Filters {
1001                only_failing: true,
1002                coverage_range: Some(range),
1003            },
1004            ..Default::default()
1005        };
1006        let view = apply(&r, spec);
1007        for v in &view.shown {
1008            assert!(v.exceeds);
1009            let cov = v.scored.coverage_percent;
1010            assert!((50.0..=100.0).contains(&cov));
1011        }
1012    }
1013
1014    #[test]
1015    fn nan_coverage_excluded_from_range_filter() {
1016        // view.feature l. 231: NaN coverage excluded from range filter.
1017        let v = mk_verdict("zero_lines", "src/a.rs", 1, f64::NAN, 1.0, 25.0);
1018        let r = AnalysisResult {
1019            functions: vec![v],
1020            summary: empty_result().summary,
1021            passed: true,
1022        };
1023        let range = CoverageRange::new(0.0, 100.0).unwrap();
1024        let spec = ViewSpec {
1025            filters: Filters {
1026                coverage_range: Some(range),
1027                ..Default::default()
1028            },
1029            ..Default::default()
1030        };
1031        let view = apply(&r, spec);
1032        assert!(view.shown.is_empty());
1033    }
1034
1035    // ── Sort ───────────────────────────────────────────────────────
1036
1037    #[test]
1038    fn sort_by_crap_descending() {
1039        // view.feature ll. 95-98.
1040        let r = background_fixture();
1041        let spec = ViewSpec {
1042            sort: SortKey::Crap,
1043            ..Default::default()
1044        };
1045        let view = apply(&r, spec);
1046        for w in view.shown.windows(2) {
1047            assert!(w[0].scored.crap.value >= w[1].scored.crap.value);
1048        }
1049    }
1050
1051    #[test]
1052    fn sort_by_coverage_ascending() {
1053        // view.feature ll. 100-103.
1054        let r = background_fixture();
1055        let spec = ViewSpec {
1056            sort: SortKey::Coverage,
1057            ..Default::default()
1058        };
1059        let view = apply(&r, spec);
1060        for w in view.shown.windows(2) {
1061            assert!(w[0].scored.coverage_percent <= w[1].scored.coverage_percent);
1062        }
1063    }
1064
1065    #[test]
1066    fn sort_by_complexity_descending() {
1067        // view.feature ll. 105-108.
1068        let r = background_fixture();
1069        let spec = ViewSpec {
1070            sort: SortKey::Complexity,
1071            ..Default::default()
1072        };
1073        let view = apply(&r, spec);
1074        for w in view.shown.windows(2) {
1075            assert!(w[0].scored.complexity >= w[1].scored.complexity);
1076        }
1077    }
1078
1079    #[test]
1080    fn sort_by_path_alphabetical_then_crap() {
1081        // view.feature ll. 110-114.
1082        let r = background_fixture();
1083        let spec = ViewSpec {
1084            sort: SortKey::Path,
1085            ..Default::default()
1086        };
1087        let view = apply(&r, spec);
1088        // Primary: file_path ascending.
1089        for w in view.shown.windows(2) {
1090            let (a, b) = (
1091                &w[0].scored.identity.file_path,
1092                &w[1].scored.identity.file_path,
1093            );
1094            assert!(a <= b, "files not in ascending order: {a} then {b}");
1095        }
1096        // Within each file: CRAP descending.
1097        for w in view.shown.windows(2) {
1098            if w[0].scored.identity.file_path == w[1].scored.identity.file_path {
1099                assert!(
1100                    w[0].scored.crap.value >= w[1].scored.crap.value,
1101                    "within file {}: CRAP not descending: {} then {}",
1102                    w[0].scored.identity.file_path,
1103                    w[0].scored.crap.value,
1104                    w[1].scored.crap.value
1105                );
1106            }
1107        }
1108    }
1109
1110    #[test]
1111    fn sort_by_path_secondary_multi_file() {
1112        // view.feature ll. 116-120: 3 files, 5 verdicts.
1113        // src/a.rs (5, 30), src/b.rs (10), src/c.rs (1, 50)
1114        // Expected: a.rs::30, a.rs::5, b.rs::10, c.rs::50, c.rs::1
1115        let verdicts = vec![
1116            mk_verdict("a_low", "src/a.rs", 1, 50.0, 5.0, 25.0),
1117            mk_verdict("a_high", "src/a.rs", 1, 50.0, 30.0, 25.0),
1118            mk_verdict("b_only", "src/b.rs", 1, 50.0, 10.0, 25.0),
1119            mk_verdict("c_low", "src/c.rs", 1, 50.0, 1.0, 25.0),
1120            mk_verdict("c_high", "src/c.rs", 1, 50.0, 50.0, 25.0),
1121        ];
1122        let r = AnalysisResult {
1123            functions: verdicts,
1124            summary: empty_result().summary,
1125            passed: true,
1126        };
1127        let spec = ViewSpec {
1128            sort: SortKey::Path,
1129            ..Default::default()
1130        };
1131        let view = apply(&r, spec);
1132        let names: Vec<&str> = view
1133            .shown
1134            .iter()
1135            .map(|v| v.scored.identity.qualified_name.as_str())
1136            .collect();
1137        assert_eq!(
1138            names,
1139            vec!["a_high", "a_low", "b_only", "c_high", "c_low"],
1140            "path sort with secondary CRAP-desc order wrong"
1141        );
1142    }
1143
1144    #[test]
1145    fn nan_coverage_sorts_last_under_coverage_ascending() {
1146        // view.feature ll. 237-242. Coverages: [10.0, NaN, 50.0, NaN, 90.0].
1147        let verdicts = vec![
1148            mk_verdict("c10", "src/a.rs", 1, 10.0, 1.0, 25.0),
1149            mk_verdict("nan1", "src/a.rs", 1, f64::NAN, 1.0, 25.0),
1150            mk_verdict("c50", "src/a.rs", 1, 50.0, 1.0, 25.0),
1151            mk_verdict("nan2", "src/a.rs", 1, f64::NAN, 1.0, 25.0),
1152            mk_verdict("c90", "src/a.rs", 1, 90.0, 1.0, 25.0),
1153        ];
1154        let r = AnalysisResult {
1155            functions: verdicts,
1156            summary: empty_result().summary,
1157            passed: true,
1158        };
1159        let spec = ViewSpec {
1160            sort: SortKey::Coverage,
1161            ..Default::default()
1162        };
1163        let view = apply(&r, spec);
1164        // First 3 are non-NaN ascending; last 2 are NaN.
1165        let coverages: Vec<f64> = view
1166            .shown
1167            .iter()
1168            .map(|v| v.scored.coverage_percent)
1169            .collect();
1170        assert_eq!(coverages[0], 10.0);
1171        assert_eq!(coverages[1], 50.0);
1172        assert_eq!(coverages[2], 90.0);
1173        assert!(coverages[3].is_nan());
1174        assert!(coverages[4].is_nan());
1175    }
1176
1177    // ── Truncate ───────────────────────────────────────────────────
1178
1179    #[test]
1180    fn limit_truncates() {
1181        // view.feature ll. 132-137. Background has 6 functions; limit=3.
1182        let r = background_fixture();
1183        let spec = ViewSpec {
1184            limit: Some(3),
1185            ..Default::default()
1186        };
1187        let view = apply(&r, spec);
1188        assert_eq!(view.shown.len(), 3);
1189        assert_eq!(view.eligible_count, 6);
1190        assert!(view.truncated);
1191    }
1192
1193    #[test]
1194    fn limit_greater_than_eligible() {
1195        // view.feature ll. 139-144.
1196        let r = background_fixture();
1197        let spec = ViewSpec {
1198            limit: Some(100),
1199            ..Default::default()
1200        };
1201        let view = apply(&r, spec);
1202        assert_eq!(view.shown.len(), 6);
1203        assert_eq!(view.eligible_count, 6);
1204        assert!(!view.truncated);
1205    }
1206
1207    #[test]
1208    fn limit_none() {
1209        // view.feature ll. 146-150.
1210        let r = background_fixture();
1211        let spec = ViewSpec {
1212            limit: None,
1213            ..Default::default()
1214        };
1215        let view = apply(&r, spec);
1216        assert_eq!(view.shown.len(), view.eligible_count);
1217        assert!(!view.truncated);
1218    }
1219
1220    #[test]
1221    fn limit_zero_treated_as_no_limit() {
1222        // view.feature l. 213. --top 0 ⇒ limit = None semantics.
1223        // Construct directly with Some(0); the code treats it as no-limit.
1224        let r = background_fixture();
1225        let spec = ViewSpec {
1226            limit: Some(0),
1227            ..Default::default()
1228        };
1229        let view = apply(&r, spec);
1230        assert_eq!(view.shown.len(), view.eligible_count);
1231        assert!(!view.truncated);
1232    }
1233
1234    #[test]
1235    fn limit_equal_to_eligible_does_not_mark_truncated() {
1236        // Boundary: shown.len() == limit. Mutation-killer for `>` vs `>=`
1237        // in `truncate_to`. The data is unchanged either way, but
1238        // `truncated` MUST stay false when nothing was actually dropped.
1239        let r = background_fixture();
1240        assert_eq!(r.functions.len(), 6, "background fixture sanity");
1241        let spec = ViewSpec {
1242            limit: Some(6),
1243            ..Default::default()
1244        };
1245        let view = apply(&r, spec);
1246        assert_eq!(view.shown.len(), 6);
1247        assert_eq!(view.eligible_count, 6);
1248        assert!(!view.truncated, "limit == eligible must NOT mark truncated");
1249    }
1250
1251    // ── Order of operations ────────────────────────────────────────
1252
1253    #[test]
1254    fn order_filter_then_sort_then_truncate() {
1255        // view.feature ll. 154-160.
1256        // only_failing AND sort=Coverage AND limit=2
1257        let r = background_fixture();
1258        let spec = ViewSpec {
1259            filters: Filters {
1260                only_failing: true,
1261                ..Default::default()
1262            },
1263            sort: SortKey::Coverage,
1264            limit: Some(2),
1265            ..Default::default()
1266        };
1267        let view = apply(&r, spec);
1268        assert_eq!(view.shown.len(), 2);
1269        for v in &view.shown {
1270            assert!(v.exceeds);
1271        }
1272        // Coverage ascending
1273        assert!(view.shown[0].scored.coverage_percent <= view.shown[1].scored.coverage_percent);
1274        // eligible_count = total failing functions before truncation
1275        let total_failing = r.functions.iter().filter(|v| v.exceeds).count();
1276        assert_eq!(view.eligible_count, total_failing);
1277    }
1278
1279    #[test]
1280    fn truncation_does_not_change_gate() {
1281        // view.feature ll. 162-168 — Given an analysis with 3 functions
1282        // exceeding threshold. Construct that fixture explicitly.
1283        let verdicts = vec![
1284            mk_verdict("ok", "src/a.rs", 1, 100.0, 1.0, 25.0),
1285            mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
1286            mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
1287            mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
1288        ];
1289        let summary = compute_summary(&verdicts);
1290        let passed = verdicts.iter().all(|v| !v.exceeds);
1291        let r = AnalysisResult {
1292            functions: verdicts,
1293            summary,
1294            passed,
1295        };
1296        let spec = ViewSpec {
1297            limit: Some(1),
1298            ..Default::default()
1299        };
1300        let view = apply(&r, spec);
1301        assert_eq!(view.shown.len(), 1);
1302        // gate = view.full.passed (false) and exceeding count unchanged.
1303        assert!(!view.full.passed);
1304        assert_eq!(view.full.summary.exceeding_threshold, 3);
1305    }
1306
1307    #[test]
1308    fn filtering_does_not_change_gate() {
1309        // view.feature ll. 170-175 — analysis with 3 exceeding, filter
1310        // excludes all of them.
1311        let verdicts = vec![
1312            mk_verdict("ok", "src/a.rs", 1, 100.0, 1.0, 25.0),
1313            mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
1314            mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
1315            mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
1316        ];
1317        let summary = compute_summary(&verdicts);
1318        let r = AnalysisResult {
1319            functions: verdicts,
1320            summary,
1321            passed: false,
1322        };
1323        // Range [99, 100] keeps "ok" only; excludes the 3 failing ones.
1324        let range = CoverageRange::new(99.0, 100.0).unwrap();
1325        let spec = ViewSpec {
1326            filters: Filters {
1327                coverage_range: Some(range),
1328                ..Default::default()
1329            },
1330            ..Default::default()
1331        };
1332        let view = apply(&r, spec);
1333        assert!(view.shown.iter().all(|v| !v.exceeds));
1334        assert!(!view.full.passed);
1335        assert_eq!(view.full.summary.exceeding_threshold, 3);
1336    }
1337
1338    // ── shown_summary ──────────────────────────────────────────────
1339
1340    #[test]
1341    fn shown_summary_over_shown_subset() {
1342        // view.feature ll. 179-186.
1343        let r = background_fixture();
1344        let spec = ViewSpec {
1345            filters: Filters {
1346                only_failing: true,
1347                ..Default::default()
1348            },
1349            ..Default::default()
1350        };
1351        let view = apply(&r, spec);
1352        assert_eq!(view.shown_summary.total_functions, view.shown.len());
1353        assert_eq!(
1354            view.shown_summary.exceeding_threshold,
1355            view.shown.len(),
1356            "every shown row exceeds, so shown_summary should report all"
1357        );
1358        // Manual check: avg
1359        let manual_avg: f64 =
1360            view.shown.iter().map(|v| v.scored.crap.value).sum::<f64>() / view.shown.len() as f64;
1361        assert!((view.shown_summary.average_crap - manual_avg).abs() < 1e-9);
1362    }
1363
1364    #[test]
1365    fn shown_summary_differs_from_full() {
1366        // view.feature ll. 188-193 — analysis with 6 functions, 3 exceeding.
1367        let verdicts = vec![
1368            mk_verdict("ok1", "src/a.rs", 1, 100.0, 1.0, 25.0),
1369            mk_verdict("ok2", "src/a.rs", 1, 100.0, 2.0, 25.0),
1370            mk_verdict("ok3", "src/a.rs", 1, 100.0, 3.0, 25.0),
1371            mk_verdict("fail1", "src/a.rs", 1, 0.0, 50.0, 25.0),
1372            mk_verdict("fail2", "src/a.rs", 1, 0.0, 60.0, 25.0),
1373            mk_verdict("fail3", "src/a.rs", 1, 0.0, 70.0, 25.0),
1374        ];
1375        let summary = compute_summary(&verdicts);
1376        let r = AnalysisResult {
1377            functions: verdicts,
1378            summary,
1379            passed: false,
1380        };
1381        let spec = ViewSpec {
1382            filters: Filters {
1383                only_failing: true,
1384                ..Default::default()
1385            },
1386            ..Default::default()
1387        };
1388        let view = apply(&r, spec);
1389        assert_eq!(view.full.summary.total_functions, 6);
1390        assert_eq!(view.shown_summary.total_functions, 3);
1391    }
1392
1393    // ── Edge cases ─────────────────────────────────────────────────
1394
1395    #[test]
1396    fn all_filtered_out_produces_empty_shown() {
1397        // view.feature ll. 205-211.
1398        let v = mk_verdict("low_cov", "src/a.rs", 1, 50.0, 1.0, 25.0);
1399        let r = AnalysisResult {
1400            functions: vec![v],
1401            summary: empty_result().summary,
1402            passed: true,
1403        };
1404        let range = CoverageRange::new(95.0, 100.0).unwrap();
1405        let spec = ViewSpec {
1406            filters: Filters {
1407                coverage_range: Some(range),
1408                ..Default::default()
1409            },
1410            ..Default::default()
1411        };
1412        let view = apply(&r, spec);
1413        assert!(view.shown.is_empty());
1414        assert_eq!(view.eligible_count, 0);
1415        assert!(!view.truncated);
1416    }
1417
1418    // ── should_render_view_line — display predicate ────────────────
1419
1420    #[test]
1421    fn display_predicate_default_spec_is_false() {
1422        // Default invocation: no filtering, no truncation.
1423        let r = background_fixture();
1424        let view = apply(&r, ViewSpec::default());
1425        assert!(!should_render_view_line(&view));
1426    }
1427
1428    #[test]
1429    fn display_predicate_sort_only_is_false() {
1430        // Sort-only invocation: still false (no rows reduced).
1431        let r = background_fixture();
1432        let spec = ViewSpec {
1433            sort: SortKey::Coverage,
1434            ..Default::default()
1435        };
1436        let view = apply(&r, spec);
1437        assert!(!should_render_view_line(&view));
1438    }
1439
1440    #[test]
1441    fn display_predicate_top_truncating_is_true() {
1442        // --top truncating: true.
1443        let r = background_fixture();
1444        let spec = ViewSpec {
1445            limit: Some(2),
1446            ..Default::default()
1447        };
1448        let view = apply(&r, spec);
1449        assert!(should_render_view_line(&view));
1450    }
1451
1452    #[test]
1453    fn display_predicate_coverage_filter_excluding_is_true() {
1454        // Coverage filter that excludes: true.
1455        let r = background_fixture();
1456        let range = CoverageRange::new(99.0, 100.0).unwrap();
1457        let spec = ViewSpec {
1458            filters: Filters {
1459                coverage_range: Some(range),
1460                ..Default::default()
1461            },
1462            ..Default::default()
1463        };
1464        let view = apply(&r, spec);
1465        assert!(should_render_view_line(&view));
1466    }
1467
1468    #[test]
1469    fn display_predicate_only_failing_reducing_is_true() {
1470        // --only-failing with some passing rows: true.
1471        let r = background_fixture();
1472        let spec = ViewSpec {
1473            filters: Filters {
1474                only_failing: true,
1475                ..Default::default()
1476            },
1477            ..Default::default()
1478        };
1479        let view = apply(&r, spec);
1480        assert!(should_render_view_line(&view));
1481    }
1482
1483    // ── Grouping (issue #64 — `--group-by file`) ────────────────────
1484
1485    #[test]
1486    fn no_group_by_means_no_grouped_block() {
1487        // Biconditional half: spec.group_by.is_none() ⇒ view.grouped.is_none()
1488        let r = background_fixture();
1489        let view = apply(&r, ViewSpec::default());
1490        assert!(view.grouped.is_none());
1491    }
1492
1493    #[test]
1494    fn group_by_file_populates_grouped_block() {
1495        // Biconditional half: spec.group_by.is_some() ⇒ view.grouped.is_some()
1496        let r = background_fixture();
1497        let spec = ViewSpec {
1498            group_by: Some(GroupKey::File),
1499            ..Default::default()
1500        };
1501        let view = apply(&r, spec);
1502        let grouped = view.grouped.as_ref().expect("grouped block expected");
1503        assert_eq!(grouped.key, GroupKey::File);
1504        // Background fixture: 5 distinct files (parse_args is in cli/mod.rs;
1505        // table.rs has two functions; lcov, syn, threshold one each).
1506        assert_eq!(grouped.files.len(), 5);
1507        assert_eq!(grouped.eligible_count, 5);
1508        assert!(!grouped.truncated);
1509    }
1510
1511    #[test]
1512    fn group_by_file_does_not_truncate_function_shown() {
1513        // Function-level shown is the un-truncated eligible set under grouping.
1514        let r = background_fixture();
1515        let spec = ViewSpec {
1516            group_by: Some(GroupKey::File),
1517            limit: Some(2),
1518            ..Default::default()
1519        };
1520        let view = apply(&r, spec);
1521        // limit=2 truncates files, not functions.
1522        assert_eq!(view.shown.len(), r.functions.len());
1523        assert!(!view.truncated);
1524        let grouped = view.grouped.as_ref().unwrap();
1525        assert_eq!(grouped.files.len(), 2);
1526        assert!(grouped.truncated);
1527        assert_eq!(grouped.eligible_count, 5);
1528    }
1529
1530    #[test]
1531    fn group_by_file_keeps_gate_unchanged() {
1532        // P6 (gate-vs-display): grouping does not change view.full.passed
1533        // or view.full.summary.
1534        let r = background_fixture();
1535        let baseline_passed = r.passed;
1536        let baseline_total = r.summary.total_functions;
1537        let baseline_exceeding = r.summary.exceeding_threshold;
1538        let spec = ViewSpec {
1539            group_by: Some(GroupKey::File),
1540            limit: Some(1),
1541            ..Default::default()
1542        };
1543        let view = apply(&r, spec);
1544        assert_eq!(view.full.passed, baseline_passed);
1545        assert_eq!(view.full.summary.total_functions, baseline_total);
1546        assert_eq!(view.full.summary.exceeding_threshold, baseline_exceeding);
1547    }
1548
1549    #[test]
1550    fn group_by_file_default_sort_is_avg_crap_desc() {
1551        let r = background_fixture();
1552        let spec = ViewSpec {
1553            group_by: Some(GroupKey::File),
1554            ..Default::default()
1555        };
1556        let view = apply(&r, spec);
1557        let files = &view.grouped.as_ref().unwrap().files;
1558        for w in files.windows(2) {
1559            assert!(
1560                w[0].average_crap >= w[1].average_crap,
1561                "files not in average_crap descending order"
1562            );
1563        }
1564    }
1565
1566    #[test]
1567    fn group_by_file_sort_by_coverage_ascending() {
1568        let r = background_fixture();
1569        let spec = ViewSpec {
1570            group_by: Some(GroupKey::File),
1571            sort: SortKey::Coverage,
1572            ..Default::default()
1573        };
1574        let view = apply(&r, spec);
1575        let files = &view.grouped.as_ref().unwrap().files;
1576        for w in files.windows(2) {
1577            assert!(w[0].average_coverage <= w[1].average_coverage);
1578        }
1579    }
1580
1581    #[test]
1582    fn group_by_file_sort_by_complexity_descending() {
1583        let r = background_fixture();
1584        let spec = ViewSpec {
1585            group_by: Some(GroupKey::File),
1586            sort: SortKey::Complexity,
1587            ..Default::default()
1588        };
1589        let view = apply(&r, spec);
1590        let files = &view.grouped.as_ref().unwrap().files;
1591        for w in files.windows(2) {
1592            assert!(w[0].max_complexity >= w[1].max_complexity);
1593        }
1594    }
1595
1596    #[test]
1597    fn group_by_file_sort_by_path_alphabetical() {
1598        let r = background_fixture();
1599        let spec = ViewSpec {
1600            group_by: Some(GroupKey::File),
1601            sort: SortKey::Path,
1602            ..Default::default()
1603        };
1604        let view = apply(&r, spec);
1605        let files = &view.grouped.as_ref().unwrap().files;
1606        for w in files.windows(2) {
1607            assert!(w[0].file_path <= w[1].file_path);
1608        }
1609    }
1610
1611    #[test]
1612    fn group_by_file_truncate_files() {
1613        let r = background_fixture();
1614        let spec = ViewSpec {
1615            group_by: Some(GroupKey::File),
1616            limit: Some(3),
1617            ..Default::default()
1618        };
1619        let view = apply(&r, spec);
1620        let grouped = view.grouped.as_ref().unwrap();
1621        assert_eq!(grouped.files.len(), 3);
1622        assert!(grouped.truncated);
1623        assert_eq!(grouped.eligible_count, 5);
1624    }
1625
1626    #[test]
1627    fn group_by_file_filters_compose_before_grouping() {
1628        // only_failing + group_by file: grouped.files reflect only files
1629        // that have a failing function.
1630        let r = background_fixture();
1631        let spec = ViewSpec {
1632            filters: Filters {
1633                only_failing: true,
1634                ..Default::default()
1635            },
1636            group_by: Some(GroupKey::File),
1637            ..Default::default()
1638        };
1639        let view = apply(&r, spec);
1640        let grouped = view.grouped.as_ref().unwrap();
1641        // Background fixture: failing functions are sort_verdicts (table.rs)
1642        // CRAP=42 and parse_args (cli/mod.rs) CRAP=63.5 → 2 distinct files.
1643        assert_eq!(grouped.files.len(), 2);
1644        // Every file has at least one exceeding function.
1645        for f in &grouped.files {
1646            assert!(f.exceeding_count >= 1);
1647        }
1648    }
1649
1650    #[test]
1651    fn group_by_file_empty_input_produces_empty_files() {
1652        let r = empty_result();
1653        let spec = ViewSpec {
1654            group_by: Some(GroupKey::File),
1655            ..Default::default()
1656        };
1657        let view = apply(&r, spec);
1658        let grouped = view.grouped.as_ref().unwrap();
1659        assert!(grouped.files.is_empty());
1660        assert_eq!(grouped.eligible_count, 0);
1661        assert!(!grouped.truncated);
1662    }
1663
1664    #[test]
1665    fn display_predicate_group_by_only_default_input_is_false() {
1666        // Grouping without filtering or truncating: all distinct files
1667        // appear in the grouped block, so the predicate returns false
1668        // (no rows reduced, no files reduced).
1669        let r = background_fixture();
1670        let spec = ViewSpec {
1671            group_by: Some(GroupKey::File),
1672            ..Default::default()
1673        };
1674        let view = apply(&r, spec);
1675        assert!(!should_render_view_line(&view));
1676    }
1677
1678    #[test]
1679    fn display_predicate_group_by_truncating_files_is_true() {
1680        let r = background_fixture();
1681        let spec = ViewSpec {
1682            group_by: Some(GroupKey::File),
1683            limit: Some(2),
1684            ..Default::default()
1685        };
1686        let view = apply(&r, spec);
1687        assert!(should_render_view_line(&view));
1688    }
1689
1690    // ── Mutation killers for truncate_files_to ─────────────────────
1691    //
1692    // truncate_files_to has a tight guard: `Some(n) if n > 0 && files.len() > n`.
1693    // The three tests below pin each clause:
1694    //  - L265:22 `n > 0`        — proven by `--top 0` non-empty case
1695    //  - L265:41 `files.len() > n` — proven by `files.len() == n` case
1696    //  - L265:26 `&&` operator   — proven by `--top 0` non-empty case
1697    //  - L265:20 whole guard    — proven by both cases above
1698
1699    #[test]
1700    fn group_by_file_top_zero_is_no_limit() {
1701        // limit=Some(0) with non-empty files MUST NOT truncate; truncated=false.
1702        // Mirrors the `--top 0` ergonomic where 0 means "no limit".
1703        let r = background_fixture();
1704        let spec = ViewSpec {
1705            group_by: Some(GroupKey::File),
1706            limit: Some(0),
1707            ..Default::default()
1708        };
1709        let view = apply(&r, spec);
1710        let grouped = view.grouped.as_ref().expect("grouping active");
1711        assert!(!grouped.truncated);
1712        // All 5 distinct files must be present.
1713        assert_eq!(grouped.files.len(), 5);
1714    }
1715
1716    #[test]
1717    fn group_by_file_limit_equal_to_file_count_is_not_truncated() {
1718        // When limit exactly matches file count, truncated MUST be false.
1719        // Distinguishes `files.len() > n` (correct) from `files.len() >= n`
1720        // (would set truncated=true for an effectively no-op truncate).
1721        let r = background_fixture();
1722        let spec = ViewSpec {
1723            group_by: Some(GroupKey::File),
1724            limit: Some(5),
1725            ..Default::default()
1726        };
1727        let view = apply(&r, spec);
1728        let grouped = view.grouped.as_ref().expect("grouping active");
1729        assert!(!grouped.truncated);
1730        assert_eq!(grouped.files.len(), 5);
1731    }
1732
1733    // ── Mutation killers for distinct_files ────────────────────────
1734    //
1735    // `should_render_view_line` calls `distinct_files(view.full)` to decide
1736    // whether grouping reduced the file count. Mutants replacing the body
1737    // with `0` or `1` constants are killed by these tests:
1738    //  - replace -> 0: filtering excludes some files; eligible_count < distinct
1739    //                  must NOT trigger when all files survive (background = 5)
1740    //  - replace -> 1: with 5 distinct files, predicate must reflect that
1741
1742    #[test]
1743    fn display_predicate_full_grouping_no_reduction_is_false() {
1744        // With grouping active but no filter/truncate, view line MUST NOT
1745        // render. This requires distinct_files == eligible_count == 5
1746        // (replace-with-0 would make distinct=0, predicate fires; killed.)
1747        let r = background_fixture();
1748        let spec = ViewSpec {
1749            group_by: Some(GroupKey::File),
1750            ..Default::default()
1751        };
1752        let view = apply(&r, spec);
1753        assert!(!should_render_view_line(&view));
1754    }
1755
1756    #[test]
1757    fn display_predicate_grouping_reduces_files_is_true() {
1758        // Filter excludes 4 of 5 files; eligible_count=1 < distinct=5.
1759        // Predicate must fire. Replace-with-1 would yield distinct=1=eligible
1760        // and predicate would NOT fire — killed.
1761        let r = background_fixture();
1762        let spec = ViewSpec {
1763            group_by: Some(GroupKey::File),
1764            filters: Filters {
1765                only_failing: true,
1766                ..Default::default()
1767            },
1768            ..Default::default()
1769        };
1770        let view = apply(&r, spec);
1771        assert!(should_render_view_line(&view));
1772    }
1773}
1774
1775#[cfg(test)]
1776mod proptests {
1777    use super::*;
1778    use crate::test_strategies::{arb_analysis_result, arb_verdict_with_nan_coverage};
1779    use proptest::prelude::*;
1780
1781    /// Mirror the legacy `format_table` sort: CRAP descending with stable
1782    /// fallback to input order on ties.
1783    fn legacy_sort_order(result: &AnalysisResult) -> Vec<&FunctionVerdict> {
1784        let mut sorted: Vec<&FunctionVerdict> = result.functions.iter().collect();
1785        sorted.sort_by(|a, b| {
1786            b.scored
1787                .crap
1788                .value
1789                .partial_cmp(&a.scored.crap.value)
1790                .unwrap_or(std::cmp::Ordering::Equal)
1791        });
1792        sorted
1793    }
1794
1795    proptest! {
1796        #![proptest_config(ProptestConfig::with_cases(256))]
1797
1798        /// Invariant 1 (Order): `apply(r, ViewSpec::default()).shown` matches
1799        /// the legacy sort: CRAP descending, stable on ties.
1800        ///
1801        /// Both sides borrow from `result.functions`, so pointer equality is
1802        /// the strictest possible witness of stable-sort agreement and is
1803        /// immune to any duplicate `qualified_name` the strategy might
1804        /// produce (CodeRabbit CR-N7).
1805        #[test]
1806        fn prop_default_spec_order_matches_legacy_sort(result in arb_analysis_result()) {
1807            let view = apply(&result, ViewSpec::default());
1808            let legacy = legacy_sort_order(&result);
1809            prop_assert_eq!(view.shown.len(), legacy.len());
1810            for (a, b) in view.shown.iter().zip(legacy.iter()) {
1811                prop_assert!(std::ptr::eq(*a, *b));
1812            }
1813        }
1814
1815        /// Invariant 2 (Identity): the set of identities is preserved.
1816        #[test]
1817        fn prop_default_spec_preserves_identity(result in arb_analysis_result()) {
1818            let view = apply(&result, ViewSpec::default());
1819            let shown_identities: std::collections::HashSet<&crate::domain::types::FunctionIdentity> =
1820                view.shown.iter().map(|v| &v.scored.identity).collect();
1821            let original_identities: std::collections::HashSet<&crate::domain::types::FunctionIdentity> =
1822                result.functions.iter().map(|v| &v.scored.identity).collect();
1823            prop_assert_eq!(shown_identities, original_identities);
1824        }
1825
1826        /// Invariant 3 (Summary): `view.full` borrows the original result.
1827        /// Stronger than equals — pointer equality.
1828        #[test]
1829        fn prop_default_spec_preserves_summary(result in arb_analysis_result()) {
1830            let view = apply(&result, ViewSpec::default());
1831            prop_assert!(std::ptr::eq(view.full, &result));
1832            // and shape: total_functions agrees
1833            prop_assert_eq!(view.full.summary.total_functions, result.summary.total_functions);
1834        }
1835
1836        /// Invariant 4 (Display): biconditional of the predicate.
1837        #[test]
1838        fn prop_display_predicate_biconditional(result in arb_analysis_result()) {
1839            // Default spec: predicate must be false (no rows reduced).
1840            let view = apply(&result, ViewSpec::default());
1841            let computed = should_render_view_line(&view);
1842            let expected = view.eligible_count < view.full.functions.len() || view.truncated;
1843            prop_assert_eq!(computed, expected);
1844            // Default invocation reduces nothing → both sides false.
1845            prop_assert!(!computed);
1846        }
1847
1848        /// `apply` never panics on NaN-coverage inputs.
1849        #[test]
1850        fn prop_apply_never_panics_with_nan_coverage(
1851            verdicts in prop::collection::vec(arb_verdict_with_nan_coverage(), 0..50)
1852        ) {
1853            let result = AnalysisResult {
1854                functions: verdicts.clone(),
1855                summary: crate::domain::types::AnalysisSummary {
1856                    total_functions: verdicts.len(),
1857                    total_files: verdicts.len(),
1858                    exceeding_threshold: 0,
1859                    average_crap: 0.0,
1860                    median_crap: 0.0,
1861                    max_crap: None,
1862                    worst_function: None,
1863                    distribution: crate::domain::types::RiskDistribution {
1864                        low: 0, acceptable: 0, moderate: 0, high: 0,
1865                    },
1866                    ..Default::default()
1867                },
1868                passed: true,
1869            };
1870            // Try every SortKey + a coverage-range filter.
1871            for sort in [SortKey::Crap, SortKey::Coverage, SortKey::Complexity, SortKey::Path] {
1872                let spec = ViewSpec {
1873                    filters: Filters {
1874                        coverage_range: Some(CoverageRange::new(0.0, 100.0).unwrap()),
1875                        ..Default::default()
1876                    },
1877                    sort,
1878                    limit: Some(10),
1879                    ..Default::default()
1880                };
1881                let _ = apply(&result, spec);
1882            }
1883        }
1884    }
1885}