ktstr/assert/
claim.rs

1//! Pointwise-claim accumulator API: [`Verdict`], [`ClaimBuilder`],
2//! [`SetClaim`], [`SeqClaim`], and the [`claim!`](crate::claim) macro.
3//!
4//! `Verdict` is the per-test claim accumulator. [`Assert`] holds threshold
5//! config and stays `Copy`; `Verdict` carries the per-test claim records
6//! (which include `Vec`/`String` allocations) and is built via
7//! [`Assert::default_checks`]`().verdict()` or [`Verdict::new`].
8//!
9//! Test authors reach for one of two compile-mechanical labelers:
10//!
11//!   1. Typed field accessors generated by `#[derive(Claim)]` on stats
12//!      structs — e.g. `stats.claim_max_gap_ms(&mut verdict).at_most(100)`.
13//!      The label `"max_gap_ms"` comes from `stringify!(max_gap_ms)` in
14//!      the generated method body; renaming the field updates both the
15//!      method name AND the rendered label.
16//!
17//!   2. The [`claim!`](crate::claim) macro on a local binding or
18//!      expression — e.g. `claim!(verdict, iter_delta).at_least(100)`.
19//!      The label comes from `stringify!(<token tree>)` over the
20//!      expression tokens.
21//!
22//! There is NO third recommended "manual string" path. The
23//! string-taking entry points on `Verdict` (`claim` / `claim_set` /
24//! `claim_seq` / `claim_present`) are `#[doc(hidden)]` and expected to
25//! be fed only by `stringify!` via the derive or the `claim!` macro.
26//!
27//! Comparator surface for [`ClaimBuilder<T>`]:
28//!   * `T: PartialOrd + Display` -> `at_least` / `at_most` / `lt` / `gt`
29//!     / `between`
30//!   * `T: PartialEq + Display`  -> `eq` / `ne`
31//!   * `T = f64`                 -> `is_finite` / `near`
32//!
33//! Container claims (set / sequence) bypass scalar comparators and offer
34//! `empty` / `nonempty` / `contains` / `len_eq` / `len_at_most` /
35//! `len_at_least` / `subset_of` / `disjoint_from` instead.
36
37use std::collections::BTreeSet;
38
39use super::{Assert, AssertDetail, AssertResult, DetailKind, NoteValue, Outcome};
40
41/// Read the `KTSTR_LOG_PASSES` env var to seed
42/// [`Verdict::log_passes`]. Any value other than `""` or `"0"` enables
43/// the flag — so `KTSTR_LOG_PASSES=1`, `KTSTR_LOG_PASSES=true`, and a
44/// bare `KTSTR_LOG_PASSES=` (empty) all behave intuitively. Read once
45/// at [`Verdict::new`] time per call; cheap (`getenv` + tiny match)
46/// and respects a mid-process env-var flip without process restart.
47fn log_passes_default() -> bool {
48    match std::env::var(crate::KTSTR_LOG_PASSES_ENV) {
49        Ok(v) => !(v.is_empty() || v == "0"),
50        Err(_) => false,
51    }
52}
53
54/// Pointwise-claim accumulator.
55///
56/// Carries an [`AssertResult`] under the hood and exposes the same
57/// `outcome()` / `outcomes` / `stats` shape on completion. Build via
58/// [`Verdict::new`] or [`Assert::verdict`]; finish with
59/// [`Verdict::into_result`] (a bound local, zero-clone) or
60/// [`Verdict::to_result`] (a fluent chain off a temporary).
61///
62/// ```
63/// # use ktstr::assert::{Assert, Verdict};
64/// // Empty verdict — no claims, passes.
65/// let v = Assert::default_checks().verdict();
66/// let r = v.into_result();
67/// assert!(r.is_pass());
68/// ```
69#[must_use = "Verdict accumulates claims; call into_result() (bound local) or to_result() (fluent chain) to consume"]
70#[derive(Debug, Clone)]
71pub struct Verdict {
72    /// Threshold config the verdict was opened against. Carried so
73    /// downstream merge sites (e.g. retrofitted `assert_not_starved`)
74    /// can read thresholds out of the verdict directly without an
75    /// extra parameter. Optional because [`Verdict::new`] does not
76    /// require an `Assert`; tests that only run pointwise claims
77    /// don't need a threshold layer attached.
78    assert: Option<Assert>,
79    /// Underlying [`AssertResult`] envelope — claim records and merged
80    /// upstream results write into this. Initialized as a passing
81    /// result at `new`; mutates as comparators record outcomes.
82    result: AssertResult,
83    /// When true, every comparator's pass arm emits a
84    /// `tracing::info!` event naming the claim and the compared
85    /// values. Off by default so the no-allocation pass path stays
86    /// allocation-free under normal runs; flipped on via
87    /// [`Self::with_log_passes`] (or the
88    /// `KTSTR_LOG_PASSES` env var, read once at [`Self::new`] time)
89    /// when a test author wants positive confirmation of every
90    /// compared value in `--nocapture` output. The pass-message
91    /// formatting is gated behind the flag, so the `format!` cost
92    /// is paid only on the explicit opt-in.
93    log_passes: bool,
94}
95
96impl Default for Verdict {
97    /// Equivalent to [`Verdict::new`]: no threshold config attached, no
98    /// claims accumulated.
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl Verdict {
105    /// Empty accumulator with no [`Assert`] attached. Use when a test
106    /// is composing pointwise claims and does not care about the
107    /// scheduler-level threshold layer.
108    pub fn new() -> Self {
109        Self {
110            assert: None,
111            result: AssertResult::pass(),
112            log_passes: log_passes_default(),
113        }
114    }
115
116    /// Empty accumulator carrying `assert` for downstream lookup.
117    /// Reached via [`Assert::verdict`]; not part of the test author's
118    /// usual entry point.
119    pub fn with_assert(assert: Assert) -> Self {
120        Self {
121            assert: Some(assert),
122            result: AssertResult::pass(),
123            log_passes: log_passes_default(),
124        }
125    }
126
127    /// Toggle positive-confirmation logging. When `on`, every
128    /// comparator's pass arm emits a `tracing::info!` event naming
129    /// the claim and the values compared, visible to operators
130    /// running tests with `--nocapture`. Off by default — the
131    /// pass path stays allocation-free.
132    ///
133    /// Initial value reads `KTSTR_LOG_PASSES` (any value other than
134    /// `""` or `"0"` enables) so a debugging session can flip the
135    /// flag from the shell without touching test source.
136    /// Programmatic override always wins over the env var; chain
137    /// `.with_log_passes(false)` to silence a single verdict even
138    /// when the env var is set.
139    pub fn with_log_passes(mut self, on: bool) -> Self {
140        self.log_passes = on;
141        self
142    }
143
144    /// Read the current positive-confirmation logging flag.
145    pub fn log_passes(&self) -> bool {
146        self.log_passes
147    }
148
149    /// Open a per-claim builder for the named `(subject, value)` pair.
150    /// `name` is `&'static str` because the only legitimate sources for
151    /// a claim label are `stringify!(field)` (from the
152    /// [`#[derive(Claim)]`](ktstr_macros::Claim) generator) and
153    /// `stringify!(expr)` (from the [`claim!`](crate::claim) macro) —
154    /// both produce static string slices.
155    ///
156    /// Public so the `#[derive(Claim)]` generator and the `claim!`
157    /// macro can dispatch through it; not the recommended user-facing
158    /// entry point. Hand-typing `verdict.claim("subject", value)` is
159    /// disallowed: a manual string can drift from the value it
160    /// labels (rename a field, leave the literal stale), so labels
161    /// must originate from `stringify!(field)` or `stringify!(expr)`
162    /// via the derive or the macro.
163    #[doc(hidden)]
164    pub fn claim<T>(&mut self, name: &'static str, value: T) -> ClaimBuilder<'_, T> {
165        ClaimBuilder {
166            verdict: self,
167            name,
168            value,
169            kind: DetailKind::Other,
170            reason: None,
171        }
172    }
173
174    /// Open a per-claim builder for a set-typed value. Same naming
175    /// rules as [`Self::claim`]; the [`#[derive(Claim)]`](ktstr_macros::Claim)
176    /// generator emits accessors that route through this dispatch
177    /// helper for `BTreeSet<T>` fields.
178    #[doc(hidden)]
179    pub fn claim_set<'a, T: Ord + std::fmt::Debug>(
180        &'a mut self,
181        name: &'static str,
182        set: &'a BTreeSet<T>,
183    ) -> SetClaim<'a, T> {
184        SetClaim {
185            verdict: self,
186            name,
187            value: set,
188            kind: DetailKind::Other,
189            reason: None,
190        }
191    }
192
193    /// Open a per-claim builder for a sequence-typed value. Same naming
194    /// rules as [`Self::claim`]; the [`#[derive(Claim)]`](ktstr_macros::Claim)
195    /// generator emits accessors that route through this dispatch
196    /// helper for `Vec<T>` and slice fields.
197    #[doc(hidden)]
198    pub fn claim_seq<'a, T: std::fmt::Debug>(
199        &'a mut self,
200        name: &'static str,
201        seq: &'a [T],
202    ) -> SeqClaim<'a, T> {
203        SeqClaim {
204            verdict: self,
205            name,
206            value: seq,
207            kind: DetailKind::Other,
208            reason: None,
209        }
210    }
211
212    /// Open a presence-checked claim for a possibly-absent metric.
213    ///
214    /// Use this on the direct-claim path when the value comes from an
215    /// `Option`-returning lookup (e.g. `VmResult::phase_metric` or a
216    /// `ScenarioStats` accessor): a `None` (the metric was never
217    /// produced) records a LOUD `Fail` rather than panicking on
218    /// `.unwrap()` or silently passing a sentinel from
219    /// `.unwrap_or(0.0)`. On `Some(v)` the returned [`PresentClaim`]
220    /// behaves exactly like [`Self::claim`].
221    ///
222    /// Public so the [`claim_present!`](crate::claim_present) macro can
223    /// dispatch through it; prefer the macro so the label is
224    /// `stringify!`-derived (same `&'static str` discipline as
225    /// [`Self::claim`]).
226    #[doc(hidden)]
227    pub fn claim_present(&mut self, name: &'static str, value: Option<f64>) -> PresentClaim<'_> {
228        PresentClaim {
229            verdict: self,
230            name,
231            value,
232            kind: DetailKind::Other,
233            reason: None,
234        }
235    }
236
237    /// Append an informational note to the underlying [`AssertResult`]
238    /// without altering the verdict. Mirrors [`AssertResult::note`].
239    pub fn note(&mut self, msg: impl Into<String>) -> &mut Self {
240        self.result.note(msg);
241        self
242    }
243
244    /// Attach a structured `(key, value)` measurement to the
245    /// underlying [`AssertResult::measurements`] map without
246    /// altering the verdict. Mirrors [`AssertResult::note_value`].
247    /// Use when a test wants to surface a programmatically
248    /// consumable measurement (e.g. `verdict.note_value("max_wchar",
249    /// 12345i64)`) alongside pass/fail claims, so sidecar parsers
250    /// and `perf-delta` dashboards can read the typed value
251    /// without re-grepping `details`.
252    pub fn note_value(&mut self, key: impl Into<String>, value: impl Into<NoteValue>) -> &mut Self {
253        self.result.note_value(key, value);
254        self
255    }
256
257    /// Mark the verdict as skipped with the supplied reason. Pushes
258    /// one [`Outcome::Skip`] carrying a [`DetailKind::Skip`] detail
259    /// onto the outcome stream. Use when a precondition is missing
260    /// and the scenario cannot run.
261    ///
262    /// Prior outcomes are preserved: a verdict whose earlier claim
263    /// failed and then transitions to skip stays failed. The merge
264    /// lattice (`Fail > Inconclusive > Pass > Skip`) keeps any
265    /// recorded [`Outcome::Fail`] dominant, so once a claim records
266    /// a real failure, a later skip cannot mask it. Distinct from
267    /// [`AssertResult::skip`], which is a CONSTRUCTOR producing a
268    /// fresh skipped envelope from no prior state.
269    pub fn skip(&mut self, reason: impl Into<String>) -> &mut Self {
270        self.result.record_skip(reason);
271        self
272    }
273
274    /// Conditional skip: skip with `reason` when `cond` is true; no-op
275    /// otherwise. Convenience over `if cond { v.skip(reason); }`.
276    pub fn skip_if(&mut self, cond: bool, reason: impl Into<String>) -> &mut Self {
277        if cond {
278            self.skip(reason);
279        }
280        self
281    }
282
283    /// Mark the verdict as inconclusive with the supplied detail.
284    /// Pushes one [`Outcome::Inconclusive`] carrying `detail` onto
285    /// the outcome stream. Use when the gate APPLIED (preconditions
286    /// were met) but the signal needed to evaluate it was absent —
287    /// e.g. a ratio whose denominator is INSTRUMENT-derived and
288    /// happened to be zero (no migrations observed), so the
289    /// comparison cannot be performed and the verdict is neither
290    /// pass nor fail.
291    ///
292    /// Boundary with [`Self::skip`]: Inconclusive = the gate
293    /// applied but the signal was absent; Skip = the gate's
294    /// precondition was unmet so the gate did not apply at all.
295    /// Boundary with comparator-driven `record_fail`: Inconclusive
296    /// = the denominator is INSTRUMENT-derived (a measurement that
297    /// happened to be zero); Fail = the denominator is
298    /// POLICY-derived (a configured expectation that must hold —
299    /// see the [`Outcome`] doc's `MemPolicy::Bind` carve-out).
300    ///
301    /// Prior outcomes are preserved: a verdict whose earlier claim
302    /// failed and then transitions to inconclusive stays failed
303    /// per the merge lattice (`Fail > Inconclusive > Pass > Skip`).
304    pub fn inconclusive(&mut self, detail: AssertDetail) -> &mut Self {
305        self.result.record_inconclusive(detail);
306        self
307    }
308
309    /// Conditional inconclusive: record `detail` when `cond` is
310    /// true; no-op otherwise. Convenience over
311    /// `if cond { v.inconclusive(detail); }`. Mirrors
312    /// [`Self::skip_if`].
313    pub fn inconclusive_if(&mut self, cond: bool, detail: AssertDetail) -> &mut Self {
314        if cond {
315            self.inconclusive(detail);
316        }
317        self
318    }
319
320    /// Fold an external [`AssertResult`] into this verdict. Useful when
321    /// a test combines pointwise claims with the result of an upstream
322    /// `assert_*` call (e.g. `assert_not_starved`). Mirrors
323    /// [`AssertResult::merge`] semantics — `other.outcomes` are
324    /// appended to this verdict's outcome stream, so the merge
325    /// lattice (`Fail > Inconclusive > Pass > Skip`) folds across
326    /// both sides; notes are concatenated, measurements are folded as
327    /// a keyed union (other's keys overwrite self's on collision);
328    /// aggregate stats adopt the worst per dimension.
329    pub fn merge(&mut self, other: AssertResult) -> &mut Self {
330        self.result.merge(other);
331        self
332    }
333
334    /// True iff the folded outcome is [`Outcome::Pass`]. Per
335    /// [`AssertResult::is_pass`], this requires no recorded
336    /// `Fail`, no recorded `Inconclusive`, AND the outcome
337    /// stream is either empty or contains at least one
338    /// non-`Skip` claim. An empty verdict (no claims recorded)
339    /// folds to Pass; an all-`Skip` stream returns `false`
340    /// here — the verdict didn't fail, but it also didn't
341    /// actually run. Read-only. Name matches the rest of the
342    /// 4-state vocabulary across [`AssertResult::is_pass`] /
343    /// [`crate::test_support::SidecarResult::is_pass`] /
344    /// [`Outcome::is_pass`] / [`Self::is_pass`] /
345    /// `MonitorVerdict::is_pass` (in the `monitor` module,
346    /// which is `pub(crate)`) / `GauntletRow::is_pass` (in
347    /// the `stats` module, which is `pub(crate)`). The methods
348    /// are `pub`; only the containing modules are `pub(crate)`,
349    /// so the bare rustdoc links don't resolve from this pub
350    /// item — the code spans on those two are bare to avoid a
351    /// private intra-doc-link warning.
352    ///
353    /// Note: a verdict that recorded one or more [`Outcome::Inconclusive`]
354    /// outcomes returns `false` here even though it did not record a
355    /// hard failure. Use [`Verdict::into_result`] + [`AssertResult::outcome`]
356    /// when callers need to distinguish Pass from Inconclusive from Fail.
357    pub fn is_pass(&self) -> bool {
358        self.result.is_pass()
359    }
360
361    /// True iff any recorded outcome is [`Outcome::Fail`]. Mirrors
362    /// [`AssertResult::is_fail`] — any Fail in the outcome stream
363    /// dominates under the `Fail > Inconclusive > Pass > Skip`
364    /// lattice, so a single failed claim makes this true regardless
365    /// of how many later inconclusive or pass claims followed.
366    pub fn is_fail(&self) -> bool {
367        self.result.is_fail()
368    }
369
370    /// True iff any recorded outcome is [`Outcome::Inconclusive`] and
371    /// no [`Outcome::Fail`] was recorded. Mirrors
372    /// [`AssertResult::is_inconclusive`]. Fail dominates: a verdict
373    /// with both Fail and Inconclusive outcomes returns false here
374    /// and true from [`Self::is_fail`].
375    pub fn is_inconclusive(&self) -> bool {
376        self.result.is_inconclusive()
377    }
378
379    /// True iff the outcome stream is non-empty and every recorded
380    /// outcome is [`Outcome::Skip`]. Mirrors [`AssertResult::is_skip`].
381    /// An empty verdict (the merge identity) returns false here — it
382    /// is reported as Pass via [`Self::is_pass`], not Skip.
383    pub fn is_skip(&self) -> bool {
384        self.result.is_skip()
385    }
386
387    /// Number of recorded outcomes carrying a payload
388    /// (Fail + Inconclusive + Skip) — i.e. recorded diagnostics so
389    /// far. Pass markers don't count.
390    pub fn detail_count(&self) -> usize {
391        self.result
392            .outcomes
393            .iter()
394            .filter(|o| !matches!(o, Outcome::Pass))
395            .count()
396    }
397
398    /// Read the [`Assert`] threshold config attached at construction
399    /// time, if any. `None` when the verdict was built via
400    /// [`Verdict::new`] (no threshold layer).
401    pub fn assert(&self) -> Option<&Assert> {
402        self.assert.as_ref()
403    }
404
405    /// Consume the accumulator and return the merged [`AssertResult`].
406    /// Terminal for a BOUND verdict — zero-clone, takes `self`.
407    ///
408    /// ```
409    /// # use ktstr::assert::Verdict;
410    /// let mut v = Verdict::new();
411    /// v.claim("answer", 42u64).at_least(40);
412    /// let r = v.into_result();
413    /// assert!(r.is_pass());
414    /// ```
415    ///
416    /// Cannot terminate a fluent chain off a temporary: the comparators
417    /// return `&mut Verdict`, so `Verdict::new().claim(..).at_least(..)`
418    /// yields a borrow, and `into_result(self)` cannot move the temporary
419    /// out from under it (E0507). Use [`Self::to_result`] for the fluent
420    /// one-liner.
421    pub fn into_result(self) -> AssertResult {
422        self.result
423    }
424
425    /// Clone out the merged [`AssertResult`] from `&self` — the terminal
426    /// for a FLUENT chain. The comparators return `&mut Verdict`, so a
427    /// chain built on a temporary (`Verdict::new().claim(..).at_least(..)`)
428    /// only ever has a borrow at the end; [`Self::into_result`] would have
429    /// to move the temporary out from under that borrow (E0507). This
430    /// borrows instead and clones, so the one-liner compiles:
431    ///
432    /// ```
433    /// # use ktstr::assert::Verdict;
434    /// let r = Verdict::new()
435    ///     .claim("answer", 42u64)
436    ///     .at_least(40)
437    ///     .to_result();
438    /// assert!(r.is_pass());
439    /// ```
440    ///
441    /// Prefer [`Self::into_result`] when the verdict is already a bound
442    /// local (it consumes, no clone). The clone here is a test-path cost
443    /// (claim records are bounded by [`super::MAX_RECORDED_PASSES`]).
444    pub fn to_result(&self) -> AssertResult {
445        self.result.clone()
446    }
447
448    /// Terminal post_vm-callback helper. Equivalent to
449    /// `self.into_result().into_anyhow_or_log()` — drains the
450    /// accumulator, logs every [`crate::assert::InfoNote`] via
451    /// `tracing::info!`, and bails on any accumulated failure
452    /// (with all failure details concatenated). See
453    /// [`AssertResult::into_anyhow_or_log`] for the full contract.
454    ///
455    /// Use this to collapse a typical post_vm callback's terminal
456    /// `let r = v.into_result(); … bail/log loop` boilerplate
457    /// into a single chainable call.
458    pub fn into_anyhow_or_log(self) -> anyhow::Result<()> {
459        self.into_result().into_anyhow_or_log()
460    }
461
462    /// Mutable handle to the underlying [`AssertResult`]. Used by
463    /// the [`crate::assert::temporal`] patterns to push
464    /// [`DetailKind::Temporal`] details and flip `passed` without
465    /// going through the comparator surface (which is shaped for
466    /// scalar `(name, value)` claims, not per-sample multi-failure
467    /// pile-on). Crate-internal — out-of-tree callers use
468    /// [`Self::merge`] / [`Self::note`] / [`Self::note_value`].
469    pub(crate) fn result_mut(&mut self) -> &mut AssertResult {
470        &mut self.result
471    }
472
473    /// Read-only handle to the underlying [`AssertResult`]. Used by
474    /// the [`crate::assert::temporal`] patterns to count
475    /// [`DetailKind::Temporal`] failures around a pattern's main
476    /// loop so positive-confirmation logging can gate on
477    /// "this pattern call added zero failures" (notes do not count
478    /// against the gate — a pattern that skipped projection errors
479    /// but otherwise passed every sample still emits the positive
480    /// log). Crate-internal mirror of [`Self::result_mut`].
481    pub(crate) fn result(&self) -> &AssertResult {
482        &self.result
483    }
484
485    /// Internal: record one claim outcome (pass = no detail, fail =
486    /// push detail with `kind` and conjoin `passed`). Shared by every
487    /// comparator on every builder type.
488    fn record(&mut self, outcome: ClaimOutcome) {
489        if let ClaimOutcome::Fail { kind, message } = outcome {
490            self.result.record_fail(AssertDetail::new(kind, message));
491        }
492    }
493
494    /// Record a passing claim as both a structured
495    /// [`PassDetail`](super::PassDetail) on `result.passes` AND a
496    /// `tracing::info!` event (gated on [`Self::log_passes`]).
497    ///
498    /// The structured push is unconditional: every comparator's pass
499    /// arm contributes one [`super::PassDetail`] to the result so the
500    /// auto-repro renderer (and any other consumer that wants per-
501    /// claim fidelity) can iterate passing assertions alongside
502    /// failing ones without re-running the comparators.
503    ///
504    /// The tracing event remains gated on `log_passes` so the
505    /// `--nocapture` operator surface stays clean by default; only
506    /// the structured push is universal.
507    fn record_pass_binary(
508        &mut self,
509        name: &str,
510        comparator: impl Into<std::borrow::Cow<'static, str>>,
511        value: impl std::fmt::Display,
512        expected: impl std::fmt::Display,
513    ) {
514        self.record_pass_inner(
515            name,
516            comparator.into(),
517            value.to_string(),
518            Some(expected.to_string()),
519        );
520    }
521
522    fn record_pass_unary(
523        &mut self,
524        name: &str,
525        comparator: impl Into<std::borrow::Cow<'static, str>>,
526        value: impl std::fmt::Display,
527    ) {
528        self.record_pass_inner(name, comparator.into(), value.to_string(), None);
529    }
530
531    fn record_pass_inner(
532        &mut self,
533        name: &str,
534        comparator: std::borrow::Cow<'static, str>,
535        value: String,
536        expected: Option<String>,
537    ) {
538        // Catch unregistered comparator tokens at debug-build runtime.
539        // Vocabulary count + membership guards in
540        // tests/claim_comparator_tokens_canonical.rs catch the
541        // forgot-to-update-the-vocab case at test-collection time;
542        // this debug_assert catches it inside any test that exercises
543        // the new comparator path (zero release-build cost).
544        debug_assert!(
545            super::COMPARATOR_VOCABULARY.contains(&comparator.as_ref())
546                || comparator == super::PASSES_TRUNCATION_SENTINEL_COMPARATOR,
547            "comparator token {comparator:?} not in COMPARATOR_VOCABULARY \
548             — add it to the const slice + regression test before shipping"
549        );
550        if self.log_passes {
551            match expected.as_ref() {
552                Some(exp) => tracing::info!(
553                    target: "ktstr::assert::claim",
554                    "{name}: {value} {comparator} {exp}"
555                ),
556                None => tracing::info!(
557                    target: "ktstr::assert::claim",
558                    "{name}: {value} {comparator}"
559                ),
560            }
561        }
562        // Cap + truncation sentinel: once `MAX_RECORDED_PASSES` real
563        // records have been stored, one sentinel record naming the
564        // dropped-count is appended (pushing the vec to
565        // `MAX_RECORDED_PASSES + 1`); further pushes become no-ops.
566        // The cap bounds the wire-formatted AssertResult so a
567        // pathological test firing millions of claims can't blow
568        // past `MAX_BULK_FRAME_PAYLOAD`. Sentinel pattern mirrors
569        // `SnapshotBridgeEvent::EventLogTruncated`.
570        let len = self.result.passes.len();
571        if len < super::MAX_RECORDED_PASSES {
572            let detail = match expected {
573                Some(exp) => super::PassDetail::binary(name, comparator, value, exp),
574                None => super::PassDetail::unary(name, comparator, value),
575            };
576            self.result.passes.push(detail);
577        } else if len == super::MAX_RECORDED_PASSES {
578            self.result.passes.push(super::PassDetail::unary(
579                super::PASSES_TRUNCATION_SENTINEL_NAME,
580                super::PASSES_TRUNCATION_SENTINEL_COMPARATOR,
581                format!("cap={}", super::MAX_RECORDED_PASSES),
582            ));
583        }
584        // len > MAX_RECORDED_PASSES: drop on the floor — sentinel is
585        // already published.
586    }
587}
588
589/// Outcome of a single comparator. `Pass` is the no-allocation arm;
590/// `Fail` carries the formatted failure message and its [`DetailKind`].
591enum ClaimOutcome {
592    Pass,
593    Fail { kind: DetailKind, message: String },
594}
595
596/// Per-claim builder for scalar values. Produced by [`Verdict::claim`]
597/// (and the typed `claim_<field>` accessors generated by
598/// [`#[derive(Claim)]`](ktstr_macros::Claim)). Chain a comparator
599/// (`at_least`, `at_most`, `lt`, `gt`, `between`, `eq`, `ne`,
600/// `is_finite`, `near`) to record the outcome.
601#[must_use = "ClaimBuilder records nothing until a comparator is invoked"]
602pub struct ClaimBuilder<'a, T> {
603    verdict: &'a mut Verdict,
604    name: &'static str,
605    value: T,
606    kind: DetailKind,
607    reason: Option<&'a str>,
608}
609
610impl<'a, T> ClaimBuilder<'a, T> {
611    /// Override the [`DetailKind`] used on failure. Defaults to
612    /// [`DetailKind::Other`]; tests with a structural category set it
613    /// here so downstream `kind`-based filters route the failure
614    /// correctly.
615    pub fn kind(mut self, kind: DetailKind) -> Self {
616        self.kind = kind;
617        self
618    }
619
620    /// Attach a human-readable rationale appended to the failure
621    /// message as `(reason)`. No effect on the pass path. Use to
622    /// record *why* the bound matters so a regression two months
623    /// later carries original intent in its diagnostic.
624    pub fn because(mut self, reason: &'a str) -> Self {
625        self.reason = Some(reason);
626        self
627    }
628}
629
630/// Append the optional `reason` to a failure message, returning the
631/// final formatted string. Centralized so every comparator's failure
632/// arm shares one definition of "( reason )" appendix formatting.
633fn append_reason(msg: String, reason: Option<&str>) -> String {
634    match reason {
635        Some(r) => format!("{msg} ({r})"),
636        None => msg,
637    }
638}
639
640impl<'a, T> ClaimBuilder<'a, T>
641where
642    T: PartialEq + std::fmt::Display,
643{
644    /// Pass when `value == expected`; fail with
645    /// `<name>: expected <expected>, was <value>` otherwise.
646    ///
647    /// **NaN gotcha** (only relevant for `T = f64`): IEEE 754 says
648    /// `NaN == NaN` is `false`. Use `is_finite` or
649    /// [`claim!`](crate::claim) on `value.is_nan()` instead.
650    pub fn eq(self, expected: T) -> &'a mut Verdict {
651        let ClaimBuilder {
652            verdict,
653            name,
654            value,
655            kind,
656            reason,
657        } = self;
658        let outcome = if value == expected {
659            verdict.record_pass_binary(name, "eq", value, expected);
660            ClaimOutcome::Pass
661        } else {
662            let msg = append_reason(format!("{name}: expected {expected}, was {value}"), reason);
663            ClaimOutcome::Fail { kind, message: msg }
664        };
665        verdict.record(outcome);
666        verdict
667    }
668
669    /// Pass when `value != forbidden`; fail with
670    /// `<name>: expected != <forbidden>, was <value>` otherwise.
671    pub fn ne(self, forbidden: T) -> &'a mut Verdict {
672        let ClaimBuilder {
673            verdict,
674            name,
675            value,
676            kind,
677            reason,
678        } = self;
679        let outcome = if value != forbidden {
680            verdict.record_pass_binary(name, "ne", value, forbidden);
681            ClaimOutcome::Pass
682        } else {
683            let msg = append_reason(
684                format!("{name}: expected != {forbidden}, was {value}"),
685                reason,
686            );
687            ClaimOutcome::Fail { kind, message: msg }
688        };
689        verdict.record(outcome);
690        verdict
691    }
692}
693
694impl<'a, T> ClaimBuilder<'a, T>
695where
696    T: PartialOrd + std::fmt::Display,
697{
698    /// Pass when `value >= floor`; fail with `<name>: expected at
699    /// least <floor>, was <value>` otherwise.
700    pub fn at_least(self, floor: T) -> &'a mut Verdict {
701        let ClaimBuilder {
702            verdict,
703            name,
704            value,
705            kind,
706            reason,
707        } = self;
708        let outcome = if value >= floor {
709            verdict.record_pass_binary(name, "ge", value, floor);
710            ClaimOutcome::Pass
711        } else {
712            let msg = append_reason(
713                format!("{name}: expected at least {floor}, was {value}"),
714                reason,
715            );
716            ClaimOutcome::Fail { kind, message: msg }
717        };
718        verdict.record(outcome);
719        verdict
720    }
721
722    /// Pass when `value <= ceiling`; fail with `<name>: expected at
723    /// most <ceiling>, was <value>` otherwise.
724    pub fn at_most(self, ceiling: T) -> &'a mut Verdict {
725        let ClaimBuilder {
726            verdict,
727            name,
728            value,
729            kind,
730            reason,
731        } = self;
732        let outcome = if value <= ceiling {
733            verdict.record_pass_binary(name, "le", value, ceiling);
734            ClaimOutcome::Pass
735        } else {
736            let msg = append_reason(
737                format!("{name}: expected at most {ceiling}, was {value}"),
738                reason,
739            );
740            ClaimOutcome::Fail { kind, message: msg }
741        };
742        verdict.record(outcome);
743        verdict
744    }
745
746    /// Pass when `value < ceiling`; fail otherwise.
747    pub fn lt(self, ceiling: T) -> &'a mut Verdict {
748        let ClaimBuilder {
749            verdict,
750            name,
751            value,
752            kind,
753            reason,
754        } = self;
755        let outcome = if value < ceiling {
756            verdict.record_pass_binary(name, "lt", value, ceiling);
757            ClaimOutcome::Pass
758        } else {
759            let msg = append_reason(
760                format!("{name}: expected less than {ceiling}, was {value}"),
761                reason,
762            );
763            ClaimOutcome::Fail { kind, message: msg }
764        };
765        verdict.record(outcome);
766        verdict
767    }
768
769    /// Pass when `value > floor`; fail otherwise.
770    pub fn gt(self, floor: T) -> &'a mut Verdict {
771        let ClaimBuilder {
772            verdict,
773            name,
774            value,
775            kind,
776            reason,
777        } = self;
778        let outcome = if value > floor {
779            verdict.record_pass_binary(name, "gt", value, floor);
780            ClaimOutcome::Pass
781        } else {
782            let msg = append_reason(
783                format!("{name}: expected greater than {floor}, was {value}"),
784                reason,
785            );
786            ClaimOutcome::Fail { kind, message: msg }
787        };
788        verdict.record(outcome);
789        verdict
790    }
791
792    /// Pass when `lo <= value <= hi` (inclusive on both ends). Fails
793    /// with caller-error message when `lo > hi`.
794    pub fn between(self, lo: T, hi: T) -> &'a mut Verdict {
795        let ClaimBuilder {
796            verdict,
797            name,
798            value,
799            kind,
800            reason,
801        } = self;
802        let outcome = if lo > hi {
803            let msg = append_reason(
804                format!("{name}: caller error: interval inverted (lo={lo} > hi={hi})"),
805                reason,
806            );
807            ClaimOutcome::Fail { kind, message: msg }
808        } else if value >= lo && value <= hi {
809            verdict.record_pass_binary(name, "in_range", value, format_args!("[{lo}, {hi}]"));
810            ClaimOutcome::Pass
811        } else {
812            let msg = append_reason(
813                format!("{name}: expected in [{lo}, {hi}], was {value}"),
814                reason,
815            );
816            ClaimOutcome::Fail { kind, message: msg }
817        };
818        verdict.record(outcome);
819        verdict
820    }
821}
822
823impl<'a> ClaimBuilder<'a, f64> {
824    /// Pass when the value is finite (neither NaN nor ±Infinity).
825    pub fn is_finite(self) -> &'a mut Verdict {
826        let ClaimBuilder {
827            verdict,
828            name,
829            value,
830            kind,
831            reason,
832        } = self;
833        let outcome = if value.is_finite() {
834            verdict.record_pass_unary(name, "is_finite", value);
835            ClaimOutcome::Pass
836        } else {
837            let msg = append_reason(format!("{name}: expected finite, was {value}"), reason);
838            ClaimOutcome::Fail { kind, message: msg }
839        };
840        verdict.record(outcome);
841        verdict
842    }
843
844    /// Pass when `(value - target).abs() <= tolerance`. `value ==
845    /// target` short-circuits as a pass before the subtraction so
846    /// `±inf == ±inf` reads as a match. Negative `tolerance` is a
847    /// caller bug — the failure message names it.
848    pub fn near(self, target: f64, tolerance: f64) -> &'a mut Verdict {
849        let ClaimBuilder {
850            verdict,
851            name,
852            value,
853            kind,
854            reason,
855        } = self;
856        let outcome = if tolerance < 0.0 {
857            let msg = append_reason(
858                format!("{name}: caller error: tolerance negative (={tolerance})"),
859                reason,
860            );
861            ClaimOutcome::Fail { kind, message: msg }
862        } else if value == target || (value - target).abs() <= tolerance {
863            verdict.record_pass_binary(
864                name,
865                "near_within",
866                value,
867                format_args!("{target} (±{tolerance})"),
868            );
869            ClaimOutcome::Pass
870        } else {
871            let msg = append_reason(
872                format!("{name}: expected near {target} (±{tolerance}), was {value}"),
873                reason,
874            );
875            ClaimOutcome::Fail { kind, message: msg }
876        };
877        verdict.record(outcome);
878        verdict
879    }
880}
881
882/// Per-claim builder for a possibly-absent (`Option`-valued) metric.
883/// Produced by [`Verdict::claim_present`] and the
884/// [`claim_present!`](crate::claim_present) macro. On `Some(v)` every
885/// comparator delegates to the value-bound [`ClaimBuilder`], so a
886/// present metric behaves EXACTLY like [`claim!`](crate::claim). On
887/// `None` the metric is absent — there is nothing to compare, so every
888/// comparator records a LOUD `Fail` (`<name>: metric absent`) rather
889/// than silently passing. This mirrors the no-guessed-value discipline
890/// of `MetricId::def` returning `None` for an unregistered key: an
891/// absent signal fails the gate, it does not vacuously satisfy it (the
892/// hazard of `metric.unwrap_or(0.0)`, where a missing metric becomes a
893/// sentinel that can pass a bound).
894#[must_use = "PresentClaim records nothing until a comparator is invoked"]
895pub struct PresentClaim<'a> {
896    verdict: &'a mut Verdict,
897    name: &'static str,
898    value: Option<f64>,
899    kind: DetailKind,
900    reason: Option<&'a str>,
901}
902
903impl<'a> PresentClaim<'a> {
904    /// Override the [`DetailKind`] recorded on failure (whether the
905    /// metric is absent OR present-but-out-of-bound). Mirrors
906    /// [`ClaimBuilder::kind`].
907    pub fn kind(mut self, kind: DetailKind) -> Self {
908        self.kind = kind;
909        self
910    }
911
912    /// Attach a rationale appended to the failure message as
913    /// `(reason)`. Mirrors [`ClaimBuilder::because`].
914    pub fn because(mut self, reason: &'a str) -> Self {
915        self.reason = Some(reason);
916        self
917    }
918
919    /// Record the absent-metric `Fail`. Shared by every comparator's
920    /// `None` arm.
921    fn record_absent(
922        verdict: &'a mut Verdict,
923        name: &str,
924        kind: DetailKind,
925        reason: Option<&str>,
926    ) -> &'a mut Verdict {
927        let msg = append_reason(
928            format!("{name}: metric absent (no value to compare)"),
929            reason,
930        );
931        verdict.record(ClaimOutcome::Fail { kind, message: msg });
932        verdict
933    }
934
935    /// Build the value-bound [`ClaimBuilder`] for the present case,
936    /// carrying the `kind` / `because` modifiers forward. Shared by
937    /// every comparator's `Some` arm so the present path is
938    /// behavior-identical to [`Verdict::claim`].
939    fn present(
940        verdict: &'a mut Verdict,
941        name: &'static str,
942        value: f64,
943        kind: DetailKind,
944        reason: Option<&'a str>,
945    ) -> ClaimBuilder<'a, f64> {
946        let mut cb = verdict.claim(name, value).kind(kind);
947        if let Some(r) = reason {
948            cb = cb.because(r);
949        }
950        cb
951    }
952
953    /// Present and `>= floor`; absent → `Fail`. See
954    /// [`ClaimBuilder::at_least`].
955    pub fn at_least(self, floor: f64) -> &'a mut Verdict {
956        let PresentClaim {
957            verdict,
958            name,
959            value,
960            kind,
961            reason,
962        } = self;
963        match value {
964            None => Self::record_absent(verdict, name, kind, reason),
965            Some(v) => Self::present(verdict, name, v, kind, reason).at_least(floor),
966        }
967    }
968
969    /// Present and `<= ceiling`; absent → `Fail`. See
970    /// [`ClaimBuilder::at_most`].
971    pub fn at_most(self, ceiling: f64) -> &'a mut Verdict {
972        let PresentClaim {
973            verdict,
974            name,
975            value,
976            kind,
977            reason,
978        } = self;
979        match value {
980            None => Self::record_absent(verdict, name, kind, reason),
981            Some(v) => Self::present(verdict, name, v, kind, reason).at_most(ceiling),
982        }
983    }
984
985    /// Present and `< ceiling`; absent → `Fail`. See [`ClaimBuilder::lt`].
986    pub fn lt(self, ceiling: f64) -> &'a mut Verdict {
987        let PresentClaim {
988            verdict,
989            name,
990            value,
991            kind,
992            reason,
993        } = self;
994        match value {
995            None => Self::record_absent(verdict, name, kind, reason),
996            Some(v) => Self::present(verdict, name, v, kind, reason).lt(ceiling),
997        }
998    }
999
1000    /// Present and `> floor`; absent → `Fail`. See [`ClaimBuilder::gt`].
1001    pub fn gt(self, floor: f64) -> &'a mut Verdict {
1002        let PresentClaim {
1003            verdict,
1004            name,
1005            value,
1006            kind,
1007            reason,
1008        } = self;
1009        match value {
1010            None => Self::record_absent(verdict, name, kind, reason),
1011            Some(v) => Self::present(verdict, name, v, kind, reason).gt(floor),
1012        }
1013    }
1014
1015    /// Present and within `[lo, hi]` (inclusive); absent → `Fail`. See
1016    /// [`ClaimBuilder::between`].
1017    pub fn between(self, lo: f64, hi: f64) -> &'a mut Verdict {
1018        let PresentClaim {
1019            verdict,
1020            name,
1021            value,
1022            kind,
1023            reason,
1024        } = self;
1025        match value {
1026            None => Self::record_absent(verdict, name, kind, reason),
1027            Some(v) => Self::present(verdict, name, v, kind, reason).between(lo, hi),
1028        }
1029    }
1030
1031    /// Present and within `tolerance` of `target`; absent → `Fail`. See
1032    /// [`ClaimBuilder::near`].
1033    pub fn near(self, target: f64, tolerance: f64) -> &'a mut Verdict {
1034        let PresentClaim {
1035            verdict,
1036            name,
1037            value,
1038            kind,
1039            reason,
1040        } = self;
1041        match value {
1042            None => Self::record_absent(verdict, name, kind, reason),
1043            Some(v) => Self::present(verdict, name, v, kind, reason).near(target, tolerance),
1044        }
1045    }
1046
1047    /// Present and finite (neither NaN nor ±∞); absent → `Fail`. See
1048    /// [`ClaimBuilder::is_finite`].
1049    pub fn is_finite(self) -> &'a mut Verdict {
1050        let PresentClaim {
1051            verdict,
1052            name,
1053            value,
1054            kind,
1055            reason,
1056        } = self;
1057        match value {
1058            None => Self::record_absent(verdict, name, kind, reason),
1059            Some(v) => Self::present(verdict, name, v, kind, reason).is_finite(),
1060        }
1061    }
1062}
1063
1064/// Per-claim builder for `BTreeSet<T>` values. Same `kind` / `because`
1065/// modifiers as [`ClaimBuilder`]; comparator surface is set-specific.
1066#[must_use = "SetClaim records nothing until a comparator is invoked"]
1067pub struct SetClaim<'a, T: Ord + std::fmt::Debug> {
1068    verdict: &'a mut Verdict,
1069    name: &'static str,
1070    value: &'a BTreeSet<T>,
1071    kind: DetailKind,
1072    reason: Option<&'a str>,
1073}
1074
1075impl<'a, T: Ord + std::fmt::Debug> SetClaim<'a, T> {
1076    pub fn kind(mut self, kind: DetailKind) -> Self {
1077        self.kind = kind;
1078        self
1079    }
1080    pub fn because(mut self, reason: &'a str) -> Self {
1081        self.reason = Some(reason);
1082        self
1083    }
1084
1085    /// Pass iff the set is empty.
1086    pub fn empty(self) -> &'a mut Verdict {
1087        let SetClaim {
1088            verdict,
1089            name,
1090            value,
1091            kind,
1092            reason,
1093        } = self;
1094        let outcome = if value.is_empty() {
1095            verdict.record_pass_unary(name, "set_is_empty", "");
1096            ClaimOutcome::Pass
1097        } else {
1098            let msg = append_reason(format!("{name}: expected empty, was {value:?}"), reason);
1099            ClaimOutcome::Fail { kind, message: msg }
1100        };
1101        verdict.record(outcome);
1102        verdict
1103    }
1104
1105    /// Pass iff the set is non-empty.
1106    pub fn nonempty(self) -> &'a mut Verdict {
1107        let SetClaim {
1108            verdict,
1109            name,
1110            value,
1111            kind,
1112            reason,
1113        } = self;
1114        let outcome = if !value.is_empty() {
1115            let len = value.len();
1116            verdict.record_pass_unary(name, "set_is_non_empty", len);
1117            ClaimOutcome::Pass
1118        } else {
1119            let msg = append_reason(format!("{name}: expected non-empty, was empty"), reason);
1120            ClaimOutcome::Fail { kind, message: msg }
1121        };
1122        verdict.record(outcome);
1123        verdict
1124    }
1125
1126    /// Pass iff the set contains `needle`.
1127    pub fn contains(self, needle: &T) -> &'a mut Verdict {
1128        let SetClaim {
1129            verdict,
1130            name,
1131            value,
1132            kind,
1133            reason,
1134        } = self;
1135        let outcome = if value.contains(needle) {
1136            // Pass-actual is the set's length — naming the haystack
1137            // size at pass time without dumping the full set into
1138            // PassDetail::value (pass records are bounded by
1139            // MAX_RECORDED_PASSES so we keep the payload compact, and
1140            // the analogous `set_len_eq` comparator already publishes
1141            // len-as-actual so consumers see a uniform shape across
1142            // the set_* family). Prior emission used the empty string
1143            // — that left consumers with no haystack signal at all,
1144            // forcing them to re-derive the set context from the
1145            // surrounding test setup.
1146            verdict.record_pass_binary(
1147                name,
1148                "set_contains",
1149                value.len(),
1150                format_args!("{needle:?}"),
1151            );
1152            ClaimOutcome::Pass
1153        } else {
1154            let msg = append_reason(
1155                format!("{name}: expected to contain {needle:?}, set was {value:?}"),
1156                reason,
1157            );
1158            ClaimOutcome::Fail { kind, message: msg }
1159        };
1160        verdict.record(outcome);
1161        verdict
1162    }
1163
1164    /// Pass iff `set.len() == n`.
1165    pub fn len_eq(self, n: usize) -> &'a mut Verdict {
1166        let SetClaim {
1167            verdict,
1168            name,
1169            value,
1170            kind,
1171            reason,
1172        } = self;
1173        let actual = value.len();
1174        let outcome = if actual == n {
1175            verdict.record_pass_binary(name, "set_len_eq", actual, n);
1176            ClaimOutcome::Pass
1177        } else {
1178            let msg = append_reason(format!("{name}: expected len == {n}, was {actual}"), reason);
1179            ClaimOutcome::Fail { kind, message: msg }
1180        };
1181        verdict.record(outcome);
1182        verdict
1183    }
1184
1185    /// Pass iff `set.len() <= n`.
1186    pub fn len_at_most(self, n: usize) -> &'a mut Verdict {
1187        let SetClaim {
1188            verdict,
1189            name,
1190            value,
1191            kind,
1192            reason,
1193        } = self;
1194        let actual = value.len();
1195        let outcome = if actual <= n {
1196            verdict.record_pass_binary(name, "set_len_le", actual, n);
1197            ClaimOutcome::Pass
1198        } else {
1199            let msg = append_reason(format!("{name}: expected len <= {n}, was {actual}"), reason);
1200            ClaimOutcome::Fail { kind, message: msg }
1201        };
1202        verdict.record(outcome);
1203        verdict
1204    }
1205
1206    /// Pass iff `set.len() >= n`.
1207    pub fn len_at_least(self, n: usize) -> &'a mut Verdict {
1208        let SetClaim {
1209            verdict,
1210            name,
1211            value,
1212            kind,
1213            reason,
1214        } = self;
1215        let actual = value.len();
1216        let outcome = if actual >= n {
1217            verdict.record_pass_binary(name, "set_len_ge", actual, n);
1218            ClaimOutcome::Pass
1219        } else {
1220            let msg = append_reason(format!("{name}: expected len >= {n}, was {actual}"), reason);
1221            ClaimOutcome::Fail { kind, message: msg }
1222        };
1223        verdict.record(outcome);
1224        verdict
1225    }
1226
1227    /// Pass iff every element of `value` is in `whitelist`. Fails with
1228    /// the offending elements listed.
1229    pub fn subset_of(self, whitelist: &BTreeSet<T>) -> &'a mut Verdict {
1230        let SetClaim {
1231            verdict,
1232            name,
1233            value,
1234            kind,
1235            reason,
1236        } = self;
1237        let bad: Vec<&T> = value.iter().filter(|x| !whitelist.contains(x)).collect();
1238        let outcome = if bad.is_empty() {
1239            verdict.record_pass_binary(
1240                name,
1241                "subset_of",
1242                format_args!("{value:?}"),
1243                format_args!("{whitelist:?}"),
1244            );
1245            ClaimOutcome::Pass
1246        } else {
1247            let msg = append_reason(
1248                format!("{name}: expected subset of {whitelist:?}, but {bad:?} are not in the set"),
1249                reason,
1250            );
1251            ClaimOutcome::Fail { kind, message: msg }
1252        };
1253        verdict.record(outcome);
1254        verdict
1255    }
1256
1257    /// Pass iff `value` shares no element with `forbidden`. Fails with
1258    /// the offending elements listed.
1259    pub fn disjoint_from(self, forbidden: &BTreeSet<T>) -> &'a mut Verdict {
1260        let SetClaim {
1261            verdict,
1262            name,
1263            value,
1264            kind,
1265            reason,
1266        } = self;
1267        let bad: Vec<&T> = value.iter().filter(|x| forbidden.contains(x)).collect();
1268        let outcome = if bad.is_empty() {
1269            verdict.record_pass_binary(
1270                name,
1271                "disjoint_from",
1272                format_args!("{value:?}"),
1273                format_args!("{forbidden:?}"),
1274            );
1275            ClaimOutcome::Pass
1276        } else {
1277            let msg = append_reason(
1278                format!(
1279                    "{name}: expected disjoint from {forbidden:?}, but {bad:?} are present in both"
1280                ),
1281                reason,
1282            );
1283            ClaimOutcome::Fail { kind, message: msg }
1284        };
1285        verdict.record(outcome);
1286        verdict
1287    }
1288}
1289
1290/// Per-claim builder for slice / `Vec<T>` values. Same modifier
1291/// surface as [`SetClaim`]; comparator surface is sequence-specific.
1292#[must_use = "SeqClaim records nothing until a comparator is invoked"]
1293pub struct SeqClaim<'a, T: std::fmt::Debug> {
1294    verdict: &'a mut Verdict,
1295    name: &'static str,
1296    value: &'a [T],
1297    kind: DetailKind,
1298    reason: Option<&'a str>,
1299}
1300
1301impl<'a, T: std::fmt::Debug> SeqClaim<'a, T> {
1302    pub fn kind(mut self, kind: DetailKind) -> Self {
1303        self.kind = kind;
1304        self
1305    }
1306    pub fn because(mut self, reason: &'a str) -> Self {
1307        self.reason = Some(reason);
1308        self
1309    }
1310
1311    /// Pass iff the sequence is empty.
1312    pub fn empty(self) -> &'a mut Verdict {
1313        let SeqClaim {
1314            verdict,
1315            name,
1316            value,
1317            kind,
1318            reason,
1319        } = self;
1320        let outcome = if value.is_empty() {
1321            verdict.record_pass_unary(name, "sequence_is_empty", "");
1322            ClaimOutcome::Pass
1323        } else {
1324            let msg = append_reason(format!("{name}: expected empty, was {value:?}"), reason);
1325            ClaimOutcome::Fail { kind, message: msg }
1326        };
1327        verdict.record(outcome);
1328        verdict
1329    }
1330
1331    /// Pass iff the sequence is non-empty.
1332    pub fn nonempty(self) -> &'a mut Verdict {
1333        let SeqClaim {
1334            verdict,
1335            name,
1336            value,
1337            kind,
1338            reason,
1339        } = self;
1340        let outcome = if !value.is_empty() {
1341            let len = value.len();
1342            verdict.record_pass_unary(name, "sequence_is_non_empty", len);
1343            ClaimOutcome::Pass
1344        } else {
1345            let msg = append_reason(format!("{name}: expected non-empty, was empty"), reason);
1346            ClaimOutcome::Fail { kind, message: msg }
1347        };
1348        verdict.record(outcome);
1349        verdict
1350    }
1351
1352    /// Pass iff the sequence contains an element equal to `needle`.
1353    pub fn contains(self, needle: &T) -> &'a mut Verdict
1354    where
1355        T: PartialEq,
1356    {
1357        let SeqClaim {
1358            verdict,
1359            name,
1360            value,
1361            kind,
1362            reason,
1363        } = self;
1364        let outcome = if value.iter().any(|x| x == needle) {
1365            // Pass-actual is the sequence length — mirrors the
1366            // `sequence_len_eq` comparator's len-as-actual emission
1367            // and the parallel `set_contains` rationale above
1368            // (compact, uniform, gives consumers a haystack signal).
1369            verdict.record_pass_binary(
1370                name,
1371                "sequence_contains",
1372                value.len(),
1373                format_args!("{needle:?}"),
1374            );
1375            ClaimOutcome::Pass
1376        } else {
1377            let msg = append_reason(
1378                format!("{name}: expected to contain {needle:?}, sequence was {value:?}"),
1379                reason,
1380            );
1381            ClaimOutcome::Fail { kind, message: msg }
1382        };
1383        verdict.record(outcome);
1384        verdict
1385    }
1386
1387    /// Pass iff `seq.len() == n`.
1388    pub fn len_eq(self, n: usize) -> &'a mut Verdict {
1389        let SeqClaim {
1390            verdict,
1391            name,
1392            value,
1393            kind,
1394            reason,
1395        } = self;
1396        let actual = value.len();
1397        let outcome = if actual == n {
1398            verdict.record_pass_binary(name, "sequence_len_eq", actual, n);
1399            ClaimOutcome::Pass
1400        } else {
1401            let msg = append_reason(format!("{name}: expected len == {n}, was {actual}"), reason);
1402            ClaimOutcome::Fail { kind, message: msg }
1403        };
1404        verdict.record(outcome);
1405        verdict
1406    }
1407
1408    /// Pass iff `seq.len() <= n`.
1409    pub fn len_at_most(self, n: usize) -> &'a mut Verdict {
1410        let SeqClaim {
1411            verdict,
1412            name,
1413            value,
1414            kind,
1415            reason,
1416        } = self;
1417        let actual = value.len();
1418        let outcome = if actual <= n {
1419            verdict.record_pass_binary(name, "sequence_len_le", actual, n);
1420            ClaimOutcome::Pass
1421        } else {
1422            let msg = append_reason(format!("{name}: expected len <= {n}, was {actual}"), reason);
1423            ClaimOutcome::Fail { kind, message: msg }
1424        };
1425        verdict.record(outcome);
1426        verdict
1427    }
1428
1429    /// Pass iff `seq.len() >= n`.
1430    pub fn len_at_least(self, n: usize) -> &'a mut Verdict {
1431        let SeqClaim {
1432            verdict,
1433            name,
1434            value,
1435            kind,
1436            reason,
1437        } = self;
1438        let actual = value.len();
1439        let outcome = if actual >= n {
1440            verdict.record_pass_binary(name, "sequence_len_ge", actual, n);
1441            ClaimOutcome::Pass
1442        } else {
1443            let msg = append_reason(format!("{name}: expected len >= {n}, was {actual}"), reason);
1444            ClaimOutcome::Fail { kind, message: msg }
1445        };
1446        verdict.record(outcome);
1447        verdict
1448    }
1449}
1450
1451/// Open a [`Verdict`] claim from a local binding or expression. The
1452/// label is `stringify!(<expr-tokens>)` so a regression that renames
1453/// the binding or alters the expression updates the rendered failure
1454/// message in lock-step.
1455///
1456/// Use as `claim!(verdict, my_local).at_most(N)` or
1457/// `claim!(verdict, end - start).at_least(N)`. For struct fields under
1458/// a `#[derive(Claim)]`, prefer the typed accessor
1459/// (`verdict.claim_<field>(s)`) — the macro is the escape hatch when
1460/// no derived accessor exists.
1461///
1462/// Expanded form: `verdict.claim(stringify!(expr), expr)`. The macro
1463/// is a thin wrapper so `Verdict::claim`'s `'static` label invariant
1464/// holds (string literals from `stringify!` always have `'static`
1465/// lifetime).
1466///
1467/// ```
1468/// # use ktstr::{assert::Verdict, claim};
1469/// let mut v = Verdict::new();
1470/// let answer = 42u64;
1471/// claim!(v, answer).at_least(40);
1472/// claim!(v, answer * 2).at_most(100);
1473/// let r = v.into_result();
1474/// assert!(r.is_pass());
1475/// ```
1476#[macro_export]
1477macro_rules! claim {
1478    ($verdict:expr, $value:expr) => {
1479        $verdict.claim(stringify!($value), $value)
1480    };
1481}
1482
1483/// Presence-checked sibling of [`claim!`] for a possibly-absent metric.
1484///
1485/// Expands to [`Verdict::claim_present`](crate::assert::Verdict::claim_present)
1486/// with a `stringify!`-derived label (the same `&'static str` discipline
1487/// as [`claim!`]). The argument is an `Option`-valued expression (e.g. a
1488/// `phase_metric` lookup); a `None` records a LOUD `Fail`
1489/// (`<expr>: metric absent`), a `Some(v)` behaves exactly like [`claim!`].
1490///
1491/// ```
1492/// # use ktstr::{assert::Verdict, claim_present};
1493/// let mut v = Verdict::new();
1494/// let throughput: Option<f64> = Some(1500.0);
1495/// claim_present!(v, throughput).at_least(1000.0);
1496/// let missing: Option<f64> = None;
1497/// claim_present!(v, missing).at_least(1.0); // records a Fail, not a panic
1498/// let r = v.into_result();
1499/// assert!(!r.is_pass()); // the absent metric failed the verdict
1500/// ```
1501#[macro_export]
1502macro_rules! claim_present {
1503    ($verdict:expr, $value:expr) => {
1504        $verdict.claim_present(stringify!($value), $value)
1505    };
1506}