use std::collections::BTreeSet;
use super::{Assert, AssertDetail, AssertResult, DetailKind, NoteValue, Outcome};
fn log_passes_default() -> bool {
match std::env::var("KTSTR_LOG_PASSES") {
Ok(v) => !(v.is_empty() || v == "0"),
Err(_) => false,
}
}
#[must_use = "Verdict accumulates claims; call into_result() to consume"]
#[derive(Debug, Clone)]
pub struct Verdict {
assert: Option<Assert>,
result: AssertResult,
log_passes: bool,
}
impl Default for Verdict {
fn default() -> Self {
Self::new()
}
}
impl Verdict {
pub fn new() -> Self {
Self {
assert: None,
result: AssertResult::pass(),
log_passes: log_passes_default(),
}
}
pub fn with_assert(assert: Assert) -> Self {
Self {
assert: Some(assert),
result: AssertResult::pass(),
log_passes: log_passes_default(),
}
}
pub fn with_log_passes(mut self, on: bool) -> Self {
self.log_passes = on;
self
}
pub fn log_passes(&self) -> bool {
self.log_passes
}
#[doc(hidden)]
pub fn claim<T>(&mut self, name: &'static str, value: T) -> ClaimBuilder<'_, T> {
ClaimBuilder {
verdict: self,
name,
value,
kind: DetailKind::Other,
reason: None,
}
}
#[doc(hidden)]
pub fn claim_set<'a, T: Ord + std::fmt::Debug>(
&'a mut self,
name: &'static str,
set: &'a BTreeSet<T>,
) -> SetClaim<'a, T> {
SetClaim {
verdict: self,
name,
value: set,
kind: DetailKind::Other,
reason: None,
}
}
#[doc(hidden)]
pub fn claim_seq<'a, T: std::fmt::Debug>(
&'a mut self,
name: &'static str,
seq: &'a [T],
) -> SeqClaim<'a, T> {
SeqClaim {
verdict: self,
name,
value: seq,
kind: DetailKind::Other,
reason: None,
}
}
pub fn note(&mut self, msg: impl Into<String>) -> &mut Self {
self.result.note(msg);
self
}
pub fn note_value(&mut self, key: impl Into<String>, value: impl Into<NoteValue>) -> &mut Self {
self.result.note_value(key, value);
self
}
pub fn skip(&mut self, reason: impl Into<String>) -> &mut Self {
self.result.record_skip(reason);
self
}
pub fn skip_if(&mut self, cond: bool, reason: impl Into<String>) -> &mut Self {
if cond {
self.skip(reason);
}
self
}
pub fn inconclusive(&mut self, detail: AssertDetail) -> &mut Self {
self.result.record_inconclusive(detail);
self
}
pub fn inconclusive_if(&mut self, cond: bool, detail: AssertDetail) -> &mut Self {
if cond {
self.inconclusive(detail);
}
self
}
pub fn merge(&mut self, other: AssertResult) -> &mut Self {
self.result.merge(other);
self
}
pub fn is_pass(&self) -> bool {
self.result.is_pass()
}
pub fn is_fail(&self) -> bool {
self.result.is_fail()
}
pub fn is_inconclusive(&self) -> bool {
self.result.is_inconclusive()
}
pub fn is_skip(&self) -> bool {
self.result.is_skip()
}
pub fn detail_count(&self) -> usize {
self.result
.outcomes
.iter()
.filter(|o| !matches!(o, Outcome::Pass))
.count()
}
pub fn assert(&self) -> Option<&Assert> {
self.assert.as_ref()
}
pub fn into_result(self) -> AssertResult {
self.result
}
pub fn into_anyhow_or_log(self) -> anyhow::Result<()> {
self.into_result().into_anyhow_or_log()
}
pub(crate) fn result_mut(&mut self) -> &mut AssertResult {
&mut self.result
}
pub(crate) fn result(&self) -> &AssertResult {
&self.result
}
fn record(&mut self, outcome: ClaimOutcome) {
if let ClaimOutcome::Fail { kind, message } = outcome {
self.result.record_fail(AssertDetail::new(kind, message));
}
}
fn record_pass_binary(
&mut self,
name: &str,
comparator: impl Into<std::borrow::Cow<'static, str>>,
value: impl std::fmt::Display,
expected: impl std::fmt::Display,
) {
self.record_pass_inner(
name,
comparator.into(),
value.to_string(),
Some(expected.to_string()),
);
}
fn record_pass_unary(
&mut self,
name: &str,
comparator: impl Into<std::borrow::Cow<'static, str>>,
value: impl std::fmt::Display,
) {
self.record_pass_inner(name, comparator.into(), value.to_string(), None);
}
fn record_pass_inner(
&mut self,
name: &str,
comparator: std::borrow::Cow<'static, str>,
value: String,
expected: Option<String>,
) {
debug_assert!(
super::COMPARATOR_VOCABULARY.contains(&comparator.as_ref())
|| comparator == super::PASSES_TRUNCATION_SENTINEL_COMPARATOR,
"comparator token {comparator:?} not in COMPARATOR_VOCABULARY \
— add it to the const slice + regression test before shipping"
);
if self.log_passes {
match expected.as_ref() {
Some(exp) => tracing::info!(
target: "ktstr::assert::claim",
"{name}: {value} {comparator} {exp}"
),
None => tracing::info!(
target: "ktstr::assert::claim",
"{name}: {value} {comparator}"
),
}
}
let len = self.result.passes.len();
if len < super::MAX_RECORDED_PASSES {
let detail = match expected {
Some(exp) => super::PassDetail::binary(name, comparator, value, exp),
None => super::PassDetail::unary(name, comparator, value),
};
self.result.passes.push(detail);
} else if len == super::MAX_RECORDED_PASSES {
self.result.passes.push(super::PassDetail::unary(
super::PASSES_TRUNCATION_SENTINEL_NAME,
super::PASSES_TRUNCATION_SENTINEL_COMPARATOR,
format!("cap={}", super::MAX_RECORDED_PASSES),
));
}
}
}
enum ClaimOutcome {
Pass,
Fail { kind: DetailKind, message: String },
}
#[must_use = "ClaimBuilder records nothing until a comparator is invoked"]
pub struct ClaimBuilder<'a, T> {
verdict: &'a mut Verdict,
name: &'static str,
value: T,
kind: DetailKind,
reason: Option<&'a str>,
}
impl<'a, T> ClaimBuilder<'a, T> {
pub fn kind(mut self, kind: DetailKind) -> Self {
self.kind = kind;
self
}
pub fn because(mut self, reason: &'a str) -> Self {
self.reason = Some(reason);
self
}
}
fn append_reason(msg: String, reason: Option<&str>) -> String {
match reason {
Some(r) => format!("{msg} ({r})"),
None => msg,
}
}
impl<'a, T> ClaimBuilder<'a, T>
where
T: PartialEq + std::fmt::Display,
{
pub fn eq(self, expected: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value == expected {
verdict.record_pass_binary(name, "eq", value, expected);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected {expected}, was {value}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn ne(self, forbidden: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value != forbidden {
verdict.record_pass_binary(name, "ne", value, forbidden);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected != {forbidden}, was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
}
impl<'a, T> ClaimBuilder<'a, T>
where
T: PartialOrd + std::fmt::Display,
{
pub fn at_least(self, floor: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value >= floor {
verdict.record_pass_binary(name, "ge", value, floor);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected at least {floor}, was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn at_most(self, ceiling: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value <= ceiling {
verdict.record_pass_binary(name, "le", value, ceiling);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected at most {ceiling}, was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn lt(self, ceiling: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value < ceiling {
verdict.record_pass_binary(name, "lt", value, ceiling);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected less than {ceiling}, was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn gt(self, floor: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value > floor {
verdict.record_pass_binary(name, "gt", value, floor);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected greater than {floor}, was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn between(self, lo: T, hi: T) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if lo > hi {
let msg = append_reason(
format!("{name}: caller error: interval inverted (lo={lo} > hi={hi})"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
} else if value >= lo && value <= hi {
verdict.record_pass_binary(name, "in_range", value, format_args!("[{lo}, {hi}]"));
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected in [{lo}, {hi}], was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
}
impl<'a> ClaimBuilder<'a, f64> {
pub fn is_finite(self) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value.is_finite() {
verdict.record_pass_unary(name, "is_finite", value);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected finite, was {value}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn near(self, target: f64, tolerance: f64) -> &'a mut Verdict {
let ClaimBuilder {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if tolerance < 0.0 {
let msg = append_reason(
format!("{name}: caller error: tolerance negative (={tolerance})"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
} else if value == target || (value - target).abs() <= tolerance {
verdict.record_pass_binary(
name,
"near_within",
value,
format_args!("{target} (±{tolerance})"),
);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected near {target} (±{tolerance}), was {value}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
}
#[must_use = "SetClaim records nothing until a comparator is invoked"]
pub struct SetClaim<'a, T: Ord + std::fmt::Debug> {
verdict: &'a mut Verdict,
name: &'static str,
value: &'a BTreeSet<T>,
kind: DetailKind,
reason: Option<&'a str>,
}
impl<'a, T: Ord + std::fmt::Debug> SetClaim<'a, T> {
pub fn kind(mut self, kind: DetailKind) -> Self {
self.kind = kind;
self
}
pub fn because(mut self, reason: &'a str) -> Self {
self.reason = Some(reason);
self
}
pub fn empty(self) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value.is_empty() {
verdict.record_pass_unary(name, "set_is_empty", "");
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected empty, was {value:?}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn nonempty(self) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if !value.is_empty() {
let len = value.len();
verdict.record_pass_unary(name, "set_is_non_empty", len);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected non-empty, was empty"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn contains(self, needle: &T) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value.contains(needle) {
verdict.record_pass_binary(
name,
"set_contains",
value.len(),
format_args!("{needle:?}"),
);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected to contain {needle:?}, set was {value:?}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn len_eq(self, n: usize) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let actual = value.len();
let outcome = if actual == n {
verdict.record_pass_binary(name, "set_len_eq", actual, n);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected len == {n}, was {actual}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn len_at_most(self, n: usize) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let actual = value.len();
let outcome = if actual <= n {
verdict.record_pass_binary(name, "set_len_le", actual, n);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected len <= {n}, was {actual}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn len_at_least(self, n: usize) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let actual = value.len();
let outcome = if actual >= n {
verdict.record_pass_binary(name, "set_len_ge", actual, n);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected len >= {n}, was {actual}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn subset_of(self, whitelist: &BTreeSet<T>) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let bad: Vec<&T> = value.iter().filter(|x| !whitelist.contains(x)).collect();
let outcome = if bad.is_empty() {
verdict.record_pass_binary(
name,
"subset_of",
format_args!("{value:?}"),
format_args!("{whitelist:?}"),
);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected subset of {whitelist:?}, but {bad:?} are not in the set"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn disjoint_from(self, forbidden: &BTreeSet<T>) -> &'a mut Verdict {
let SetClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let bad: Vec<&T> = value.iter().filter(|x| forbidden.contains(x)).collect();
let outcome = if bad.is_empty() {
verdict.record_pass_binary(
name,
"disjoint_from",
format_args!("{value:?}"),
format_args!("{forbidden:?}"),
);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!(
"{name}: expected disjoint from {forbidden:?}, but {bad:?} are present in both"
),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
}
#[must_use = "SeqClaim records nothing until a comparator is invoked"]
pub struct SeqClaim<'a, T: std::fmt::Debug> {
verdict: &'a mut Verdict,
name: &'static str,
value: &'a [T],
kind: DetailKind,
reason: Option<&'a str>,
}
impl<'a, T: std::fmt::Debug> SeqClaim<'a, T> {
pub fn kind(mut self, kind: DetailKind) -> Self {
self.kind = kind;
self
}
pub fn because(mut self, reason: &'a str) -> Self {
self.reason = Some(reason);
self
}
pub fn empty(self) -> &'a mut Verdict {
let SeqClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value.is_empty() {
verdict.record_pass_unary(name, "sequence_is_empty", "");
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected empty, was {value:?}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn nonempty(self) -> &'a mut Verdict {
let SeqClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if !value.is_empty() {
let len = value.len();
verdict.record_pass_unary(name, "sequence_is_non_empty", len);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected non-empty, was empty"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn contains(self, needle: &T) -> &'a mut Verdict
where
T: PartialEq,
{
let SeqClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let outcome = if value.iter().any(|x| x == needle) {
verdict.record_pass_binary(
name,
"sequence_contains",
value.len(),
format_args!("{needle:?}"),
);
ClaimOutcome::Pass
} else {
let msg = append_reason(
format!("{name}: expected to contain {needle:?}, sequence was {value:?}"),
reason,
);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn len_eq(self, n: usize) -> &'a mut Verdict {
let SeqClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let actual = value.len();
let outcome = if actual == n {
verdict.record_pass_binary(name, "sequence_len_eq", actual, n);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected len == {n}, was {actual}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn len_at_most(self, n: usize) -> &'a mut Verdict {
let SeqClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let actual = value.len();
let outcome = if actual <= n {
verdict.record_pass_binary(name, "sequence_len_le", actual, n);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected len <= {n}, was {actual}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
pub fn len_at_least(self, n: usize) -> &'a mut Verdict {
let SeqClaim {
verdict,
name,
value,
kind,
reason,
} = self;
let actual = value.len();
let outcome = if actual >= n {
verdict.record_pass_binary(name, "sequence_len_ge", actual, n);
ClaimOutcome::Pass
} else {
let msg = append_reason(format!("{name}: expected len >= {n}, was {actual}"), reason);
ClaimOutcome::Fail { kind, message: msg }
};
verdict.record(outcome);
verdict
}
}
#[macro_export]
macro_rules! claim {
($verdict:expr, $value:expr) => {
$verdict.claim(stringify!($value), $value)
};
}