use std::{borrow::Cow, cmp::Ordering, collections::HashMap, error::Error, fmt};
use regex::Regex;
use serde_json::{json, Value};
use crate::{
parser::context,
schema::{
NumericValue, RegistryEntry, ScalarLiteral, ScalarValidationError,
ScalarValidationErrorKind, SchemaRegistry, SchemaValidationError,
SchemaValidationErrorKind,
},
};
use crate::request::response_capture::{
parse_capture_operand, resolve_capture_value_with_path_for_assertion, CaptureSource,
CaptureTarget, CaptureValue, ResolvedAssertionCaptureValue, ResponseSnapshot,
};
#[derive(Debug, Clone)]
pub struct Assertion {
pub raw: String,
label: Option<String>,
left: Operand,
comparison: Comparison,
schema_registry: SchemaRegistry,
message: Option<String>,
guard: Option<Guard>,
}
#[derive(Debug, Clone)]
pub struct Guard {
display: String,
left: Operand,
comparison: Comparison,
}
#[derive(Debug, Clone)]
enum Operand {
Variable(String),
Literal(LiteralValue),
Capture {
source: CaptureSource,
target: CaptureTarget,
raw_path: String,
},
}
#[derive(Debug, Clone)]
enum LiteralValue {
Null,
Bool(bool),
Number(String),
String(String),
Json(Value),
}
#[derive(Debug, Clone)]
enum Comparison {
Binary {
operator: BinaryOperator,
right: Operand,
},
Schema {
target: String,
},
Matches {
pattern: MatchPattern,
},
}
#[derive(Debug, Clone, Copy)]
enum BinaryOperator {
Eq,
Ne,
Lt,
Lte,
Gt,
Gte,
}
#[derive(Debug, Clone)]
enum MatchPattern {
Regex(Regex),
Contains(String),
Json(Value),
}
#[derive(Debug)]
pub struct AssertionError(
pub String,
pub Option<AssertionMismatch>,
pub Option<AssertionDiff>,
);
#[derive(Debug, Clone, PartialEq)]
pub struct AssertionMismatch {
pub kind: AssertionMismatchKind,
pub reason: AssertionMismatchReason,
pub target: Option<String>,
pub path: Option<String>,
pub actual_path: Option<String>,
pub compared_path: Option<String>,
pub operator: Option<String>,
pub actual: AssertionMismatchValue,
pub expected: AssertionMismatchValue,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AssertionMismatchValue {
pub value_type: String,
pub value: Value,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AssertionDiff {
pub format: AssertionDiffFormat,
pub path: Option<String>,
pub rendered: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionDiffFormat {
Unified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionMismatchKind {
Comparison,
Match,
Schema,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionMismatchReason {
ValueMismatch,
TypeMismatch,
UnexpectedEqual,
OrderingMismatch,
NonComparable,
RegexNoMatch,
SubstringNotFound,
InvalidActualType,
MissingField,
MissingRequiredField,
ArrayMemberNotFound,
ArrayElementNotFound,
EnumMismatch,
FormatMismatch,
LengthOutOfRange,
PatternMismatch,
RangeOutOfRange,
ArrayItemMismatch,
NullNotAllowed,
}
#[derive(Debug, Clone)]
struct ResolvedValue {
value: AssertionValue,
path: Option<String>,
}
#[derive(Debug, Clone)]
struct MatchFailure {
path: Option<String>,
reason: AssertionMismatchReason,
actual: AssertionMismatchValue,
expected: AssertionMismatchValue,
}
#[derive(Debug, Clone)]
enum AssertionValue {
Missing,
Json(Value),
RawText(String),
}
impl fmt::Display for AssertionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for AssertionError {}
impl AssertionError {
fn new(message: impl Into<String>) -> Self {
Self(message.into(), None, None)
}
fn with_mismatch_and_diff(
message: impl Into<String>,
mismatch: AssertionMismatch,
diff: Option<AssertionDiff>,
) -> Self {
Self(message.into(), Some(mismatch), diff)
}
}
impl AssertionMismatchKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Comparison => "comparison",
Self::Match => "match",
Self::Schema => "schema",
}
}
}
impl AssertionMismatchReason {
pub fn as_str(&self) -> &'static str {
match self {
Self::ValueMismatch => "value_mismatch",
Self::TypeMismatch => "type_mismatch",
Self::UnexpectedEqual => "unexpected_equal",
Self::OrderingMismatch => "ordering_mismatch",
Self::NonComparable => "non_comparable",
Self::RegexNoMatch => "regex_no_match",
Self::SubstringNotFound => "substring_not_found",
Self::InvalidActualType => "invalid_actual_type",
Self::MissingField => "missing_field",
Self::ArrayMemberNotFound => "array_member_not_found",
Self::ArrayElementNotFound => "array_element_not_found",
Self::MissingRequiredField => "missing_required_field",
Self::EnumMismatch => "enum_mismatch",
Self::FormatMismatch => "format_mismatch",
Self::LengthOutOfRange => "length_out_of_range",
Self::PatternMismatch => "pattern_mismatch",
Self::RangeOutOfRange => "range_out_of_range",
Self::ArrayItemMismatch => "array_item_mismatch",
Self::NullNotAllowed => "null_not_allowed",
}
}
}
impl AssertionDiffFormat {
pub fn as_str(&self) -> &'static str {
match self {
Self::Unified => "unified",
}
}
}
impl Assertion {
pub fn parse(raw: &str, context_map: &HashMap<String, String>) -> Result<Self, AssertionError> {
let schema_registry = SchemaRegistry::default();
Self::parse_with_registry(raw, context_map, &schema_registry)
}
pub fn parse_with_registry(
raw: &str,
context_map: &HashMap<String, String>,
schema_registry: &SchemaRegistry,
) -> Result<Self, AssertionError> {
let trimmed = raw.trim();
let (guard_source, remainder) = extract_guard_prefix(trimmed)?;
let remainder = remainder.trim_start();
let without_prefix = remainder
.strip_prefix('^')
.ok_or_else(|| AssertionError::new("assertion must begin with '^'"))?
.trim();
if without_prefix.is_empty() {
return Err(AssertionError::new("assertion expression is empty"));
}
let (expr_part, message_part) = split_message(without_prefix)?;
let expr_injected = context::inject_from_variable(expr_part, context_map);
let message = message_part.map(|m| {
let injected = context::inject_from_variable(m, context_map);
normalize_literal(&injected)
});
let (lhs, operator, rhs) = split_expression(expr_injected.as_str())?;
let left = Operand::parse(lhs)?;
let comparison = build_comparison(operator, rhs, schema_registry)?;
let guard = match guard_source {
Some(expr) => Some(Guard::parse(expr.as_str(), context_map)?),
None => None,
};
Ok(Self {
raw: trimmed.to_string(),
label: None,
left,
comparison,
schema_registry: schema_registry.clone(),
message,
guard,
})
}
pub fn evaluate(
&self,
env: &HashMap<String, String>,
current_snapshot: &ResponseSnapshot,
dependency_snapshots: &HashMap<String, ResponseSnapshot>,
) -> Result<(), AssertionError> {
let left_value = self
.left
.resolve(env, current_snapshot, dependency_snapshots)?;
match &self.comparison {
Comparison::Binary { operator, right } => {
let right_value = right.resolve(env, current_snapshot, dependency_snapshots)?;
if evaluate_binary(operator, &left_value, &right_value) {
Ok(())
} else {
let mismatch = build_binary_mismatch(*operator, &left_value, &right_value);
let diff = build_binary_diff(&left_value, &right_value, &mismatch);
let failure_message = self.message.clone().unwrap_or_else(|| {
format_binary_failure(&self.raw, &mismatch)
});
Err(AssertionError::with_mismatch_and_diff(
failure_message,
mismatch,
diff,
))
}
}
Comparison::Schema { target } => evaluate_schema_comparison(
&self.raw,
&self.schema_registry,
target,
&left_value,
self.message.as_deref(),
),
Comparison::Matches { pattern } => {
match pattern.evaluate(&left_value) {
Ok(()) => Ok(()),
Err(detail) => {
let mismatch = build_match_mismatch(&left_value, pattern, &detail);
let diff = build_match_diff(&left_value, pattern, &mismatch);
let failure_message = self.message.clone().unwrap_or_else(|| {
format_match_failure_with_detail(&self.raw, &left_value, pattern, &mismatch)
});
Err(AssertionError::with_mismatch_and_diff(
failure_message,
mismatch,
diff,
))
}
}
}
}
}
pub fn should_execute(
&self,
env: &HashMap<String, String>,
current_snapshot: &ResponseSnapshot,
dependency_snapshots: &HashMap<String, ResponseSnapshot>,
) -> Result<bool, AssertionError> {
match &self.guard {
Some(condition) => condition.evaluate(env, current_snapshot, dependency_snapshots),
None => Ok(true),
}
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub(crate) fn with_label(mut self, label: Option<String>) -> Self {
self.label = label.filter(|label| !label.trim().is_empty());
self
}
}
impl Guard {
pub fn parse(raw: &str, context_map: &HashMap<String, String>) -> Result<Self, AssertionError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AssertionError::new("guard predicate is empty"));
}
let injected = context::inject_from_variable(trimmed, context_map);
let (lhs, operator, rhs) = split_expression(injected.as_str())?;
if matches!(operator, OperatorToken::SchemaEq) {
return Err(AssertionError::new(
"schema validation operator is not supported in guards",
));
}
let normalized_rhs = normalize_guard_rhs(rhs);
let left = Operand::parse(lhs)?;
let schema_registry = SchemaRegistry::default();
let comparison = build_comparison(operator, normalized_rhs.as_ref(), &schema_registry)?;
Ok(Self {
display: injected,
left,
comparison,
})
}
pub fn evaluate(
&self,
env: &HashMap<String, String>,
current_snapshot: &ResponseSnapshot,
dependency_snapshots: &HashMap<String, ResponseSnapshot>,
) -> Result<bool, AssertionError> {
let label = format!("[{}]", self.display);
let left_value = self
.left
.resolve(env, current_snapshot, dependency_snapshots)
.map_err(|err| {
AssertionError::new(format!("Failed to evaluate guard {}: {}", label, err.0))
})?;
match &self.comparison {
Comparison::Binary { operator, right } => {
let right_value = right
.resolve(env, current_snapshot, dependency_snapshots)
.map_err(|err| {
AssertionError::new(format!("Failed to evaluate guard {}: {}", label, err.0))
})?;
Ok(evaluate_binary(operator, &left_value, &right_value))
}
Comparison::Schema { .. } => Err(AssertionError::new(format!(
"Failed to evaluate guard {}: schema validation operator is not supported in guards",
label
))),
Comparison::Matches { pattern } => Ok(pattern.evaluate(&left_value).is_ok()),
}
}
pub fn evaluate_with_env(&self, env: &HashMap<String, String>) -> Result<bool, AssertionError> {
let snapshot = ResponseSnapshot::default();
let deps = HashMap::new();
self.evaluate(env, &snapshot, &deps)
}
}
fn extract_guard_prefix(input: &str) -> Result<(Option<String>, &str), AssertionError> {
let trimmed = input.trim_start();
if !trimmed.starts_with('[') {
return Ok((None, input));
}
let bytes = trimmed.as_bytes();
let mut idx = 1;
let mut in_single = false;
let mut in_double = false;
while idx < bytes.len() {
let ch = bytes[idx] as char;
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
']' if !in_single && !in_double => {
let predicate = trimmed[1..idx].trim();
if predicate.is_empty() {
return Err(AssertionError::new("guard predicate is empty"));
}
let remainder = &trimmed[idx + 1..];
return Ok((Some(predicate.to_string()), remainder));
}
_ => {}
}
idx += 1;
}
Err(AssertionError::new("guard predicate is missing closing ']'") )
}
fn build_comparison(
operator: OperatorToken,
rhs: &str,
schema_registry: &SchemaRegistry,
) -> Result<Comparison, AssertionError> {
match operator {
OperatorToken::SchemaEq => {
let target = parse_schema_target(rhs, schema_registry)?;
Ok(Comparison::Schema { target })
}
OperatorToken::Matches => {
let pattern = MatchPattern::parse(rhs)?;
Ok(Comparison::Matches { pattern })
}
OperatorToken::Eq => Ok(Comparison::Binary {
operator: BinaryOperator::Eq,
right: Operand::parse(rhs)?,
}),
OperatorToken::Ne => Ok(Comparison::Binary {
operator: BinaryOperator::Ne,
right: Operand::parse(rhs)?,
}),
OperatorToken::Lt => Ok(Comparison::Binary {
operator: BinaryOperator::Lt,
right: Operand::parse(rhs)?,
}),
OperatorToken::Lte => Ok(Comparison::Binary {
operator: BinaryOperator::Lte,
right: Operand::parse(rhs)?,
}),
OperatorToken::Gt => Ok(Comparison::Binary {
operator: BinaryOperator::Gt,
right: Operand::parse(rhs)?,
}),
OperatorToken::Gte => Ok(Comparison::Binary {
operator: BinaryOperator::Gte,
right: Operand::parse(rhs)?,
}),
}
}
impl Operand {
fn parse(input: &str) -> Result<Self, AssertionError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssertionError::new("missing operand"));
}
if trimmed.starts_with('&') {
let (source, target, raw_path) =
parse_capture_operand(trimmed).map_err(|err| AssertionError::new(err.0))?;
return Ok(Operand::Capture {
source,
target,
raw_path,
});
}
if trimmed.starts_with('$') {
let key = trimmed.trim_start_matches('$').trim().to_string();
if key.is_empty() {
return Err(AssertionError::new("invalid variable reference"));
}
return Ok(Operand::Variable(key));
}
if is_null_literal(trimmed) {
return Ok(Operand::Literal(LiteralValue::Null));
}
if is_numeric_literal(trimmed) {
return Ok(Operand::Literal(LiteralValue::Number(trimmed.to_string())));
}
if is_boolean_literal(trimmed) {
return Ok(Operand::Literal(LiteralValue::Bool(trimmed == "true")));
}
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return Ok(Operand::Literal(LiteralValue::String(normalize_literal(trimmed))));
}
if let Some(value) = parse_json_literal(trimmed) {
return Ok(Operand::Literal(LiteralValue::Json(value)));
}
Ok(Operand::Variable(trimmed.to_string()))
}
fn resolve(
&self,
env: &HashMap<String, String>,
current_snapshot: &ResponseSnapshot,
dependency_snapshots: &HashMap<String, ResponseSnapshot>,
) -> Result<ResolvedValue, AssertionError> {
match self {
Operand::Variable(name) => env
.get(name)
.cloned()
.map(ResolvedValue::raw_text)
.ok_or_else(|| AssertionError::new(format!("No value found for variable '{}'.", name))),
Operand::Literal(value) => Ok(ResolvedValue::from_literal(value)),
Operand::Capture {
source,
target,
raw_path,
} => {
let snapshot = match source {
CaptureSource::Current => current_snapshot,
CaptureSource::Dependency(dep) => dependency_snapshots.get(dep).ok_or_else(|| {
AssertionError::new(format!(
"No response snapshot available for dependency '{}' referenced in assertion.",
dep
))
})?,
};
resolve_capture_value_with_path_for_assertion(target, raw_path, snapshot, env)
.map(|value| ResolvedValue::from_capture(source, raw_path, value))
.map_err(|err| AssertionError::new(err.0))
}
}
}
}
impl MatchPattern {
fn parse(input: &str) -> Result<Self, AssertionError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssertionError::new(
"matches operator requires a right-hand operand",
));
}
let literal = if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
normalize_literal(trimmed)
} else {
trimmed.to_string()
};
if literal.starts_with('/') && literal.ends_with('/') && literal.len() >= 2 {
let pattern = &literal[1..literal.len() - 1];
let regex = Regex::new(pattern)
.map_err(|e| AssertionError::new(format!("Invalid regex in assertion: {}", e)))?;
Ok(MatchPattern::Regex(regex))
} else if let Some(value) = parse_json_literal(trimmed) {
Ok(MatchPattern::Json(value))
} else {
Ok(MatchPattern::Contains(literal))
}
}
fn evaluate(&self, value: &ResolvedValue) -> Result<(), MatchFailure> {
match self {
MatchPattern::Regex(regex) => {
let Some(actual) = value.as_match_text() else {
return Err(match_failure(
value,
value.path.clone(),
AssertionMismatchReason::InvalidActualType,
self.mismatch_value(),
));
};
if regex.is_match(actual) {
Ok(())
} else {
Err(match_failure(
value,
value.path.clone(),
AssertionMismatchReason::RegexNoMatch,
self.mismatch_value(),
))
}
}
MatchPattern::Contains(expected) => {
let Some(actual) = value.as_match_text() else {
return Err(match_failure(
value,
value.path.clone(),
AssertionMismatchReason::InvalidActualType,
self.mismatch_value(),
));
};
if actual.contains(expected) {
Ok(())
} else {
Err(match_failure(
value,
value.path.clone(),
AssertionMismatchReason::SubstringNotFound,
self.mismatch_value(),
))
}
}
MatchPattern::Json(expected) => match_structural_value(&value.value, expected, value.path.as_deref()),
}
}
fn display(&self) -> String {
match self {
MatchPattern::Regex(regex) => format!("/{}/", regex.as_str()),
MatchPattern::Contains(expected) => render_string(expected),
MatchPattern::Json(value) => value.to_string(),
}
}
fn mismatch_value(&self) -> AssertionMismatchValue {
match self {
MatchPattern::Regex(regex) => AssertionMismatchValue {
value_type: "regex".to_string(),
value: Value::String(regex.as_str().to_string()),
},
MatchPattern::Contains(expected) => AssertionMismatchValue {
value_type: "substring".to_string(),
value: Value::String(expected.clone()),
},
MatchPattern::Json(value) => AssertionMismatchValue {
value_type: json_value_type_name(value).to_string(),
value: value.clone(),
},
}
}
}
#[derive(Debug, Clone, Copy)]
enum OperatorToken {
SchemaEq,
Eq,
Ne,
Lt,
Lte,
Gt,
Gte,
Matches,
}
fn split_expression(input: &str) -> Result<(&str, OperatorToken, &str), AssertionError> {
let bytes = input.as_bytes();
let mut in_single = false;
let mut in_double = false;
let mut bracket_depth = 0usize;
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i] as char;
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'[' if !in_single && !in_double => bracket_depth += 1,
']' if !in_single && !in_double && bracket_depth > 0 => bracket_depth -= 1,
_ => {}
}
if in_single || in_double || bracket_depth > 0 {
i += 1;
continue;
}
if i + 2 < bytes.len() && &input[i..i + 3] == "===" {
let left = input[..i].trim();
let right = input[i + 3..].trim();
if left.is_empty() || right.is_empty() {
return Err(AssertionError::new("invalid assertion expression"));
}
return Ok((left, OperatorToken::SchemaEq, right));
}
if i + 1 < bytes.len() {
let two = &input[i..i + 2];
let op = match two {
"==" => Some(OperatorToken::Eq),
"!=" => Some(OperatorToken::Ne),
">=" => Some(OperatorToken::Gte),
"<=" => Some(OperatorToken::Lte),
"~=" => Some(OperatorToken::Matches),
_ => None,
};
if let Some(token) = op {
let left = input[..i].trim();
let right = input[i + 2..].trim();
if left.is_empty() || right.is_empty() {
return Err(AssertionError::new("invalid assertion expression"));
}
return Ok((left, token, right));
}
}
let op = match ch {
'>' => Some(OperatorToken::Gt),
'<' => Some(OperatorToken::Lt),
_ => None,
};
if let Some(token) = op {
let left = input[..i].trim();
let right = input[i + 1..].trim();
if left.is_empty() || right.is_empty() {
return Err(AssertionError::new("invalid assertion expression"));
}
return Ok((left, token, right));
}
i += 1;
}
Err(AssertionError::new("No valid operator found in assertion"))
}
fn split_message(input: &str) -> Result<(&str, Option<&str>), AssertionError> {
let bytes = input.as_bytes();
let mut in_single = false;
let mut in_double = false;
let mut bracket_depth = 0usize;
let mut i = 0;
while i + 1 < bytes.len() {
let ch = bytes[i] as char;
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'[' if !in_single && !in_double => bracket_depth += 1,
']' if !in_single && !in_double && bracket_depth > 0 => bracket_depth -= 1,
_ => {}
}
if !in_single
&& !in_double
&& bracket_depth == 0
&& ch == '|'
&& bytes[i + 1] as char == '|'
{
let expr = input[..i].trim();
let message = input[i + 2..].trim();
if expr.is_empty() {
return Err(AssertionError::new("assertion expression is empty"));
}
if message.is_empty() {
return Err(AssertionError::new("assertion message is empty"));
}
return Ok((expr, Some(message)));
}
i += 1;
}
Ok((input, None))
}
fn normalize_literal(input: &str) -> String {
let trimmed = input.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
trimmed[1..trimmed.len() - 1].to_string()
} else {
trimmed.to_string()
}
}
fn parse_schema_target(
input: &str,
schema_registry: &SchemaRegistry,
) -> Result<String, AssertionError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssertionError::new(
"schema validation operator requires a target name",
));
}
if !is_identifier(trimmed) {
return Err(AssertionError::new(
"schema validation target must be a declared scalar or schema name",
));
}
if schema_registry.get(trimmed).is_none() {
return Err(AssertionError::new(format!(
"Unknown schema validation target '{}'",
trimmed
)));
}
Ok(trimmed.to_string())
}
fn is_identifier(input: &str) -> bool {
let mut chars = input.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_ascii_alphabetic() || first == '_') {
return false;
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
fn is_numeric_literal(input: &str) -> bool {
input.trim().parse::<f64>().is_ok()
}
fn is_boolean_literal(input: &str) -> bool {
matches!(input.trim(), "true" | "false")
}
fn is_null_literal(input: &str) -> bool {
input.trim() == "null"
}
fn parse_json_literal(input: &str) -> Option<Value> {
let trimmed = input.trim();
if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
return None;
}
serde_json::from_str::<Value>(trimmed).ok()
}
fn normalize_guard_rhs(input: &str) -> Cow<'_, str> {
let trimmed = input.trim();
if trimmed.is_empty()
|| trimmed.starts_with('$')
|| trimmed.starts_with('&')
|| trimmed.starts_with('{')
|| trimmed.starts_with('[')
|| trimmed.starts_with('"')
|| trimmed.starts_with('\'')
|| trimmed.starts_with('/')
|| is_boolean_literal(trimmed)
|| is_null_literal(trimmed)
|| is_numeric_literal(trimmed)
|| trimmed.chars().any(|ch| ch.is_ascii_uppercase())
{
Cow::Borrowed(trimmed)
} else {
Cow::Owned(format!("'{}'", trimmed))
}
}
impl BinaryOperator {
fn as_str(&self) -> &'static str {
match self {
Self::Eq => "==",
Self::Ne => "!=",
Self::Lt => "<",
Self::Lte => "<=",
Self::Gt => ">",
Self::Gte => ">=",
}
}
}
impl ResolvedValue {
fn raw_text(value: String) -> Self {
Self {
value: AssertionValue::RawText(value),
path: None,
}
}
fn from_literal(value: &LiteralValue) -> Self {
let value = match value {
LiteralValue::Null => AssertionValue::Json(Value::Null),
LiteralValue::Bool(boolean) => AssertionValue::Json(Value::Bool(*boolean)),
LiteralValue::Number(number) => AssertionValue::Json(Value::from(
number.parse::<f64>().expect("validated numeric literal"),
)),
LiteralValue::String(text) => AssertionValue::Json(Value::String(text.clone())),
LiteralValue::Json(value) => AssertionValue::Json(value.clone()),
};
Self { value, path: None }
}
fn from_capture(
source: &CaptureSource,
raw_path: &str,
resolved: ResolvedAssertionCaptureValue,
) -> Self {
let resolved_path = resolved.resolved_path.unwrap_or_else(|| raw_path.to_string());
let path = match source {
CaptureSource::Current => resolved_path,
CaptureSource::Dependency(name) => format!("[{}].{}", name, resolved_path),
};
let value = match resolved.value {
CaptureValue::Missing => AssertionValue::Missing,
CaptureValue::Json(value) => AssertionValue::Json(value),
CaptureValue::RawText(value) => AssertionValue::RawText(value),
};
Self {
value,
path: Some(path),
}
}
fn type_name(&self) -> &'static str {
match &self.value {
AssertionValue::Missing => "missing",
AssertionValue::RawText(_) => "string",
AssertionValue::Json(Value::Null) => "null",
AssertionValue::Json(Value::Bool(_)) => "boolean",
AssertionValue::Json(Value::Number(_)) => "number",
AssertionValue::Json(Value::String(_)) => "string",
AssertionValue::Json(Value::Array(_)) => "array",
AssertionValue::Json(Value::Object(_)) => "object",
}
}
fn as_match_text(&self) -> Option<&str> {
match &self.value {
AssertionValue::Missing => None,
AssertionValue::RawText(text) => Some(text.as_str()),
AssertionValue::Json(Value::String(text)) => Some(text.as_str()),
_ => None,
}
}
fn match_display(&self) -> String {
match &self.value {
AssertionValue::Missing => "<missing>".to_string(),
AssertionValue::RawText(text) => render_string(text),
AssertionValue::Json(Value::String(text)) => render_string(text),
AssertionValue::Json(value) => value.to_string(),
}
}
fn mismatch_value(&self) -> AssertionMismatchValue {
mismatch_value_from_assertion_value(&self.value)
}
}
fn render_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| format!("\"{}\"", value))
}
fn values_equal(left: &AssertionValue, right: &AssertionValue) -> bool {
match (left, right) {
(AssertionValue::Missing, AssertionValue::Missing) => true,
(AssertionValue::Json(lhs), AssertionValue::Json(rhs)) => json_values_equal(lhs, rhs),
(AssertionValue::RawText(lhs), AssertionValue::RawText(rhs)) => lhs == rhs,
(AssertionValue::RawText(text), AssertionValue::Json(json))
| (AssertionValue::Json(json), AssertionValue::RawText(text)) => {
raw_text_equals_json(text, json)
}
_ => false,
}
}
fn json_values_equal(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Number(lhs), Value::Number(rhs)) => compare_json_numbers(lhs, rhs)
.map(|ordering| ordering == Ordering::Equal)
.unwrap_or(false),
_ => left == right,
}
}
fn raw_text_equals_json(text: &str, json: &Value) -> bool {
match json {
Value::String(expected) => text == expected,
Value::Bool(expected) => parse_bool(text) == Some(*expected),
Value::Number(expected) => compare_text_to_number(text, expected)
.map(|ordering| ordering == Ordering::Equal)
.unwrap_or(false),
_ => false,
}
}
fn compare_json_numbers(left: &serde_json::Number, right: &serde_json::Number) -> Option<Ordering> {
left.as_f64()?.partial_cmp(&right.as_f64()?)
}
fn compare_text_to_number(text: &str, number: &serde_json::Number) -> Option<Ordering> {
text.trim().parse::<f64>().ok()?.partial_cmp(&number.as_f64()?)
}
fn compare_values(left: &AssertionValue, right: &AssertionValue) -> Option<Ordering> {
match (left, right) {
(AssertionValue::Json(Value::Number(lhs)), AssertionValue::Json(Value::Number(rhs))) => {
compare_json_numbers(lhs, rhs)
}
(AssertionValue::RawText(lhs), AssertionValue::RawText(rhs)) => Some(lhs.cmp(rhs)),
(AssertionValue::Json(Value::String(lhs)), AssertionValue::Json(Value::String(rhs))) => {
Some(lhs.cmp(rhs))
}
(AssertionValue::RawText(text), AssertionValue::Json(Value::Number(number))) => {
compare_text_to_number(text, number)
}
(AssertionValue::Json(Value::Number(number)), AssertionValue::RawText(text)) => {
compare_text_to_number(text, number).map(Ordering::reverse)
}
(AssertionValue::RawText(lhs), AssertionValue::Json(Value::String(rhs))) => {
Some(lhs.as_str().cmp(rhs.as_str()))
}
(AssertionValue::Json(Value::String(lhs)), AssertionValue::RawText(rhs)) => {
Some(lhs.as_str().cmp(rhs.as_str()))
}
_ => None,
}
}
fn join_object_path(base: Option<&str>, key: &str) -> String {
match base {
Some(base) if !base.is_empty() => format!("{}.{}", base, key),
_ => key.to_string(),
}
}
fn join_array_path(base: Option<&str>, index: usize) -> String {
match base {
Some(base) if !base.is_empty() => format!("{}[{}]", base, index),
_ => format!("[{}]", index),
}
}
fn match_failure(
actual: &ResolvedValue,
path: Option<String>,
reason: AssertionMismatchReason,
expected: AssertionMismatchValue,
) -> MatchFailure {
MatchFailure {
path,
reason,
actual: actual.mismatch_value(),
expected,
}
}
fn match_failure_from_value(
actual: &AssertionValue,
path: Option<String>,
expected: &Value,
) -> MatchFailure {
MatchFailure {
path,
reason: mismatch_reason_for_values(actual, &AssertionValue::Json(expected.clone())),
actual: mismatch_value_from_assertion_value(actual),
expected: AssertionMismatchValue {
value_type: json_value_type_name(expected).to_string(),
value: expected.clone(),
},
}
}
fn assertion_value_type_name(value: &AssertionValue) -> &'static str {
match value {
AssertionValue::Missing => "missing",
AssertionValue::RawText(_) => "string",
AssertionValue::Json(Value::Null) => "null",
AssertionValue::Json(Value::Bool(_)) => "boolean",
AssertionValue::Json(Value::Number(_)) => "number",
AssertionValue::Json(Value::String(_)) => "string",
AssertionValue::Json(Value::Array(_)) => "array",
AssertionValue::Json(Value::Object(_)) => "object",
}
}
fn json_value_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn mismatch_value_from_assertion_value(value: &AssertionValue) -> AssertionMismatchValue {
match value {
AssertionValue::Missing => AssertionMismatchValue {
value_type: "missing".to_string(),
value: Value::Null,
},
AssertionValue::RawText(text) => AssertionMismatchValue {
value_type: "string".to_string(),
value: Value::String(text.clone()),
},
AssertionValue::Json(value) => AssertionMismatchValue {
value_type: json_value_type_name(value).to_string(),
value: value.clone(),
},
}
}
fn mismatch_reason_for_values(
actual: &AssertionValue,
expected: &AssertionValue,
) -> AssertionMismatchReason {
match actual {
AssertionValue::Missing => AssertionMismatchReason::MissingField,
_ if assertion_value_type_name(actual) != assertion_value_type_name(expected) => {
AssertionMismatchReason::TypeMismatch
}
_ => AssertionMismatchReason::ValueMismatch,
}
}
fn build_binary_mismatch(
operator: BinaryOperator,
left: &ResolvedValue,
right: &ResolvedValue,
) -> AssertionMismatch {
let reason = match operator {
BinaryOperator::Eq => mismatch_reason_for_values(&left.value, &right.value),
BinaryOperator::Ne => AssertionMismatchReason::UnexpectedEqual,
BinaryOperator::Lt | BinaryOperator::Lte | BinaryOperator::Gt | BinaryOperator::Gte => {
if compare_values(&left.value, &right.value).is_none() {
AssertionMismatchReason::NonComparable
} else {
AssertionMismatchReason::OrderingMismatch
}
}
};
AssertionMismatch {
kind: AssertionMismatchKind::Comparison,
reason,
target: None,
path: left.path.clone(),
actual_path: left.path.clone(),
compared_path: right.path.clone(),
operator: Some(operator.as_str().to_string()),
actual: left.mismatch_value(),
expected: right.mismatch_value(),
}
}
fn build_match_mismatch(
left: &ResolvedValue,
pattern: &MatchPattern,
failure: &MatchFailure,
) -> AssertionMismatch {
AssertionMismatch {
kind: AssertionMismatchKind::Match,
reason: failure.reason,
target: None,
path: failure.path.clone().or_else(|| left.path.clone()),
actual_path: left.path.clone(),
compared_path: None,
operator: Some("~=".to_string()),
actual: failure.actual.clone(),
expected: if matches!(pattern, MatchPattern::Json(_)) {
failure.expected.clone()
} else {
pattern.mismatch_value()
},
}
}
fn evaluate_schema_comparison(
raw: &str,
schema_registry: &SchemaRegistry,
target: &str,
left: &ResolvedValue,
custom_message: Option<&str>,
) -> Result<(), AssertionError> {
let AssertionValue::Json(value) = &left.value else {
let mismatch = build_schema_invalid_actual_mismatch(left, target);
let diff = build_schema_diff(left, &mismatch);
let failure_message = custom_message
.map(str::to_string)
.unwrap_or_else(|| {
format_schema_failure(
raw,
left,
target,
&mismatch,
"schema validation requires a typed JSON value on the left-hand side",
)
});
return Err(AssertionError::with_mismatch_and_diff(
failure_message,
mismatch,
diff,
));
};
let result = match schema_registry.get(target) {
Some(RegistryEntry::BuiltinScalar(_) | RegistryEntry::Scalar(_)) => schema_registry
.validate_scalar_target(target, value)
.map_err(SchemaAssertionFailure::Scalar),
Some(RegistryEntry::Schema(_)) => schema_registry
.validate_schema_target(target, value)
.map_err(SchemaAssertionFailure::Schema),
None => {
return Err(AssertionError::new(format!(
"Unknown schema validation target '{}'",
target
)))
}
};
match result {
Ok(()) => Ok(()),
Err(failure) => {
let mismatch = build_schema_mismatch(left, target, &failure);
let diff = build_schema_diff(left, &mismatch);
let failure_message = custom_message
.map(str::to_string)
.unwrap_or_else(|| format_schema_failure(raw, left, target, &mismatch, failure.detail()));
Err(AssertionError::with_mismatch_and_diff(
failure_message,
mismatch,
diff,
))
}
}
}
#[derive(Debug)]
enum SchemaAssertionFailure {
Scalar(ScalarValidationError),
Schema(SchemaValidationError),
}
impl SchemaAssertionFailure {
fn detail(&self) -> String {
match self {
Self::Scalar(error) => error.to_string(),
Self::Schema(error) => error.to_string(),
}
}
}
fn build_schema_invalid_actual_mismatch(
left: &ResolvedValue,
target: &str,
) -> AssertionMismatch {
AssertionMismatch {
kind: AssertionMismatchKind::Schema,
reason: AssertionMismatchReason::InvalidActualType,
target: Some(target.to_string()),
path: left.path.clone(),
actual_path: left.path.clone(),
compared_path: None,
operator: Some("===".to_string()),
actual: left.mismatch_value(),
expected: assertion_mismatch_value("typed_json", Value::String(target.to_string())),
}
}
fn build_schema_mismatch(
left: &ResolvedValue,
target: &str,
failure: &SchemaAssertionFailure,
) -> AssertionMismatch {
match failure {
SchemaAssertionFailure::Scalar(error) => {
let reason = mismatch_reason_for_scalar_validation(error.actual_type.as_str(), &error.reason);
let reason = apply_array_item_context(left.path.as_deref(), reason);
AssertionMismatch {
kind: AssertionMismatchKind::Schema,
reason,
target: Some(target.to_string()),
path: left.path.clone(),
actual_path: left.path.clone(),
compared_path: None,
operator: Some("===".to_string()),
actual: AssertionMismatchValue {
value_type: error.actual_type.clone(),
value: error.actual.clone(),
},
expected: expected_value_for_scalar_validation(&error.reason),
}
}
SchemaAssertionFailure::Schema(error) => {
let path = compose_schema_path(left.path.as_deref(), error.path.as_str());
let reason = mismatch_reason_for_schema_validation(error);
let reason = apply_array_item_context(path.as_deref(), reason);
AssertionMismatch {
kind: AssertionMismatchKind::Schema,
reason,
target: Some(target.to_string()),
path,
actual_path: left.path.clone(),
compared_path: None,
operator: Some("===".to_string()),
actual: AssertionMismatchValue {
value_type: error.actual_type.clone(),
value: error.actual.clone(),
},
expected: expected_value_for_schema_validation(&error.reason),
}
}
}
}
fn mismatch_reason_for_scalar_validation(
actual_type: &str,
reason: &ScalarValidationErrorKind,
) -> AssertionMismatchReason {
match reason {
ScalarValidationErrorKind::TypeMismatch { .. } if actual_type == "null" => {
AssertionMismatchReason::NullNotAllowed
}
ScalarValidationErrorKind::TypeMismatch { .. } => AssertionMismatchReason::TypeMismatch,
ScalarValidationErrorKind::EnumMismatch { .. } => AssertionMismatchReason::EnumMismatch,
ScalarValidationErrorKind::FormatMismatch { .. }
| ScalarValidationErrorKind::UnknownFormat(_) => AssertionMismatchReason::FormatMismatch,
ScalarValidationErrorKind::LengthOutOfRange { .. } => {
AssertionMismatchReason::LengthOutOfRange
}
ScalarValidationErrorKind::PatternMismatch { .. }
| ScalarValidationErrorKind::InvalidPattern { .. } => AssertionMismatchReason::PatternMismatch,
ScalarValidationErrorKind::RangeOutOfRange { .. } => AssertionMismatchReason::RangeOutOfRange,
ScalarValidationErrorKind::UnknownTarget(_) | ScalarValidationErrorKind::TargetIsSchema(_) => {
AssertionMismatchReason::ValueMismatch
}
}
}
fn mismatch_reason_for_schema_validation(
error: &SchemaValidationError,
) -> AssertionMismatchReason {
match &error.reason {
SchemaValidationErrorKind::MissingRequiredField { .. } => {
AssertionMismatchReason::MissingRequiredField
}
SchemaValidationErrorKind::TypeMismatch { .. } if error.actual_type == "null" => {
AssertionMismatchReason::NullNotAllowed
}
SchemaValidationErrorKind::TypeMismatch { .. } => AssertionMismatchReason::TypeMismatch,
SchemaValidationErrorKind::ScalarViolation { reason, .. } => {
mismatch_reason_for_scalar_validation(error.actual_type.as_str(), reason)
}
SchemaValidationErrorKind::OneOfNoMatch { .. }
| SchemaValidationErrorKind::OneOfMultipleMatches { .. }
| SchemaValidationErrorKind::AnyOfNoMatch { .. }
| SchemaValidationErrorKind::NotMatched { .. }
| SchemaValidationErrorKind::DiscriminatorUnknownTag { .. } => {
AssertionMismatchReason::ValueMismatch
}
SchemaValidationErrorKind::UnknownTarget(_) | SchemaValidationErrorKind::TargetIsScalar(_) => {
AssertionMismatchReason::ValueMismatch
}
}
}
fn apply_array_item_context(
path: Option<&str>,
reason: AssertionMismatchReason,
) -> AssertionMismatchReason {
if path.is_some_and(|value| value.contains('['))
&& matches!(
reason,
AssertionMismatchReason::TypeMismatch
| AssertionMismatchReason::EnumMismatch
| AssertionMismatchReason::FormatMismatch
| AssertionMismatchReason::LengthOutOfRange
| AssertionMismatchReason::PatternMismatch
| AssertionMismatchReason::RangeOutOfRange
| AssertionMismatchReason::NullNotAllowed
)
{
AssertionMismatchReason::ArrayItemMismatch
} else {
reason
}
}
fn expected_value_for_scalar_validation(
reason: &ScalarValidationErrorKind,
) -> AssertionMismatchValue {
match reason {
ScalarValidationErrorKind::UnknownTarget(target) => {
assertion_mismatch_value("scalar_target", Value::String(target.clone()))
}
ScalarValidationErrorKind::TargetIsSchema(target) => {
assertion_mismatch_value("schema_target", Value::String(target.clone()))
}
ScalarValidationErrorKind::TypeMismatch { expected } => {
assertion_mismatch_value("expected_type", Value::String(expected.clone()))
}
ScalarValidationErrorKind::EnumMismatch { expected } => assertion_mismatch_value(
"enum",
Value::Array(expected.iter().map(scalar_literal_to_json).collect()),
),
ScalarValidationErrorKind::FormatMismatch { format }
| ScalarValidationErrorKind::UnknownFormat(format) => {
assertion_mismatch_value("format", Value::String(format.clone()))
}
ScalarValidationErrorKind::LengthOutOfRange { min, max, .. } => assertion_mismatch_value(
"length_range",
json!({ "min": min, "max": max }),
),
ScalarValidationErrorKind::PatternMismatch { pattern } => {
assertion_mismatch_value("pattern", Value::String(pattern.clone()))
}
ScalarValidationErrorKind::InvalidPattern { pattern, message } => assertion_mismatch_value(
"pattern",
json!({ "pattern": pattern, "message": message }),
),
ScalarValidationErrorKind::RangeOutOfRange { min, max, .. } => assertion_mismatch_value(
"numeric_range",
json!({
"min": min.as_ref().map(numeric_value_to_json),
"max": max.as_ref().map(numeric_value_to_json),
}),
),
}
}
fn expected_value_for_schema_validation(
reason: &SchemaValidationErrorKind,
) -> AssertionMismatchValue {
match reason {
SchemaValidationErrorKind::UnknownTarget(target) => {
assertion_mismatch_value("schema_target", Value::String(target.clone()))
}
SchemaValidationErrorKind::TargetIsScalar(target) => {
assertion_mismatch_value("scalar_target", Value::String(target.clone()))
}
SchemaValidationErrorKind::MissingRequiredField { field } => {
assertion_mismatch_value("required_field", Value::String(field.clone()))
}
SchemaValidationErrorKind::TypeMismatch { expected } => {
assertion_mismatch_value("expected_type", Value::String(expected.clone()))
}
SchemaValidationErrorKind::OneOfNoMatch { candidates }
| SchemaValidationErrorKind::AnyOfNoMatch { candidates } => {
assertion_mismatch_value(
"candidates",
Value::Array(candidates.iter().map(|candidate| Value::String(candidate.clone())).collect()),
)
}
SchemaValidationErrorKind::OneOfMultipleMatches { matches } => {
assertion_mismatch_value(
"matched_branches",
Value::Array(matches.iter().map(|candidate| Value::String(candidate.clone())).collect()),
)
}
SchemaValidationErrorKind::NotMatched { target } => {
assertion_mismatch_value("excluded_target", Value::String(target.clone()))
}
SchemaValidationErrorKind::DiscriminatorUnknownTag { field, expected } => {
assertion_mismatch_value(
"discriminator",
json!({
"field": field,
"expected": expected.iter().map(scalar_literal_to_json).collect::<Vec<_>>()
}),
)
}
SchemaValidationErrorKind::ScalarViolation { reason, .. } => {
expected_value_for_scalar_validation(reason)
}
}
}
fn assertion_mismatch_value(kind: &str, value: Value) -> AssertionMismatchValue {
AssertionMismatchValue {
value_type: kind.to_string(),
value,
}
}
fn scalar_literal_to_json(value: &ScalarLiteral) -> Value {
match value {
ScalarLiteral::String(value) => Value::String(value.clone()),
ScalarLiteral::Integer(value) => json!(value),
ScalarLiteral::Number(value) => json!(value),
ScalarLiteral::Boolean(value) => Value::Bool(*value),
ScalarLiteral::Null => Value::Null,
}
}
fn numeric_value_to_json(value: &NumericValue) -> Value {
match value {
NumericValue::Integer(value) => json!(value),
NumericValue::Number(value) => json!(value),
}
}
fn compose_schema_path(base_path: Option<&str>, schema_path: &str) -> Option<String> {
match (base_path, schema_path) {
(Some(base), "$") => Some(base.to_string()),
(None, "$") => Some("$".to_string()),
(Some(base), _) => Some(format!("{base}{}", schema_path.strip_prefix('$').unwrap_or(schema_path))),
(None, _) => Some(schema_path.to_string()),
}
}
fn deeper_match_failure(left: MatchFailure, right: MatchFailure) -> MatchFailure {
let left_depth = left.path.as_ref().map(|path| path.matches('.').count() + path.matches('[').count()).unwrap_or(0);
let right_depth = right.path.as_ref().map(|path| path.matches('.').count() + path.matches('[').count()).unwrap_or(0);
if right_depth > left_depth {
right
} else {
left
}
}
fn match_structural_value(
actual: &AssertionValue,
expected: &Value,
base_path: Option<&str>,
) -> Result<(), MatchFailure> {
if let AssertionValue::Json(Value::Array(actual_items)) = actual {
if !matches!(expected, Value::Array(_)) {
return match_array_member(actual_items, expected, base_path);
}
}
match expected {
Value::Object(expected_map) => match actual {
AssertionValue::Json(Value::Object(actual_map)) => {
for (key, expected_child) in expected_map {
let child_path = join_object_path(base_path, key);
let actual_child = actual_map
.get(key)
.cloned()
.map(AssertionValue::Json)
.unwrap_or(AssertionValue::Missing);
match_structural_value(&actual_child, expected_child, Some(child_path.as_str()))?;
}
Ok(())
}
_ => Err(match_failure_from_value(
actual,
base_path.map(str::to_string),
expected,
)),
},
Value::Array(expected_items) => match actual {
AssertionValue::Json(Value::Array(actual_items)) => {
match_array_subset(actual_items, expected_items, base_path)
}
_ => Err(match_failure_from_value(
actual,
base_path.map(str::to_string),
expected,
)),
},
_ => {
let expected_value = AssertionValue::Json(expected.clone());
if values_equal(actual, &expected_value) {
Ok(())
} else {
Err(match_failure_from_value(
actual,
base_path.map(str::to_string),
expected,
))
}
}
}
}
fn match_array_member(
actual_items: &[Value],
expected: &Value,
base_path: Option<&str>,
) -> Result<(), MatchFailure> {
let mut best_failure: Option<MatchFailure> = None;
for (index, item) in actual_items.iter().enumerate() {
let item_path = join_array_path(base_path, index);
match match_structural_value(&AssertionValue::Json(item.clone()), expected, Some(item_path.as_str())) {
Ok(()) => return Ok(()),
Err(failure) => {
best_failure = Some(match best_failure {
Some(current) => deeper_match_failure(current, failure),
None => failure,
});
}
}
}
Err(best_failure.unwrap_or_else(|| MatchFailure {
path: base_path.map(str::to_string),
reason: AssertionMismatchReason::ArrayMemberNotFound,
actual: AssertionMismatchValue {
value_type: "array".to_string(),
value: Value::Array(actual_items.to_vec()),
},
expected: AssertionMismatchValue {
value_type: json_value_type_name(expected).to_string(),
value: expected.clone(),
},
}))
}
fn match_array_subset(
actual_items: &[Value],
expected_items: &[Value],
base_path: Option<&str>,
) -> Result<(), MatchFailure> {
let mut used = vec![false; actual_items.len()];
for expected in expected_items {
let mut found = None;
let mut best_failure: Option<MatchFailure> = None;
for (index, item) in actual_items.iter().enumerate() {
if used[index] {
continue;
}
let item_path = join_array_path(base_path, index);
match match_structural_value(&AssertionValue::Json(item.clone()), expected, Some(item_path.as_str())) {
Ok(()) => {
found = Some(index);
break;
}
Err(failure) => {
best_failure = Some(match best_failure {
Some(current) => deeper_match_failure(current, failure),
None => failure,
});
}
}
}
if let Some(index) = found {
used[index] = true;
continue;
}
return Err(best_failure.unwrap_or_else(|| MatchFailure {
path: base_path.map(str::to_string),
reason: AssertionMismatchReason::ArrayElementNotFound,
actual: AssertionMismatchValue {
value_type: "array".to_string(),
value: Value::Array(actual_items.to_vec()),
},
expected: AssertionMismatchValue {
value_type: json_value_type_name(expected).to_string(),
value: expected.clone(),
},
}));
}
Ok(())
}
fn parse_bool(input: &str) -> Option<bool> {
match input.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn evaluate_binary(
operator: &BinaryOperator,
left: &ResolvedValue,
right: &ResolvedValue,
) -> bool {
match operator {
BinaryOperator::Eq => values_equal(&left.value, &right.value),
BinaryOperator::Ne => !values_equal(&left.value, &right.value),
BinaryOperator::Lt => compare_values(&left.value, &right.value)
.map(|ordering| ordering.is_lt())
.unwrap_or(false),
BinaryOperator::Lte => compare_values(&left.value, &right.value)
.map(|ordering| !ordering.is_gt())
.unwrap_or(false),
BinaryOperator::Gt => compare_values(&left.value, &right.value)
.map(|ordering| ordering.is_gt())
.unwrap_or(false),
BinaryOperator::Gte => compare_values(&left.value, &right.value)
.map(|ordering| !ordering.is_lt())
.unwrap_or(false),
}
}
fn format_binary_failure(
raw: &str,
mismatch: &AssertionMismatch,
) -> String {
let mut details = Vec::new();
if let Some(path) = mismatch.actual_path.as_deref() {
details.push(format!("actual path: {}", path));
}
if let Some(path) = mismatch.compared_path.as_deref() {
details.push(format!("compared path: {}", path));
}
details.push(format!("actual type: {}", mismatch.actual.value_type));
details.push(format!("actual value: {}", render_mismatch_value(&mismatch.actual)));
details.push(format!("operator: {}", mismatch.operator.as_deref().unwrap_or_default()));
details.push(format!("compared type: {}", mismatch.expected.value_type));
details.push(format!("compared value: {}", render_mismatch_value(&mismatch.expected)));
format!("Assertion failed: {} ({})", raw, details.join(", "))
}
fn format_match_failure_with_detail(
raw: &str,
left: &ResolvedValue,
pattern: &MatchPattern,
mismatch: &AssertionMismatch,
) -> String {
let mut details = Vec::new();
if let Some(path) = left.path.as_deref() {
details.push(format!("actual path: {}", path));
}
details.push(format!("actual type: {}", left.type_name()));
details.push(format!("actual value: {}", left.match_display()));
details.push("operator: ~=".to_string());
details.push(format!("pattern: {}", pattern.display()));
if let Some(path) = mismatch.path.as_deref() {
details.push(format!("mismatch path: {}", path));
}
details.push(format!("mismatch reason: {}", mismatch.reason.as_str()));
details.push(format!("mismatch actual type: {}", mismatch.actual.value_type));
details.push(format!("mismatch actual value: {}", render_mismatch_value(&mismatch.actual)));
details.push(format!("mismatch expected value: {}", render_mismatch_value(&mismatch.expected)));
format!("Assertion failed: {} ({})", raw, details.join(", "))
}
fn format_schema_failure(
raw: &str,
left: &ResolvedValue,
target: &str,
mismatch: &AssertionMismatch,
detail: impl AsRef<str>,
) -> String {
let mut details = Vec::new();
if let Some(path) = left.path.as_deref() {
details.push(format!("actual path: {}", path));
}
details.push(format!("actual type: {}", left.type_name()));
details.push(format!("actual value: {}", left.match_display()));
details.push("operator: ===".to_string());
details.push(format!("schema target: {}", target));
if let Some(path) = mismatch.path.as_deref() {
details.push(format!("mismatch path: {}", path));
}
details.push(format!("mismatch reason: {}", mismatch.reason.as_str()));
details.push(format!("mismatch actual type: {}", mismatch.actual.value_type));
details.push(format!("mismatch actual value: {}", render_mismatch_value(&mismatch.actual)));
details.push(format!("detail: {}", detail.as_ref()));
format!("Assertion failed: {} ({})", raw, details.join(", "))
}
fn build_binary_diff(
left: &ResolvedValue,
right: &ResolvedValue,
mismatch: &AssertionMismatch,
) -> Option<AssertionDiff> {
build_assertion_diff(diff_path_for_mismatch(mismatch, left), &left.value, &right.value)
}
fn build_match_diff(
left: &ResolvedValue,
pattern: &MatchPattern,
mismatch: &AssertionMismatch,
) -> Option<AssertionDiff> {
match pattern {
MatchPattern::Json(expected) => build_assertion_diff(
diff_path_for_mismatch(mismatch, left),
&left.value,
&AssertionValue::Json(expected.clone()),
),
MatchPattern::Contains(expected) => build_assertion_diff(
diff_path_for_mismatch(mismatch, left),
&left.value,
&AssertionValue::RawText(expected.clone()),
),
MatchPattern::Regex(_) => None,
}
}
fn build_schema_diff(
left: &ResolvedValue,
mismatch: &AssertionMismatch,
) -> Option<AssertionDiff> {
let path = mismatch.path.clone().or_else(|| left.path.clone());
let actual = mismatch_value_to_diff_value(&mismatch.actual);
let expected = schema_expected_diff_value(&mismatch.expected);
build_assertion_diff(path, &actual, &expected)
}
fn diff_path_for_mismatch(mismatch: &AssertionMismatch, left: &ResolvedValue) -> Option<String> {
left.path
.clone()
.or_else(|| mismatch.actual_path.clone())
.or_else(|| mismatch.compared_path.clone())
.or_else(|| mismatch.path.clone())
}
fn build_assertion_diff(
path: Option<String>,
actual: &AssertionValue,
expected: &AssertionValue,
) -> Option<AssertionDiff> {
let actual = diffable_render(actual)?;
let expected = diffable_render(expected)?;
if actual == expected {
return None;
}
let rendered = render_unified_diff(path.as_deref().unwrap_or("value"), &actual, &expected);
Some(AssertionDiff {
format: AssertionDiffFormat::Unified,
path,
rendered,
})
}
fn mismatch_value_to_diff_value(value: &AssertionMismatchValue) -> AssertionValue {
if value.value_type == "missing" {
return AssertionValue::Missing;
}
AssertionValue::Json(value.value.clone())
}
fn schema_expected_diff_value(value: &AssertionMismatchValue) -> AssertionValue {
let display = match value.value_type.as_str() {
"typed_json" => format!("<typed JSON for {}>", schema_value_label(&value.value)),
"schema_target" => format!("<schema {}>", schema_value_label(&value.value)),
"scalar_target" => format!("<scalar {}>", schema_value_label(&value.value)),
"required_field" => format!("<required field {}>", schema_value_label(&value.value)),
"expected_type" => format!("<{}>", schema_value_label(&value.value)),
"format" => format!("<{}>", schema_value_label(&value.value)),
"enum" => format!("<one of {}>", value.value),
"length_range" => format!("<length {}>", schema_range_label(&value.value)),
"numeric_range" => format!("<range {}>", schema_range_label(&value.value)),
"pattern" => format!("<{}>", schema_pattern_label(&value.value)),
_ => format!("<{}>", render_mismatch_value(value)),
};
AssertionValue::RawText(display)
}
fn schema_value_label(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
_ => value.to_string(),
}
}
fn schema_range_label(value: &Value) -> String {
let Some(object) = value.as_object() else {
return value.to_string();
};
let min = object
.get("min")
.map(schema_bound_label)
.unwrap_or_else(|| "-inf".to_string());
let max = object
.get("max")
.map(schema_bound_label)
.unwrap_or_else(|| "+inf".to_string());
format!("{}..{}", min, max)
}
fn schema_bound_label(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::String(text) => text.clone(),
_ => value.to_string(),
}
}
fn schema_pattern_label(value: &Value) -> String {
match value {
Value::String(pattern) => format!("matches {}", pattern),
Value::Object(object) => {
let pattern = object
.get("pattern")
.map(schema_value_label)
.unwrap_or_else(|| "<unknown>".to_string());
let message = object.get("message").map(schema_value_label);
match message {
Some(message) => format!("matches {} ({})", pattern, message),
None => format!("matches {}", pattern),
}
}
_ => value.to_string(),
}
}
fn diffable_render(value: &AssertionValue) -> Option<String> {
match value {
AssertionValue::Missing => Some("<missing>\n".to_string()),
AssertionValue::RawText(text) => Some(ensure_trailing_newline(text.to_string())),
AssertionValue::Json(value) => match value {
Value::Array(_) | Value::Object(_) => serde_json::to_string_pretty(value)
.ok()
.map(ensure_trailing_newline),
Value::String(text) => Some(ensure_trailing_newline(text.clone())),
Value::Null | Value::Bool(_) | Value::Number(_) => Some(ensure_trailing_newline(value.to_string())),
},
}
}
fn ensure_trailing_newline(mut value: String) -> String {
if !value.ends_with('\n') {
value.push('\n');
}
value
}
const DIFF_CONTEXT_LINES: usize = 2;
const DIFF_MAX_CHANGED_LINES: usize = 6;
const DIFF_CHANGED_EDGE_LINES: usize = DIFF_MAX_CHANGED_LINES / 2;
fn render_unified_diff(path: &str, actual: &str, expected: &str) -> String {
let actual_lines = actual.lines().collect::<Vec<_>>();
let expected_lines = expected.lines().collect::<Vec<_>>();
let prefix_len = common_prefix_len(&actual_lines, &expected_lines);
let suffix_len = common_suffix_len(&actual_lines[prefix_len..], &expected_lines[prefix_len..]);
let actual_end = actual_lines.len().saturating_sub(suffix_len);
let expected_end = expected_lines.len().saturating_sub(suffix_len);
let expected_changed = &expected_lines[prefix_len..expected_end];
let actual_changed = &actual_lines[prefix_len..actual_end];
let shown_prefix_start = prefix_len.saturating_sub(DIFF_CONTEXT_LINES);
let shown_suffix_len = suffix_len.min(DIFF_CONTEXT_LINES);
let shown_suffix_end = actual_end + shown_suffix_len;
let mut lines = Vec::new();
lines.push(format!("@@ {} @@", path));
if shown_prefix_start > 0 {
lines.push(render_omitted_lines_marker(shown_prefix_start));
}
for line in &actual_lines[shown_prefix_start..prefix_len] {
lines.push(format!(" {}", line));
}
render_changed_lines(&mut lines, expected_changed, '-', "removed");
render_changed_lines(&mut lines, actual_changed, '+', "added");
for line in &actual_lines[actual_end..shown_suffix_end] {
lines.push(format!(" {}", line));
}
let omitted_suffix_count = suffix_len.saturating_sub(shown_suffix_len);
if omitted_suffix_count > 0 {
lines.push(render_omitted_lines_marker(omitted_suffix_count));
}
lines.join("\n")
}
fn render_changed_lines(
rendered_lines: &mut Vec<String>,
changed_lines: &[&str],
prefix: char,
kind: &str,
) {
if changed_lines.len() <= DIFF_MAX_CHANGED_LINES {
for line in changed_lines {
rendered_lines.push(format!("{}{}", prefix, line));
}
return;
}
let head_len = DIFF_CHANGED_EDGE_LINES.min(changed_lines.len());
let tail_len = DIFF_CHANGED_EDGE_LINES.min(changed_lines.len().saturating_sub(head_len));
let tail_start = changed_lines.len().saturating_sub(tail_len);
for line in &changed_lines[..head_len] {
rendered_lines.push(format!("{}{}", prefix, line));
}
let omitted_count = changed_lines.len().saturating_sub(head_len + tail_len);
if omitted_count > 0 {
rendered_lines.push(render_changed_lines_marker(omitted_count, kind));
}
for line in &changed_lines[tail_start..] {
rendered_lines.push(format!("{}{}", prefix, line));
}
}
fn render_omitted_lines_marker(count: usize) -> String {
if count == 1 {
"... 1 unchanged line ...".to_string()
} else {
format!("... {} unchanged lines ...", count)
}
}
fn render_changed_lines_marker(count: usize, kind: &str) -> String {
if count == 1 {
format!("... 1 {} line ...", kind)
} else {
format!("... {} {} lines ...", count, kind)
}
}
fn common_prefix_len<'a>(left: &[&'a str], right: &[&'a str]) -> usize {
left.iter()
.zip(right.iter())
.take_while(|(lhs, rhs)| lhs == rhs)
.count()
}
fn common_suffix_len<'a>(left: &[&'a str], right: &[&'a str]) -> usize {
left.iter()
.rev()
.zip(right.iter().rev())
.take_while(|(lhs, rhs)| lhs == rhs)
.count()
}
fn render_mismatch_value(value: &AssertionMismatchValue) -> String {
match value.value_type.as_str() {
"missing" => "<missing>".to_string(),
"string" | "substring" | "regex" => match &value.value {
Value::String(text) => render_string(text),
other => other.to_string(),
},
_ => value.value.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::StatusCode;
use crate::request::response_capture::{INTERNAL_SSE_EVENT_HEADER, INTERNAL_WS_KIND_HEADER};
fn env(vars: &[(&str, &str)]) -> HashMap<String, String> {
vars.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn empty_snapshot() -> ResponseSnapshot {
ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::new(),
json: None,
}
}
fn deps() -> HashMap<String, ResponseSnapshot> {
HashMap::new()
}
#[test]
fn equality_assertion_passes() {
let context = HashMap::new();
let assertion = Assertion::parse("^ $FOO == 'bar'", &context).unwrap();
let map = env(&[("FOO", "bar")]);
let snapshot = empty_snapshot();
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn graphql_errors_alias_can_be_used_in_assertions() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & graphql.errors == null", &context).unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"data":{"user":{"id":"123"}},"errors":null}"#),
json: Some(json!({
"data": { "user": { "id": "123" } },
"errors": null
})),
};
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps()).is_ok());
}
#[test]
fn graphql_data_alias_reports_graphql_path_on_failure() {
let context = HashMap::new();
let assertion =
Assertion::parse("^ & graphql.data.user.id == '456'", &context).unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"data":{"user":{"id":"123"}},"errors":null}"#),
json: Some(json!({
"data": { "user": { "id": "123" } },
"errors": null
})),
};
let err = assertion
.evaluate(&HashMap::new(), &snapshot, &deps())
.expect_err("assertion should fail");
let mismatch = err.1.expect("mismatch should be present");
assert_eq!(mismatch.path.as_deref(), Some("graphql.data.user.id"));
assert_eq!(mismatch.actual_path.as_deref(), Some("graphql.data.user.id"));
}
#[test]
fn sse_event_alias_can_be_used_in_assertions() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & sse.event == 'price'", &context).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
INTERNAL_SSE_EVENT_HEADER,
HeaderValue::from_static("price"),
);
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers,
body: String::from(r#"{"symbol":"AAPL"}"#),
json: Some(json!({ "symbol": "AAPL" })),
};
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps()).is_ok());
}
#[test]
fn ws_kind_alias_can_be_used_in_assertions() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & ws.kind == 'text'", &context).unwrap();
let mut headers = HeaderMap::new();
headers.insert(INTERNAL_WS_KIND_HEADER, HeaderValue::from_static("text"));
let snapshot = ResponseSnapshot {
status: StatusCode::SWITCHING_PROTOCOLS,
headers,
body: String::from(r#"{"type":"ack"}"#),
json: Some(json!({ "type": "ack" })),
};
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps()).is_ok());
}
#[test]
fn numeric_greater_than_passes() {
let context = HashMap::new();
let assertion = Assertion::parse("^ $COUNT > 10", &context).unwrap();
let map = env(&[("COUNT", "12")]);
let snapshot = empty_snapshot();
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn schema_assertion_parses_with_known_target() {
let context = HashMap::new();
let schema_registry = SchemaRegistry::default();
let assertion = Assertion::parse_with_registry("^ & body === UUID", &context, &schema_registry)
.unwrap();
assert!(matches!(
assertion.comparison,
Comparison::Schema { ref target } if target == "UUID"
));
}
#[test]
fn schema_assertion_rejects_unknown_target() {
let context = HashMap::new();
let schema_registry = SchemaRegistry::default();
let error = Assertion::parse_with_registry("^ & body === User", &context, &schema_registry)
.unwrap_err();
assert!(error.0.contains("Unknown schema validation target 'User'"));
}
#[test]
fn guard_rejects_schema_operator() {
let context = HashMap::new();
let error = Assertion::parse("[status === UUID] ^ $FOO == 'bar'", &context).unwrap_err();
assert!(error
.0
.contains("schema validation operator is not supported in guards"));
}
#[test]
fn schema_assertion_validates_builtin_scalar_targets() {
let context = HashMap::new();
let schema_registry = SchemaRegistry::default();
let assertion = Assertion::parse_with_registry("^ & body.id === UUID", &context, &schema_registry)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"id":"550e8400-e29b-41d4-a716-446655440000"}"#),
json: Some(json!({ "id": "550e8400-e29b-41d4-a716-446655440000" })),
};
let deps = deps();
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps).is_ok());
let numeric_assertion = Assertion::parse_with_registry(
"^ & body.amount === NUMBER",
&context,
&schema_registry,
)
.unwrap();
let numeric_snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"amount":-3.2}"#),
json: Some(json!({ "amount": -3.2 })),
};
assert!(numeric_assertion
.evaluate(&HashMap::new(), &numeric_snapshot, &deps)
.is_ok());
}
#[test]
fn schema_assertion_validates_object_targets() {
let context = HashMap::new();
let mut schema_registry = SchemaRegistry::default();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"User",
crate::schema::SchemaShape::Object(crate::schema::ObjectSchema::open(vec![
crate::schema::SchemaField::required("id", crate::schema::TypeReference::named("UUID")),
crate::schema::SchemaField::optional(
"birthday",
crate::schema::TypeReference::named("DATE").nullable(),
),
])),
))
.unwrap();
schema_registry.validate_references().unwrap();
let assertion = Assertion::parse_with_registry("^ & body === User", &context, &schema_registry)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"id":"550e8400-e29b-41d4-a716-446655440000","birthday":null,"extra":true}"#,
),
json: Some(json!({
"id": "550e8400-e29b-41d4-a716-446655440000",
"birthday": null,
"extra": true
})),
};
let deps = deps();
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps).is_ok());
}
#[test]
fn schema_assertion_validates_root_array_targets() {
let context = HashMap::new();
let mut schema_registry = SchemaRegistry::default();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"User",
crate::schema::SchemaShape::Object(crate::schema::ObjectSchema::open(vec![
crate::schema::SchemaField::required("id", crate::schema::TypeReference::named("UUID")),
])),
))
.unwrap();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"Users",
crate::schema::SchemaShape::Array(crate::schema::TypeReference::array(
crate::schema::TypeReference::named("User"),
)),
))
.unwrap();
schema_registry.validate_references().unwrap();
let assertion = Assertion::parse_with_registry("^ & body === Users", &context, &schema_registry)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"[{"id":"550e8400-e29b-41d4-a716-446655440000"},{"id":"123e4567-e89b-12d3-a456-426614174000"}]"#,
),
json: Some(json!([
{ "id": "550e8400-e29b-41d4-a716-446655440000" },
{ "id": "123e4567-e89b-12d3-a456-426614174000" }
])),
};
let deps = deps();
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps).is_ok());
}
#[test]
fn schema_assertion_validates_dependency_response_schemas() {
let context = HashMap::new();
let mut schema_registry = SchemaRegistry::default();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"LoginResponse",
crate::schema::SchemaShape::Object(crate::schema::ObjectSchema::open(vec![
crate::schema::SchemaField::required("token", crate::schema::TypeReference::primitive(crate::schema::PrimitiveType::String)),
crate::schema::SchemaField::required("expiresAt", crate::schema::TypeReference::named("DATE_TIME")),
])),
))
.unwrap();
schema_registry.validate_references().unwrap();
let assertion = Assertion::parse_with_registry(
"^ &[Login].body === LoginResponse",
&context,
&schema_registry,
)
.unwrap();
let snapshot = empty_snapshot();
let mut deps = HashMap::new();
deps.insert(
"Login".to_string(),
ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"token":"secret","expiresAt":"2026-05-02T12:00:00Z"}"#,
),
json: Some(json!({
"token": "secret",
"expiresAt": "2026-05-02T12:00:00Z"
})),
},
);
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps).is_ok());
}
#[test]
fn schema_assertion_rejects_untyped_left_hand_values() {
let context = HashMap::new();
let schema_registry = SchemaRegistry::default();
let assertion = Assertion::parse_with_registry("^ $USER_ID === UUID", &context, &schema_registry)
.unwrap();
let snapshot = empty_snapshot();
let deps = deps();
let error = assertion
.evaluate(&env(&[("USER_ID", "550e8400-e29b-41d4-a716-446655440000")]), &snapshot, &deps)
.unwrap_err();
assert!(error.0.contains("schema validation requires a typed JSON value on the left-hand side"));
let mismatch = error.1.expect("expected mismatch details");
assert_eq!(mismatch.kind, AssertionMismatchKind::Schema);
assert_eq!(mismatch.reason, AssertionMismatchReason::InvalidActualType);
assert_eq!(mismatch.target.as_deref(), Some("UUID"));
assert_eq!(mismatch.operator.as_deref(), Some("==="));
}
#[test]
fn schema_assertion_rejects_invalid_rhs_usage() {
let context = HashMap::new();
let schema_registry = SchemaRegistry::default();
let error = Assertion::parse_with_registry("^ & body === 'UUID'", &context, &schema_registry)
.unwrap_err();
assert!(error
.0
.contains("schema validation target must be a declared scalar or schema name"));
}
#[test]
fn schema_assertion_reports_nested_schema_failure_path() {
let context = HashMap::new();
let mut schema_registry = SchemaRegistry::default();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"Address",
crate::schema::SchemaShape::Object(crate::schema::ObjectSchema::open(vec![
crate::schema::SchemaField::required("postalCode", crate::schema::TypeReference::primitive(crate::schema::PrimitiveType::String)),
])),
))
.unwrap();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"User",
crate::schema::SchemaShape::Object(crate::schema::ObjectSchema::open(vec![
crate::schema::SchemaField::required("id", crate::schema::TypeReference::named("UUID")),
crate::schema::SchemaField::required("address", crate::schema::TypeReference::named("Address")),
])),
))
.unwrap();
schema_registry.validate_references().unwrap();
let assertion = Assertion::parse_with_registry("^ & body === User", &context, &schema_registry)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"id":"550e8400-e29b-41d4-a716-446655440000","address":{}}"#),
json: Some(json!({
"id": "550e8400-e29b-41d4-a716-446655440000",
"address": {}
})),
};
let deps = deps();
let error = assertion.evaluate(&HashMap::new(), &snapshot, &deps).unwrap_err();
assert!(error.0.contains("mismatch path: body.address.postalCode"));
let mismatch = error.1.expect("expected mismatch details");
let diff = error.2.expect("expected diff details");
assert_eq!(mismatch.kind, AssertionMismatchKind::Schema);
assert_eq!(mismatch.target.as_deref(), Some("User"));
assert_eq!(mismatch.path.as_deref(), Some("body.address.postalCode"));
assert_eq!(mismatch.reason, AssertionMismatchReason::MissingRequiredField);
assert_eq!(mismatch.expected.value_type, "required_field");
assert_eq!(mismatch.expected.value, Value::String("postalCode".to_string()));
assert_eq!(diff.path.as_deref(), Some("body.address.postalCode"));
assert_eq!(diff.format, AssertionDiffFormat::Unified);
assert_eq!(
diff.rendered,
"@@ body.address.postalCode @@\n-<required field postalCode>\n+<missing>"
);
}
#[test]
fn regex_matches() {
let context = HashMap::new();
let assertion = Assertion::parse("^ $VALUE ~= /foo.+/", &context).unwrap();
let map = env(&[("VALUE", "foobarbaz")]);
let snapshot = empty_snapshot();
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn contains_matches() {
let context = HashMap::new();
let assertion = Assertion::parse("^ $VALUE ~= 'bar'", &context).unwrap();
let map = env(&[("VALUE", "foobarbaz")]);
let snapshot = empty_snapshot();
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn assertion_failure_uses_default_message() {
let context = HashMap::new();
let assertion = Assertion::parse("^ $VALUE == 'expected'", &context).unwrap();
let map = env(&[("VALUE", "actual")]);
let snapshot = empty_snapshot();
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("Assertion failed"));
assert!(message.contains("actual type: string"));
assert!(message.contains("compared type: string"));
}
#[test]
fn assertion_failure_with_custom_message() {
let context = HashMap::new();
let assertion =
Assertion::parse("^ $VALUE == 'expected' || 'custom failure'", &context).unwrap();
let map = env(&[("VALUE", "actual")]);
let snapshot = empty_snapshot();
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
assert_eq!(result.err().unwrap().0, "custom failure");
}
#[test]
fn capture_body_matches_regex() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body ~= /foo/", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: "foobarbaz".into(),
json: None,
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn capture_dependency_body_token() {
let context = HashMap::new();
let assertion = Assertion::parse("^ &[Login].body.token == 'secret'", &context).unwrap();
let map = HashMap::new();
let snapshot = empty_snapshot();
let mut deps = HashMap::new();
let dep_snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: "{\"token\":\"secret\"}".into(),
json: serde_json::from_str("{\"token\":\"secret\"}").ok(),
};
deps.insert("Login".to_string(), dep_snapshot);
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn capture_body_boolean_equals_boolean_literal() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.boolean == true", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: "{\"boolean\":true}".into(),
json: serde_json::from_str("{\"boolean\":true}").ok(),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn decoded_json_capture_operand_can_be_used_in_assertion() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & json(body.result.content[0].text).items[0].id == 'abc123'",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"result":{"content":[{"text":"{\"items\":[{\"id\":\"abc123\",\"active\":true}]}"}]}}"#,
),
json: Some(json!({
"result": {
"content": [
{
"text": "{\"items\":[{\"id\":\"abc123\",\"active\":true}]}"
}
]
}
})),
};
assert!(assertion.evaluate(&map, &snapshot, &deps()).is_ok());
}
#[test]
fn decoded_json_capture_operand_supports_schema_validation() {
let context = HashMap::new();
let mut schema_registry = SchemaRegistry::default();
schema_registry
.register_schema(crate::schema::SchemaDefinition::new(
"DecodedItem",
crate::schema::SchemaShape::Object(crate::schema::ObjectSchema::open(vec![
crate::schema::SchemaField::required(
"id",
crate::schema::TypeReference::primitive(crate::schema::PrimitiveType::String),
),
crate::schema::SchemaField::required(
"active",
crate::schema::TypeReference::primitive(crate::schema::PrimitiveType::Boolean),
),
])),
))
.unwrap();
schema_registry.validate_references().unwrap();
let assertion = Assertion::parse_with_registry(
"^ & json(body.result.content[0].text).items[0] === DecodedItem",
&context,
&schema_registry,
)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"result":{"content":[{"text":"{\"items\":[{\"id\":\"abc123\",\"active\":true}]}"}]}}"#,
),
json: Some(json!({
"result": {
"content": [
{
"text": "{\"items\":[{\"id\":\"abc123\",\"active\":true}]}"
}
]
}
})),
};
assert!(assertion.evaluate(&HashMap::new(), &snapshot, &deps()).is_ok());
}
#[test]
fn decoded_json_capture_operand_reports_invalid_json() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & json(body.result.content[0].text).items[0].id == 'abc123'",
&context,
)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"result":{"content":[{"text":"not json"}]}}"#),
json: Some(json!({
"result": {
"content": [
{
"text": "not json"
}
]
}
})),
};
let err = assertion
.evaluate(&HashMap::new(), &snapshot, &deps())
.unwrap_err();
assert!(err.0.contains("Failed to decode JSON from 'body.result.content[0].text'"));
}
#[test]
fn string_variable_can_match_boolean_literal() {
let context = HashMap::new();
let assertion = Assertion::parse("^ $FEATURE_ENABLED == true", &context).unwrap();
let map = env(&[("FEATURE_ENABLED", "true")]);
let snapshot = empty_snapshot();
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn json_string_does_not_equal_number_literal() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.count == 1", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"count":"1"}"#),
json: Some(json!({ "count": "1" })),
};
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("actual path: body.count"));
assert!(message.contains("actual type: string"));
assert!(message.contains("actual value: \"1\""));
assert!(message.contains("compared type: number"));
assert!(message.contains("compared value: 1.0") || message.contains("compared value: 1"));
}
#[test]
fn null_literal_matches_json_null() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.value == null", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"value":null}"#),
json: Some(json!({ "value": null })),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn missing_path_is_reported_distinctly_from_null() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.value == null", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from("{}"),
json: Some(json!({})),
};
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("actual path: body.value"));
assert!(message.contains("actual type: missing"));
assert!(message.contains("actual value: <missing>"));
assert!(message.contains("compared type: null"));
}
#[test]
fn structured_values_compare_without_stringifying() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.user == &[Login].body.user", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":7,"role":"admin"}}"#),
json: Some(json!({ "user": { "id": 7, "role": "admin" } })),
};
let mut deps = HashMap::new();
deps.insert(
"Login".to_string(),
ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":7,"role":"admin"}}"#),
json: Some(json!({ "user": { "id": 7, "role": "admin" } })),
},
);
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn scalar_and_structured_values_do_not_compare_equal() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.user == 7", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":7}}"#),
json: Some(json!({ "user": { "id": 7 } })),
};
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("actual path: body.user"));
assert!(message.contains("actual type: object"));
assert!(message.contains("compared type: number"));
}
#[test]
fn failure_message_includes_compared_path_for_capture_operands() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.user.id == &[Login].body.user.id", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":7}}"#),
json: Some(json!({ "user": { "id": 7 } })),
};
let mut deps = HashMap::new();
deps.insert(
"Login".to_string(),
ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":8}}"#),
json: Some(json!({ "user": { "id": 8 } })),
},
);
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("actual path: body.user.id"));
assert!(message.contains("compared path: [Login].body.user.id"));
}
#[test]
fn structural_match_allows_partial_object_subset() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.user ~= {\"id\":7}", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":7,"role":"admin","active":true}}"#),
json: Some(json!({ "user": { "id": 7, "role": "admin", "active": true } })),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn structural_match_allows_unordered_array_membership() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.items ~= {\"id\":2}", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"items":[{"id":1},{"id":2},{"id":3}]}"#),
json: Some(json!({ "items": [{ "id": 1 }, { "id": 2 }, { "id": 3 }] })),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn structural_match_allows_unordered_array_subset() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & body.items ~= [{\"id\":3},{\"id\":1}]",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"items":[{"id":1},{"id":2},{"id":3}]}"#),
json: Some(json!({ "items": [{ "id": 1 }, { "id": 2 }, { "id": 3 }] })),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn structural_match_reports_nested_mismatch_path() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & body.user ~= {\"profile\":{\"name\":\"Alice\"}}",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"profile":{"name":"Bob"}}}"#),
json: Some(json!({ "user": { "profile": { "name": "Bob" } } })),
};
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("mismatch path: body.user.profile.name"));
}
#[test]
fn structural_match_distinguishes_null_from_missing() {
let context = HashMap::new();
let assertion = Assertion::parse("^ & body.user ~= {\"middleName\":null}", &context).unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"firstName":"Alice"}}"#),
json: Some(json!({ "user": { "firstName": "Alice" } })),
};
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("mismatch path: body.user.middleName"));
assert!(message.contains("mismatch actual type: missing"));
assert!(message.contains("mismatch expected value: null"));
}
#[test]
fn structural_match_reuses_same_logic_for_dependency_captures() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ &[Login].body.user ~= {\"profile\":{\"role\":\"admin\"}}",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = empty_snapshot();
let mut deps = HashMap::new();
deps.insert(
"Login".to_string(),
ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"user":{"id":7,"profile":{"role":"admin","active":true}}}"#),
json: Some(json!({ "user": { "id": 7, "profile": { "role": "admin", "active": true } } })),
},
);
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn filtered_body_capture_operand_can_be_used_in_assertion() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & body.jobs[? recipient.email == \"alice@example.com\" && active == true].status == 'succeeded'",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"jobs":[{"recipient":{"email":"bob@example.com"},"active":true,"status":"queued"},{"recipient":{"email":"alice@example.com"},"active":true,"status":"succeeded"}]}"#,
),
json: Some(json!({
"jobs": [
{
"recipient": { "email": "bob@example.com" },
"active": true,
"status": "queued"
},
{
"recipient": { "email": "alice@example.com" },
"active": true,
"status": "succeeded"
}
]
})),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn filtered_body_capture_operand_can_use_variable_values() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & body.jobs[? recipient.email == $RECIPIENT_EMAIL && active == $EXPECT_ACTIVE].status == 'succeeded'",
&context,
)
.unwrap();
let map = env(&[("RECIPIENT_EMAIL", "alice@example.com"), ("EXPECT_ACTIVE", "true")]);
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"jobs":[{"recipient":{"email":"bob@example.com"},"active":true,"status":"queued"},{"recipient":{"email":"alice@example.com"},"active":true,"status":"succeeded"}]}"#,
),
json: Some(json!({
"jobs": [
{
"recipient": { "email": "bob@example.com" },
"active": true,
"status": "queued"
},
{
"recipient": { "email": "alice@example.com" },
"active": true,
"status": "succeeded"
}
]
})),
};
let deps = deps();
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn filtered_body_capture_operand_reports_concrete_matched_path_on_failure() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & body.jobs[? recipient == \"alice@example.com\"].status == 'queued'",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"jobs":[{"recipient":"bob@example.com","status":"queued"},{"recipient":"alice@example.com","status":"succeeded"}]}"#,
),
json: Some(json!({
"jobs": [
{ "recipient": "bob@example.com", "status": "queued" },
{ "recipient": "alice@example.com", "status": "succeeded" }
]
})),
};
let deps = deps();
let err = assertion.evaluate(&map, &snapshot, &deps).unwrap_err();
let mismatch = err.1.expect("mismatch details should be attached");
assert!(err.0.contains("actual path: body.jobs[1].status"));
assert_eq!(mismatch.actual_path.as_deref(), Some("body.jobs[1].status"));
assert_eq!(mismatch.path.as_deref(), Some("body.jobs[1].status"));
}
#[test]
fn filtered_body_capture_operand_requires_exactly_one_match() {
let context = HashMap::new();
let assertion = Assertion::parse(
"^ & body.jobs[? recipient == \"alice@example.com\"].status == 'succeeded'",
&context,
)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"jobs":[{"recipient":"alice@example.com","status":"queued"},{"recipient":"alice@example.com","status":"succeeded"}]}"#,
),
json: Some(json!({
"jobs": [
{ "recipient": "alice@example.com", "status": "queued" },
{ "recipient": "alice@example.com", "status": "succeeded" }
]
})),
};
let deps = deps();
let result = assertion.evaluate(&map, &snapshot, &deps);
assert!(result.is_err());
let message = result.err().unwrap().0;
assert!(message.contains("body.jobs[? recipient == \"alice@example.com\"].status"));
assert!(message.contains("matched 2 elements"));
assert!(message.contains("expected exactly one"));
}
#[test]
fn guarded_assertion_parses_guard() {
let context = HashMap::new();
let assertion = Assertion::parse("[ROLE == admin] ^ $STATUS == 200", &context).unwrap();
assert!(assertion.guard.is_some());
}
#[test]
fn guarded_assertion_skips_when_false() {
let context = HashMap::new();
let assertion = Assertion::parse("[ROLE == admin] ^ $STATUS == 200", &context).unwrap();
let map = env(&[("ROLE", "user"), ("STATUS", "200")]);
let snapshot = empty_snapshot();
let deps = deps();
let should_execute = assertion
.should_execute(&map, &snapshot, &deps)
.expect("guard evaluation should succeed");
assert!(!should_execute);
}
#[test]
fn guarded_assertion_executes_when_true() {
let context = HashMap::new();
let assertion = Assertion::parse("[ROLE == admin] ^ $STATUS == 200", &context).unwrap();
let map = env(&[("ROLE", "admin"), ("STATUS", "200")]);
let snapshot = empty_snapshot();
let deps = deps();
let should_execute = assertion
.should_execute(&map, &snapshot, &deps)
.expect("guard evaluation should succeed");
assert!(should_execute);
assert!(assertion.evaluate(&map, &snapshot, &deps).is_ok());
}
#[test]
fn guarded_assertion_treats_true_as_boolean_literal() {
let context = HashMap::new();
let assertion = Assertion::parse("[FEATURE_ENABLED == true] ^ $STATUS == 200", &context)
.unwrap();
let map = env(&[("FEATURE_ENABLED", "true"), ("STATUS", "200")]);
let snapshot = empty_snapshot();
let deps = deps();
let should_execute = assertion
.should_execute(&map, &snapshot, &deps)
.expect("guard evaluation should succeed");
assert!(should_execute);
}
#[test]
fn guarded_assertion_treats_null_as_null_literal() {
let context = HashMap::new();
let assertion = Assertion::parse("[& body.optional == null] ^ & status == 200", &context)
.unwrap();
let map = HashMap::new();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"optional":null}"#),
json: Some(json!({ "optional": null })),
};
let deps = deps();
let should_execute = assertion
.should_execute(&map, &snapshot, &deps)
.expect("guard evaluation should succeed");
assert!(should_execute);
}
}