use std::{collections::HashMap, error::Error, fmt};
use regex::Regex;
use crate::parser::context;
use crate::request::response_capture::{
parse_capture_operand, resolve_capture_value, CaptureSource, CaptureTarget, ResponseSnapshot,
};
#[derive(Debug, Clone)]
pub struct Assertion {
pub raw: String,
left: Operand,
comparison: Comparison,
message: Option<String>,
}
#[derive(Debug, Clone)]
enum Operand {
Variable(String),
Literal(String),
Capture {
source: CaptureSource,
target: CaptureTarget,
raw_path: String,
},
}
#[derive(Debug, Clone)]
enum Comparison {
Binary {
operator: BinaryOperator,
right: Operand,
},
Matches {
pattern: MatchPattern,
},
}
#[derive(Debug, Clone, Copy)]
enum BinaryOperator {
Eq,
Ne,
Lt,
Lte,
Gt,
Gte,
}
#[derive(Debug, Clone)]
enum MatchPattern {
Regex(Regex),
Contains(String),
}
#[derive(Debug)]
pub struct AssertionError(pub String);
impl fmt::Display for AssertionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for AssertionError {}
impl Assertion {
pub fn parse(raw: &str, context_map: &HashMap<String, String>) -> Result<Self, AssertionError> {
let trimmed = raw.trim();
let without_prefix = trimmed
.strip_prefix('^')
.ok_or_else(|| AssertionError("assertion must begin with '^'".into()))?
.trim();
if without_prefix.is_empty() {
return Err(AssertionError("assertion expression is empty".into()));
}
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 = match operator {
OperatorToken::Matches => {
let pattern = MatchPattern::parse(rhs)?;
Comparison::Matches { pattern }
}
OperatorToken::Eq => Comparison::Binary {
operator: BinaryOperator::Eq,
right: Operand::parse(rhs)?,
},
OperatorToken::Ne => Comparison::Binary {
operator: BinaryOperator::Ne,
right: Operand::parse(rhs)?,
},
OperatorToken::Lt => Comparison::Binary {
operator: BinaryOperator::Lt,
right: Operand::parse(rhs)?,
},
OperatorToken::Lte => Comparison::Binary {
operator: BinaryOperator::Lte,
right: Operand::parse(rhs)?,
},
OperatorToken::Gt => Comparison::Binary {
operator: BinaryOperator::Gt,
right: Operand::parse(rhs)?,
},
OperatorToken::Gte => Comparison::Binary {
operator: BinaryOperator::Gte,
right: Operand::parse(rhs)?,
},
};
Ok(Self {
raw: raw.trim().to_string(),
left,
comparison,
message,
})
}
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)?;
let passed = match &self.comparison {
Comparison::Binary { operator, right } => {
let right_value = right.resolve(env, current_snapshot, dependency_snapshots)?;
match operator {
BinaryOperator::Eq => left_value == right_value,
BinaryOperator::Ne => left_value != right_value,
BinaryOperator::Lt => compare_numbers(&left_value, &right_value)
.map(|ord| ord.is_lt())
.unwrap_or_else(|| left_value < right_value),
BinaryOperator::Lte => compare_numbers(&left_value, &right_value)
.map(|ord| !ord.is_gt())
.unwrap_or_else(|| left_value <= right_value),
BinaryOperator::Gt => compare_numbers(&left_value, &right_value)
.map(|ord| ord.is_gt())
.unwrap_or_else(|| left_value > right_value),
BinaryOperator::Gte => compare_numbers(&left_value, &right_value)
.map(|ord| !ord.is_lt())
.unwrap_or_else(|| left_value >= right_value),
}
}
Comparison::Matches { pattern } => match pattern {
MatchPattern::Regex(regex) => regex.is_match(&left_value),
MatchPattern::Contains(expected) => left_value.contains(expected),
},
};
if passed {
Ok(())
} else {
let failure_message = self.message.clone().unwrap_or_else(|| {
format!("Assertion failed: {} (actual: '{}')", self.raw, left_value)
});
Err(AssertionError(failure_message))
}
}
}
fn compare_numbers(left: &str, right: &str) -> Option<std::cmp::Ordering> {
let lhs = left.parse::<f64>().ok()?;
let rhs = right.parse::<f64>().ok()?;
lhs.partial_cmp(&rhs)
}
impl Operand {
fn parse(input: &str) -> Result<Self, AssertionError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssertionError("missing operand".into()));
}
if trimmed.starts_with('&') {
let (source, target, raw_path) =
parse_capture_operand(trimmed).map_err(|err| AssertionError(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("invalid variable reference".into()));
}
return Ok(Operand::Variable(key));
}
if is_numeric_literal(trimmed) {
return Ok(Operand::Literal(trimmed.to_string()));
}
if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
return Ok(Operand::Literal(normalize_literal(trimmed)));
}
Ok(Operand::Variable(trimmed.to_string()))
}
fn resolve(
&self,
env: &HashMap<String, String>,
current_snapshot: &ResponseSnapshot,
dependency_snapshots: &HashMap<String, ResponseSnapshot>,
) -> Result<String, AssertionError> {
match self {
Operand::Variable(name) => env
.get(name)
.cloned()
.ok_or_else(|| AssertionError(format!("No value found for variable '{}'.", name))),
Operand::Literal(value) => Ok(value.clone()),
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(format!(
"No response snapshot available for dependency '{}' referenced in assertion.",
dep
))
})?,
};
resolve_capture_value(target, raw_path, snapshot)
.map_err(|err| AssertionError(err.0))
}
}
}
}
impl MatchPattern {
fn parse(input: &str) -> Result<Self, AssertionError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AssertionError(
"matches operator requires a right-hand operand".into(),
));
}
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(format!("Invalid regex in assertion: {}", e)))?;
Ok(MatchPattern::Regex(regex))
} else {
Ok(MatchPattern::Contains(literal))
}
}
}
#[derive(Debug, Clone, Copy)]
enum OperatorToken {
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 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 {
i += 1;
continue;
}
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("invalid assertion expression".into()));
}
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("invalid assertion expression".into()));
}
return Ok((left, token, right));
}
i += 1;
}
Err(AssertionError(
"No valid operator found in assertion".into(),
))
}
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 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 && ch == '|' && bytes[i + 1] as char == '|' {
let expr = input[..i].trim();
let message = input[i + 2..].trim();
if expr.is_empty() {
return Err(AssertionError("assertion expression is empty".into()));
}
if message.is_empty() {
return Err(AssertionError("assertion message is empty".into()));
}
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 is_numeric_literal(input: &str) -> bool {
let trimmed = input.trim();
if trimmed.is_empty() {
return false;
}
trimmed
.chars()
.all(|ch| matches!(ch, '0'..='9' | '.' | '-' | '+'))
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderMap;
use reqwest::StatusCode;
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 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 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());
assert!(result.err().unwrap().0.contains("Assertion failed"));
}
#[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());
}
}