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}