use std::{cmp::Ordering, collections::HashMap, error::Error, fmt, iter::Peekable, str::Chars};
use reqwest::{header::HeaderMap, StatusCode};
use serde_json::Value;
use crate::parser::context;
use super::ExecutionArtifact;
pub(crate) const INTERNAL_SSE_EVENT_HEADER: &str = "x-hen-sse-event";
pub(crate) const INTERNAL_SSE_ID_HEADER: &str = "x-hen-sse-id";
pub(crate) const INTERNAL_WS_KIND_HEADER: &str = "x-hen-ws-kind";
#[derive(Debug, Clone)]
pub struct ResponseCapture {
pub source: CaptureSource,
pub target: CaptureTarget,
pub variable: String,
pub default: Option<String>,
pub raw_path: String,
}
#[derive(Debug, Clone)]
pub enum CaptureSource {
Current,
Dependency(String),
}
#[derive(Debug, Clone)]
pub enum CaptureTarget {
Body(Vec<BodyAccessor>),
Json(DecodedJsonTarget),
Header(HeaderAccessor),
Status(StatusAccessor),
Sse(SseAccessor),
Ws(WsAccessor),
}
#[derive(Debug, Clone)]
pub struct DecodedJsonTarget {
pub source: Box<CaptureTarget>,
pub source_raw_path: String,
pub path: Vec<BodyAccessor>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum BodyAccessor {
Key(String),
Index(usize),
Filter(FilterPredicate),
}
#[derive(Debug, Clone, PartialEq)]
pub struct FilterPredicate {
pub clauses: Vec<FilterClause>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FilterClause {
pub path: Vec<FilterPathAccessor>,
pub operator: FilterOperator,
pub expected: FilterExpectedValue,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FilterExpectedValue {
Literal(Value),
Variable(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum FilterPathAccessor {
Key(String),
Index(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterOperator {
Eq,
Ne,
}
#[derive(Debug, Clone)]
pub struct HeaderAccessor {
pub name: String,
pub case_sensitive: bool,
}
#[derive(Debug, Clone)]
pub enum StatusAccessor {
Code,
Text,
Family,
}
#[derive(Debug, Clone, Copy)]
pub enum SseAccessor {
Event,
Id,
}
#[derive(Debug, Clone, Copy)]
pub enum WsAccessor {
Kind,
}
#[derive(Debug)]
pub struct CaptureError(pub String);
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum CaptureValue {
Missing,
Json(Value),
RawText(String),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ResolvedAssertionCaptureValue {
pub value: CaptureValue,
pub resolved_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
struct ResolvedJsonNode<'a> {
value: &'a Value,
resolved_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BodyResolutionError {
Missing { resolved_path: Option<String> },
AmbiguousFilter { predicate: String, matches: usize },
UnknownFilterVariable(String),
}
impl fmt::Display for CaptureError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for CaptureError {}
impl ResponseCapture {
pub fn parse(raw: &str, context: &HashMap<String, String>) -> Result<Self, CaptureError> {
let trimmed = raw.trim();
if !trimmed.starts_with('&') {
return Err(CaptureError("capture must begin with '&'".into()));
}
let remainder = trimmed.trim_start_matches('&').trim_start();
let (source, remainder) = parse_capture_source(remainder)?;
let (path_part_raw, remainder_raw) = remainder
.split_once("->")
.ok_or_else(|| CaptureError("missing capture assignment '->'".into()))?;
let path_part = path_part_raw.trim();
if path_part.is_empty() {
return Err(CaptureError("missing capture path".into()));
}
let remainder = remainder_raw.trim();
let (variable_part, default_part) = match remainder.split_once(":=") {
Some((lhs, rhs)) => (lhs.trim(), Some(rhs.trim())),
None => (remainder, None),
};
let variable_part = variable_part.trim();
if !variable_part.starts_with('$') {
return Err(CaptureError(
"capture assignment must target a $variable".into(),
));
}
let variable = variable_part.trim_start_matches('$').trim().to_string();
if variable.is_empty() {
return Err(CaptureError("capture variable name cannot be empty".into()));
}
let default = default_part.map(|d| context::inject_from_variable(d, context));
let target = CaptureTarget::parse(path_part)?;
Ok(ResponseCapture {
source,
target,
variable,
default,
raw_path: path_part.to_string(),
})
}
pub fn extract_from_snapshot(
&self,
snapshot: &ResponseSnapshot,
env: &HashMap<String, String>,
) -> Result<Option<(String, String)>, CaptureError> {
let value = match resolve_capture_value(&self.target, &self.raw_path, snapshot, env) {
Ok(val) => Some(val),
Err(err) => match &self.default {
Some(default) => Some(default.clone()),
None => {
return Err(CaptureError(format!(
"{}",
err.0
.replace("assertion", &format!("capture into ${}", self.variable))
)));
}
},
};
match value {
Some(val) => Ok(Some((self.variable.clone(), val))),
None => Ok(None),
}
}
pub fn required_dependency(&self) -> Option<&str> {
match &self.source {
CaptureSource::Dependency(name) => Some(name.as_str()),
CaptureSource::Current => None,
}
}
}
pub(crate) fn validate_redaction_body_path(raw_path: &str) -> Result<(), CaptureError> {
parse_redaction_body_target(raw_path).map(|_| ())
}
pub(crate) fn resolve_redaction_body_value(
raw_path: &str,
snapshot: &ResponseSnapshot,
env: &HashMap<String, String>,
) -> Result<String, CaptureError> {
let target = parse_redaction_body_target(raw_path)?;
resolve_capture_value(&target, raw_path, snapshot, env)
}
fn parse_redaction_body_target(raw_path: &str) -> Result<CaptureTarget, CaptureError> {
let target = CaptureTarget::parse(raw_path)?;
match target {
CaptureTarget::Body(_) | CaptureTarget::Json(_) => Ok(target),
_ => Err(CaptureError(
"redact_body must target a body path like body.token or json(body.payload).token"
.to_string(),
)),
}
}
#[derive(Debug, Clone)]
pub struct ResponseSnapshot {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: String,
pub json: Option<Value>,
}
impl Default for ResponseSnapshot {
fn default() -> Self {
Self {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::new(),
json: None,
}
}
}
impl From<&ExecutionArtifact> for ResponseSnapshot {
fn from(value: &ExecutionArtifact) -> Self {
Self {
status: value.status,
headers: value.headers.clone(),
body: value.body.clone(),
json: value.json.clone(),
}
}
}
impl CaptureTarget {
pub fn parse(input: &str) -> Result<Self, CaptureError> {
let trimmed = input.trim();
if trimmed.starts_with("json(") {
return parse_decoded_json_target(trimmed);
}
let mut chars = input.chars().peekable();
let mut buffer = String::new();
while let Some(ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
} else {
break;
}
}
while let Some(ch) = chars.peek() {
if *ch == '.' || ch.is_whitespace() {
break;
}
buffer.push(*ch);
chars.next();
}
let head = buffer.trim();
let remaining: String = chars.collect();
let remaining = remaining.trim();
match head {
"body" => {
let segments = parse_body_path(remaining.trim_start_matches('.'))?;
Ok(CaptureTarget::Body(segments))
}
"graphql" => {
let segments = parse_body_path(remaining.trim_start_matches('.'))?;
Ok(CaptureTarget::Body(segments))
}
"header" => {
let accessor = parse_header_path(remaining.trim_start_matches('.'))?;
Ok(CaptureTarget::Header(accessor))
}
"status" => {
let accessor = parse_status_path(remaining.trim_start_matches('.'))?;
Ok(CaptureTarget::Status(accessor))
}
"sse" => {
let accessor = parse_sse_path(remaining.trim_start_matches('.'))?;
Ok(CaptureTarget::Sse(accessor))
}
"ws" => {
let accessor = parse_ws_path(remaining.trim_start_matches('.'))?;
Ok(CaptureTarget::Ws(accessor))
}
other => Err(CaptureError(format!(
"unsupported capture target '{}'. expected body, graphql, header, status, sse, ws, or json(...)",
other
))),
}
}
}
pub fn parse_capture_operand(
raw: &str,
) -> Result<(CaptureSource, CaptureTarget, String), CaptureError> {
let trimmed = raw.trim();
if !trimmed.starts_with('&') {
return Err(CaptureError("capture must begin with '&'".into()));
}
let remainder = trimmed.trim_start_matches('&').trim_start();
let (source, remainder) = parse_capture_source(remainder)?;
let path = remainder.trim();
if path.is_empty() {
return Err(CaptureError(
"capture operand requires a target path".into(),
));
}
let target = CaptureTarget::parse(path)?;
Ok((source, target, path.to_string()))
}
pub fn resolve_capture_value(
target: &CaptureTarget,
raw_path: &str,
snapshot: &ResponseSnapshot,
env: &HashMap<String, String>,
) -> Result<String, CaptureError> {
match target {
CaptureTarget::Body(segments) => {
if segments.is_empty() {
Ok(snapshot.body.clone())
} else if let Some(json) = snapshot.json.as_ref() {
extract_json_value(json, segments, env)
.map_err(|err| format_body_resolution_error(raw_path, err))
} else {
Err(CaptureError(format!(
"Failed to resolve body path '{}': response body is not valid JSON",
raw_path
)))
}
}
CaptureTarget::Json(target) => {
let json = decode_json_target(target, snapshot, env)?;
if target.path.is_empty() {
Ok(render_json_capture_value(&json))
} else {
extract_json_value(&json, &target.path, env)
.map_err(|err| format_body_resolution_error(raw_path, err))
}
}
CaptureTarget::Header(accessor) => extract_header_value(&snapshot.headers, accessor)
.map(|value| value.to_string())
.ok_or_else(|| {
CaptureError(format!(
"Failed to resolve header '{}' in assertion",
accessor.name
))
}),
CaptureTarget::Status(accessor) => {
let status_str = match accessor {
StatusAccessor::Code => snapshot.status.as_u16().to_string(),
StatusAccessor::Text => {
snapshot.status.canonical_reason().unwrap_or("").to_string()
}
StatusAccessor::Family => format!("{}xx", snapshot.status.as_u16() / 100),
};
Ok(status_str)
}
CaptureTarget::Sse(accessor) => extract_sse_value(&snapshot.headers, *accessor)
.ok_or_else(|| {
CaptureError(format!(
"Failed to resolve SSE field '{}' in assertion",
sse_accessor_name(*accessor)
))
}),
CaptureTarget::Ws(accessor) => extract_ws_value(&snapshot.headers, *accessor)
.ok_or_else(|| {
CaptureError(format!(
"Failed to resolve WebSocket field '{}' in assertion",
ws_accessor_name(*accessor)
))
}),
}
}
pub(crate) fn resolve_capture_value_with_path_for_assertion(
target: &CaptureTarget,
raw_path: &str,
snapshot: &ResponseSnapshot,
env: &HashMap<String, String>,
) -> Result<ResolvedAssertionCaptureValue, CaptureError> {
match target {
CaptureTarget::Body(segments) => {
if segments.is_empty() {
if let Some(json) = snapshot.json.as_ref() {
Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::Json(json.clone()),
resolved_path: Some(raw_path.to_string()),
})
} else {
Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::RawText(snapshot.body.clone()),
resolved_path: Some(raw_path.to_string()),
})
}
} else if let Some(json) = snapshot.json.as_ref() {
let root_path = if raw_path.trim_start().starts_with("graphql") {
"graphql"
} else {
"body"
};
match extract_json_node(json, segments, env, root_path) {
Ok(node) => Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::Json(node.value.clone()),
resolved_path: node
.resolved_path
.or_else(|| Some(raw_path.to_string())),
}),
Err(BodyResolutionError::Missing { resolved_path }) => {
Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::Missing,
resolved_path: resolved_path.or_else(|| Some(raw_path.to_string())),
})
}
Err(err) => Err(format_body_resolution_error(raw_path, err)),
}
} else {
Err(CaptureError(format!(
"Failed to resolve body path '{}': response body is not valid JSON",
raw_path
)))
}
}
CaptureTarget::Json(target) => {
let json = decode_json_target(target, snapshot, env)?;
if target.path.is_empty() {
Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::Json(json),
resolved_path: Some(raw_path.to_string()),
})
} else {
let decoded_root = format!("json({})", target.source_raw_path);
match extract_json_node(&json, &target.path, env, &decoded_root) {
Ok(node) => Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::Json(node.value.clone()),
resolved_path: node
.resolved_path
.or_else(|| Some(raw_path.to_string())),
}),
Err(BodyResolutionError::Missing { resolved_path }) => {
Ok(ResolvedAssertionCaptureValue {
value: CaptureValue::Missing,
resolved_path: resolved_path.or_else(|| Some(raw_path.to_string())),
})
}
Err(err) => Err(format_body_resolution_error(raw_path, err)),
}
}
}
CaptureTarget::Header(accessor) => Ok(ResolvedAssertionCaptureValue {
value: extract_header_value(&snapshot.headers, accessor)
.map(|value| CaptureValue::Json(Value::String(value.to_string())))
.unwrap_or(CaptureValue::Missing),
resolved_path: Some(raw_path.to_string()),
}),
CaptureTarget::Status(accessor) => Ok(ResolvedAssertionCaptureValue {
value: match accessor {
StatusAccessor::Code => CaptureValue::Json(Value::from(snapshot.status.as_u16())),
StatusAccessor::Text => CaptureValue::Json(Value::String(
snapshot.status.canonical_reason().unwrap_or("").to_string(),
)),
StatusAccessor::Family => CaptureValue::Json(Value::String(format!(
"{}xx",
snapshot.status.as_u16() / 100
))),
},
resolved_path: Some(raw_path.to_string()),
}),
CaptureTarget::Sse(accessor) => Ok(ResolvedAssertionCaptureValue {
value: extract_sse_value(&snapshot.headers, *accessor)
.map(|value| CaptureValue::Json(Value::String(value)))
.unwrap_or(CaptureValue::Missing),
resolved_path: Some(raw_path.to_string()),
}),
CaptureTarget::Ws(accessor) => Ok(ResolvedAssertionCaptureValue {
value: extract_ws_value(&snapshot.headers, *accessor)
.map(|value| CaptureValue::Json(Value::String(value)))
.unwrap_or(CaptureValue::Missing),
resolved_path: Some(raw_path.to_string()),
}),
}
}
fn parse_body_path(segments: &str) -> Result<Vec<BodyAccessor>, CaptureError> {
if segments.is_empty() {
return Ok(vec![]);
}
let raw_segments = split_path_segments(segments)?;
let mut accessors: Vec<BodyAccessor> = Vec::new();
for seg in raw_segments {
if seg.is_empty() {
continue;
}
let mut chars = seg.chars().peekable();
let mut name = String::new();
while let Some(ch) = chars.peek() {
if *ch == '[' {
break;
}
name.push(*ch);
chars.next();
}
let trimmed_name = name.trim();
if !trimmed_name.is_empty() {
accessors.push(BodyAccessor::Key(trimmed_name.to_string()));
}
while let Some(ch) = chars.next() {
if ch == '[' {
let bracket_content = consume_bracket_content(&mut chars)?;
let trimmed_content = bracket_content.trim();
if let Some(predicate_source) = trimmed_content.strip_prefix('?') {
accessors.push(BodyAccessor::Filter(parse_filter_predicate(
predicate_source,
)?));
continue;
}
accessors.push(BodyAccessor::Index(parse_array_index(trimmed_content)?));
} else if !ch.is_whitespace() {
return Err(CaptureError(
"unexpected character in body path segment".into(),
));
}
}
}
Ok(accessors)
}
fn parse_header_path(segment: &str) -> Result<HeaderAccessor, CaptureError> {
let raw_segments = split_path_segments(segment)?;
let name_segment = raw_segments
.first()
.ok_or_else(|| CaptureError("header capture requires a header name".into()))?;
let trimmed = name_segment.trim();
if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
let name = trimmed[1..trimmed.len() - 1].to_string();
Ok(HeaderAccessor {
name,
case_sensitive: true,
})
} else {
Ok(HeaderAccessor {
name: trimmed.to_string(),
case_sensitive: false,
})
}
}
fn parse_status_path(segment: &str) -> Result<StatusAccessor, CaptureError> {
let raw_segments = split_path_segments(segment)?;
let field = raw_segments
.first()
.map(|s| s.trim().to_string())
.unwrap_or_default();
if field.is_empty() {
return Ok(StatusAccessor::Code);
}
match field.as_str() {
"code" => Ok(StatusAccessor::Code),
"text" => Ok(StatusAccessor::Text),
"family" => Ok(StatusAccessor::Family),
other => Err(CaptureError(format!(
"unsupported status field '{}'. expected code, text, or family",
other
))),
}
}
fn parse_sse_path(segment: &str) -> Result<SseAccessor, CaptureError> {
let raw_segments = split_path_segments(segment)?;
let field = raw_segments
.first()
.map(|s| s.trim().to_string())
.unwrap_or_default();
match field.as_str() {
"event" => Ok(SseAccessor::Event),
"id" => Ok(SseAccessor::Id),
other => Err(CaptureError(format!(
"unsupported sse field '{}'. expected event or id",
other
))),
}
}
fn parse_ws_path(segment: &str) -> Result<WsAccessor, CaptureError> {
let raw_segments = split_path_segments(segment)?;
let field = raw_segments
.first()
.map(|s| s.trim().to_string())
.unwrap_or_default();
match field.as_str() {
"kind" => Ok(WsAccessor::Kind),
other => Err(CaptureError(format!(
"unsupported ws field '{}'. expected kind",
other
))),
}
}
fn split_path_segments(input: &str) -> Result<Vec<String>, CaptureError> {
if input.is_empty() {
return Ok(vec![]);
}
let mut segments = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut in_single = false;
let mut in_double = false;
let mut bracket_depth = 0usize;
while let Some(ch) = chars.next() {
match ch {
'"' => {
current.push(ch);
if !in_single {
in_double = !in_double;
}
}
'\'' => {
current.push(ch);
if !in_double {
in_single = !in_single;
}
}
'[' if !in_single && !in_double => {
bracket_depth += 1;
current.push(ch);
}
']' if !in_single && !in_double => {
if bracket_depth == 0 {
return Err(CaptureError("unmatched ']' in body path".into()));
}
bracket_depth -= 1;
current.push(ch);
}
'.' if !in_single && !in_double && bracket_depth == 0 => {
segments.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
if in_single || in_double {
return Err(CaptureError("unterminated quoted segment".into()));
}
if bracket_depth != 0 {
return Err(CaptureError("unmatched '[' in body path".into()));
}
if !current.trim().is_empty() {
segments.push(current.trim().to_string());
}
Ok(segments)
}
fn extract_json_node<'a>(
json: &'a Value,
segments: &[BodyAccessor],
env: &HashMap<String, String>,
root_path: &str,
) -> Result<ResolvedJsonNode<'a>, BodyResolutionError> {
let mut current = json;
let mut current_path = root_path.to_string();
let mut used_filter = false;
for segment in segments {
match segment {
BodyAccessor::Key(key) => match current {
Value::Object(map) => {
let next_path = join_body_object_path(¤t_path, key);
current = map.get(key).ok_or_else(|| BodyResolutionError::Missing {
resolved_path: used_filter.then_some(next_path.clone()),
})?;
current_path = next_path;
}
_ => {
return Err(BodyResolutionError::Missing {
resolved_path: used_filter
.then_some(join_body_object_path(¤t_path, key)),
})
}
},
BodyAccessor::Index(index) => match current {
Value::Array(list) => {
let next_path = join_body_array_path(¤t_path, *index);
current = list.get(*index).ok_or_else(|| BodyResolutionError::Missing {
resolved_path: used_filter.then_some(next_path.clone()),
})?;
current_path = next_path;
}
_ => {
return Err(BodyResolutionError::Missing {
resolved_path: used_filter
.then_some(join_body_array_path(¤t_path, *index)),
})
}
},
BodyAccessor::Filter(predicate) => match current {
Value::Array(list) => {
let mut matches = Vec::new();
for (index, item) in list.iter().enumerate() {
if predicate.matches(item, env)? {
matches.push((index, item));
}
}
match matches.as_slice() {
[(index, single)] => {
current = single;
current_path = join_body_array_path(¤t_path, *index);
used_filter = true;
}
[] => {
return Err(BodyResolutionError::Missing {
resolved_path: None,
})
}
_ => {
return Err(BodyResolutionError::AmbiguousFilter {
predicate: predicate.to_string(),
matches: matches.len(),
})
}
}
}
_ => {
return Err(BodyResolutionError::Missing {
resolved_path: used_filter.then_some(current_path.clone()),
})
}
},
}
}
Ok(ResolvedJsonNode {
value: current,
resolved_path: used_filter.then_some(current_path),
})
}
fn join_body_object_path(base: &str, key: &str) -> String {
if base.is_empty() {
key.to_string()
} else {
format!("{}.{}", base, key)
}
}
fn join_body_array_path(base: &str, index: usize) -> String {
format!("{}[{}]", base, index)
}
fn extract_json_value(
json: &Value,
segments: &[BodyAccessor],
env: &HashMap<String, String>,
) -> Result<String, BodyResolutionError> {
let current = extract_json_node(json, segments, env, "body")?.value;
match current {
Value::Null => Err(BodyResolutionError::Missing {
resolved_path: None,
}),
Value::Bool(b) => Ok(b.to_string()),
Value::Number(num) => Ok(num.to_string()),
Value::String(s) => Ok(s.clone()),
_ => Ok(current.to_string()),
}
}
impl FilterPredicate {
fn matches(
&self,
candidate: &Value,
env: &HashMap<String, String>,
) -> Result<bool, BodyResolutionError> {
for clause in &self.clauses {
if !clause.matches(candidate, env)? {
return Ok(false);
}
}
Ok(true)
}
}
impl FilterClause {
fn matches(
&self,
candidate: &Value,
env: &HashMap<String, String>,
) -> Result<bool, BodyResolutionError> {
let Some(actual) = resolve_filter_path(candidate, &self.path) else {
return Ok(false);
};
let matched = filter_values_equal(actual, &self.expected, env)?;
Ok(match self.operator {
FilterOperator::Eq => matched,
FilterOperator::Ne => !matched,
})
}
}
impl fmt::Display for FilterPredicate {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let rendered = self
.clauses
.iter()
.map(|clause| clause.to_string())
.collect::<Vec<_>>()
.join(" && ");
write!(f, "{}", rendered)
}
}
impl fmt::Display for FilterClause {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {} {}",
render_filter_path(&self.path),
self.operator.as_str(),
render_filter_value(&self.expected)
)
}
}
impl FilterOperator {
fn as_str(&self) -> &'static str {
match self {
Self::Eq => "==",
Self::Ne => "!=",
}
}
}
fn parse_filter_predicate(source: &str) -> Result<FilterPredicate, CaptureError> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(CaptureError("filter predicate cannot be empty".into()));
}
let clauses = split_filter_clauses(trimmed)?
.into_iter()
.map(|clause| parse_filter_clause(clause.as_str()))
.collect::<Result<Vec<_>, _>>()?;
Ok(FilterPredicate { clauses })
}
fn split_filter_clauses(input: &str) -> Result<Vec<String>, CaptureError> {
let mut clauses = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut bracket_depth = 0usize;
let chars = input.chars().collect::<Vec<_>>();
let mut idx = 0usize;
while idx < chars.len() {
let ch = chars[idx];
match ch {
'\'' if !in_double => {
in_single = !in_single;
current.push(ch);
}
'"' if !in_single => {
in_double = !in_double;
current.push(ch);
}
'[' if !in_single && !in_double => {
bracket_depth += 1;
current.push(ch);
}
']' if !in_single && !in_double => {
if bracket_depth == 0 {
return Err(CaptureError("unmatched ']' in filter predicate".into()));
}
bracket_depth -= 1;
current.push(ch);
}
'|' if !in_single && !in_double && idx + 1 < chars.len() && chars[idx + 1] == '|' => {
return Err(CaptureError(
"filter predicates do not support '||' in v1".into(),
));
}
'(' | ')' if !in_single && !in_double => {
return Err(CaptureError(
"filter predicates do not support parentheses in v1".into(),
));
}
'&'
if !in_single
&& !in_double
&& bracket_depth == 0
&& idx + 1 < chars.len()
&& chars[idx + 1] == '&' =>
{
if current.trim().is_empty() {
return Err(CaptureError("filter predicate clause cannot be empty".into()));
}
clauses.push(current.trim().to_string());
current.clear();
idx += 1;
}
_ => current.push(ch),
}
idx += 1;
}
if in_single || in_double {
return Err(CaptureError(
"unterminated quoted string in filter predicate".into(),
));
}
if bracket_depth != 0 {
return Err(CaptureError("unmatched '[' in filter predicate".into()));
}
if current.trim().is_empty() {
return Err(CaptureError("filter predicate clause cannot be empty".into()));
}
clauses.push(current.trim().to_string());
Ok(clauses)
}
fn parse_filter_clause(input: &str) -> Result<FilterClause, CaptureError> {
let (path, operator, expected) = split_filter_clause_expression(input)?;
Ok(FilterClause {
path: parse_filter_path(path)?,
operator,
expected: parse_filter_value(expected)?,
})
}
fn split_filter_clause_expression(
input: &str,
) -> Result<(&str, FilterOperator, &str), CaptureError> {
let chars = input.chars().collect::<Vec<_>>();
let mut in_single = false;
let mut in_double = false;
let mut bracket_depth = 0usize;
let mut idx = 0usize;
while idx + 1 < chars.len() {
let ch = chars[idx];
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 => {
if bracket_depth == 0 {
return Err(CaptureError("unmatched ']' in filter clause".into()));
}
bracket_depth -= 1;
}
_ => {}
}
if !in_single && !in_double && bracket_depth == 0 {
let operator = match (chars[idx], chars[idx + 1]) {
('=', '=') => Some(FilterOperator::Eq),
('!', '=') => Some(FilterOperator::Ne),
('~', '=') => {
return Err(CaptureError(
"filter predicates do not support '~=' in v1".into(),
))
}
_ => None,
};
if let Some(operator) = operator {
let left = input[..idx].trim();
let right = input[idx + 2..].trim();
if left.is_empty() || right.is_empty() {
return Err(CaptureError(
"filter predicate must contain both a path and a value".into(),
));
}
return Ok((left, operator, right));
}
}
idx += 1;
}
Err(CaptureError(
"filter predicates must use '==' or '!=' in v1".into(),
))
}
fn parse_filter_path(path: &str) -> Result<Vec<FilterPathAccessor>, CaptureError> {
let raw_segments = split_path_segments(path.trim())?;
let mut accessors = Vec::new();
for seg in raw_segments {
if seg.is_empty() {
continue;
}
let mut chars = seg.chars().peekable();
let mut name = String::new();
while let Some(ch) = chars.peek() {
if *ch == '[' {
break;
}
name.push(*ch);
chars.next();
}
let trimmed_name = name.trim();
if !trimmed_name.is_empty() {
accessors.push(FilterPathAccessor::Key(trimmed_name.to_string()));
}
while let Some(ch) = chars.next() {
if ch == '[' {
let bracket_content = consume_bracket_content(&mut chars)?;
let trimmed_content = bracket_content.trim();
if trimmed_content.starts_with('?') {
return Err(CaptureError(
"nested filter predicates are not supported in v1".into(),
));
}
accessors.push(FilterPathAccessor::Index(parse_array_index(trimmed_content)?));
} else if !ch.is_whitespace() {
return Err(CaptureError(
"unexpected character in filter path".into(),
));
}
}
}
if accessors.is_empty() {
return Err(CaptureError("filter path cannot be empty".into()));
}
Ok(accessors)
}
fn parse_filter_value(input: &str) -> Result<FilterExpectedValue, CaptureError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(CaptureError("filter value cannot be empty".into()));
}
if trimmed.starts_with('$') {
let variable = trimmed.trim_start_matches('$').trim();
if variable.is_empty() {
return Err(CaptureError(
"filter variable reference cannot be empty".into(),
));
}
return Ok(FilterExpectedValue::Variable(variable.to_string()));
}
if trimmed.starts_with('[') || trimmed.starts_with('{') {
return Err(CaptureError(
"structural subset matching inside filters is not supported in v1".into(),
));
}
if trimmed.starts_with('/') {
return Err(CaptureError(
"regex matching inside filters is not supported in v1".into(),
));
}
if (trimmed.starts_with('\'') && trimmed.ends_with('\'')) && trimmed.len() >= 2 {
return Ok(FilterExpectedValue::Literal(Value::String(
trimmed[1..trimmed.len() - 1].to_string(),
)));
}
let value = serde_json::from_str::<Value>(trimmed).map_err(|_| {
CaptureError(
"filter values must be quoted strings, numbers, booleans, or null".into(),
)
})?;
match value {
Value::Array(_) | Value::Object(_) => Err(CaptureError(
"filter values must be scalar in v1".into(),
)),
scalar => Ok(FilterExpectedValue::Literal(scalar)),
}
}
fn resolve_filter_path<'a>(candidate: &'a Value, path: &[FilterPathAccessor]) -> Option<&'a Value> {
let mut current = candidate;
for accessor in path {
match (accessor, current) {
(FilterPathAccessor::Key(key), Value::Object(map)) => current = map.get(key)?,
(FilterPathAccessor::Index(index), Value::Array(list)) => current = list.get(*index)?,
_ => return None,
}
}
Some(current)
}
fn filter_values_equal(
actual: &Value,
expected: &FilterExpectedValue,
env: &HashMap<String, String>,
) -> Result<bool, BodyResolutionError> {
match expected {
FilterExpectedValue::Literal(expected) => Ok(match (actual, expected) {
(Value::Number(lhs), Value::Number(rhs)) => lhs
.as_f64()
.zip(rhs.as_f64())
.map(|(left, right)| left == right)
.unwrap_or(false),
_ => actual == expected,
}),
FilterExpectedValue::Variable(variable) => {
let value = env
.get(variable)
.ok_or_else(|| BodyResolutionError::UnknownFilterVariable(variable.clone()))?;
Ok(filter_text_equals_json(value, actual))
}
}
}
fn filter_text_equals_json(text: &str, json: &Value) -> bool {
match json {
Value::String(expected) => text == expected,
Value::Bool(expected) => parse_filter_bool(text) == Some(*expected),
Value::Number(expected) => compare_filter_text_to_number(text, expected)
.map(|ordering| ordering == Ordering::Equal)
.unwrap_or(false),
_ => false,
}
}
fn parse_filter_bool(input: &str) -> Option<bool> {
match input.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn compare_filter_text_to_number(text: &str, number: &serde_json::Number) -> Option<Ordering> {
text.trim().parse::<f64>().ok()?.partial_cmp(&number.as_f64()?)
}
fn parse_array_index(input: &str) -> Result<usize, CaptureError> {
if input.trim().is_empty() {
return Err(CaptureError("array index cannot be empty".into()));
}
input
.trim()
.parse::<usize>()
.map_err(|_| CaptureError("array index must be a number".into()))
}
fn consume_bracket_content(chars: &mut Peekable<Chars<'_>>) -> Result<String, CaptureError> {
let mut content = String::new();
let mut depth = 1usize;
let mut in_single = false;
let mut in_double = false;
while let Some(ch) = chars.next() {
match ch {
'\'' if !in_double => {
in_single = !in_single;
content.push(ch);
}
'"' if !in_single => {
in_double = !in_double;
content.push(ch);
}
'[' if !in_single && !in_double => {
depth += 1;
content.push(ch);
}
']' if !in_single && !in_double => {
depth -= 1;
if depth == 0 {
return Ok(content);
}
content.push(ch);
}
_ => content.push(ch),
}
}
Err(CaptureError("unmatched '[' in body path".into()))
}
fn render_filter_path(path: &[FilterPathAccessor]) -> String {
let mut rendered = String::new();
for accessor in path {
match accessor {
FilterPathAccessor::Key(key) => {
if !rendered.is_empty() {
rendered.push('.');
}
rendered.push_str(key);
}
FilterPathAccessor::Index(index) => {
rendered.push('[');
rendered.push_str(index.to_string().as_str());
rendered.push(']');
}
}
}
rendered
}
fn render_filter_value(value: &FilterExpectedValue) -> String {
match value {
FilterExpectedValue::Literal(Value::String(text)) => {
serde_json::to_string(text).unwrap_or_else(|_| format!("\"{}\"", text))
}
FilterExpectedValue::Literal(value) => value.to_string(),
FilterExpectedValue::Variable(variable) => format!("${}", variable),
}
}
fn format_body_resolution_error(raw_path: &str, error: BodyResolutionError) -> CaptureError {
match error {
BodyResolutionError::Missing { .. } => CaptureError(format!(
"Failed to resolve body path '{}': path not found",
raw_path
)),
BodyResolutionError::AmbiguousFilter { predicate, matches } => CaptureError(format!(
"Failed to resolve body path '{}': filter '{}' matched {} elements; expected exactly one",
raw_path, predicate, matches
)),
BodyResolutionError::UnknownFilterVariable(variable) => CaptureError(format!(
"Failed to resolve body path '{}': filter variable '${}' was not found",
raw_path, variable
)),
}
}
fn parse_decoded_json_target(input: &str) -> Result<CaptureTarget, CaptureError> {
let (source_raw_path, remainder) = split_decoded_json_wrapper(input)?;
let source_raw_path = source_raw_path.trim();
if source_raw_path.is_empty() {
return Err(CaptureError("json() requires a source path".into()));
}
let remainder = remainder.trim();
if !remainder.is_empty() && !remainder.starts_with('.') {
return Err(CaptureError(
"json(...) path suffix must begin with '.'".into(),
));
}
let source = CaptureTarget::parse(source_raw_path)?;
let path = parse_body_path(remainder.trim_start_matches('.'))?;
Ok(CaptureTarget::Json(DecodedJsonTarget {
source: Box::new(source),
source_raw_path: source_raw_path.to_string(),
path,
}))
}
fn split_decoded_json_wrapper(input: &str) -> Result<(&str, &str), CaptureError> {
let trimmed = input.trim();
let Some(rest) = trimmed.strip_prefix("json(") else {
return Err(CaptureError("json() target must begin with 'json('".into()));
};
let mut in_single = false;
let mut in_double = false;
let mut bracket_depth = 0usize;
let mut paren_depth = 1usize;
for (offset, ch) in rest.char_indices() {
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 => {
if bracket_depth == 0 {
return Err(CaptureError("unmatched ']' in json() target".into()));
}
bracket_depth -= 1;
}
'(' if !in_single && !in_double && bracket_depth == 0 => paren_depth += 1,
')' if !in_single && !in_double && bracket_depth == 0 => {
paren_depth -= 1;
if paren_depth == 0 {
let inner = &rest[..offset];
let remainder = &rest[offset + ch.len_utf8()..];
return Ok((inner, remainder));
}
}
_ => {}
}
}
Err(CaptureError("json() target is missing closing ')'".into()))
}
fn decode_json_target(
target: &DecodedJsonTarget,
snapshot: &ResponseSnapshot,
env: &HashMap<String, String>,
) -> Result<Value, CaptureError> {
let resolved = resolve_capture_value_with_path_for_assertion(
target.source.as_ref(),
&target.source_raw_path,
snapshot,
env,
)?;
match resolved.value {
CaptureValue::Missing => Err(CaptureError(format!(
"Failed to decode JSON from '{}': source path not found",
target.source_raw_path
))),
CaptureValue::Json(Value::String(text)) => decode_json_text(&text, &target.source_raw_path),
CaptureValue::Json(value) => Ok(value),
CaptureValue::RawText(text) => decode_json_text(&text, &target.source_raw_path),
}
}
fn decode_json_text(text: &str, raw_path: &str) -> Result<Value, CaptureError> {
serde_json::from_str::<Value>(text).map_err(|err| {
CaptureError(format!(
"Failed to decode JSON from '{}': {}",
raw_path, err
))
})
}
fn render_json_capture_value(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(boolean) => boolean.to_string(),
Value::Number(number) => number.to_string(),
Value::String(text) => text.clone(),
_ => value.to_string(),
}
}
fn extract_header_value<'a>(headers: &'a HeaderMap, accessor: &HeaderAccessor) -> Option<&'a str> {
if accessor.case_sensitive {
if let Some(value) = headers.iter().find_map(|(name, value)| {
if name.as_str() == accessor.name {
value.to_str().ok()
} else {
None
}
}) {
return Some(value);
}
}
headers.iter().find_map(|(name, value)| {
if name.as_str().eq_ignore_ascii_case(&accessor.name) {
value.to_str().ok()
} else {
None
}
})
}
fn extract_sse_value(headers: &HeaderMap, accessor: SseAccessor) -> Option<String> {
let name = match accessor {
SseAccessor::Event => INTERNAL_SSE_EVENT_HEADER,
SseAccessor::Id => INTERNAL_SSE_ID_HEADER,
};
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string())
}
fn sse_accessor_name(accessor: SseAccessor) -> &'static str {
match accessor {
SseAccessor::Event => "event",
SseAccessor::Id => "id",
}
}
fn extract_ws_value(headers: &HeaderMap, accessor: WsAccessor) -> Option<String> {
let name = match accessor {
WsAccessor::Kind => INTERNAL_WS_KIND_HEADER,
};
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(|value| value.to_string())
}
fn ws_accessor_name(accessor: WsAccessor) -> &'static str {
match accessor {
WsAccessor::Kind => "kind",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use reqwest::header::HeaderValue;
#[test]
fn parses_body_capture_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse("& body.token -> $TOKEN", &context).unwrap();
match &capture.target {
CaptureTarget::Body(segments) => {
assert_eq!(segments.len(), 1);
}
_ => panic!("expected body capture"),
}
let status = StatusCode::OK;
let headers = HeaderMap::new();
let body_json = serde_json::json!({ "token": "abc123" });
let body_text = body_json.to_string();
let snapshot = ResponseSnapshot {
status,
headers,
body: body_text.clone(),
json: Some(body_json.clone()),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "TOKEN");
assert_eq!(extracted.1, "abc123");
}
#[test]
fn parses_graphql_capture_alias_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse(
"& graphql.errors[0].message -> $ERROR_MESSAGE",
&context,
)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"errors":[{"message":"boom"}]}"#),
json: Some(json!({
"errors": [
{ "message": "boom" }
]
})),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "ERROR_MESSAGE");
assert_eq!(extracted.1, "boom");
}
#[test]
fn parses_sse_capture_alias_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse("& sse.event -> $EVENT", &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" })),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "EVENT");
assert_eq!(extracted.1, "price");
}
#[test]
fn parses_ws_capture_alias_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse("& ws.kind -> $KIND", &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" })),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "KIND");
assert_eq!(extracted.1, "text");
}
#[test]
fn parses_decoded_json_capture_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse(
"& json(body.result.content[0].text).items[0].id -> $ITEM_ID",
&context,
)
.unwrap();
match &capture.target {
CaptureTarget::Json(target) => {
assert_eq!(target.source_raw_path, "body.result.content[0].text");
assert_eq!(target.path.len(), 3);
}
_ => panic!("expected decoded json capture"),
}
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"result":{"content":[{"text":"{\"items\":[{\"id\":\"abc123\"}]}"}]}}"#,
),
json: Some(json!({
"result": {
"content": [
{
"text": "{\"items\":[{\"id\":\"abc123\"}]}"
}
]
}
})),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "ITEM_ID");
assert_eq!(extracted.1, "abc123");
}
#[test]
fn decoded_json_capture_reports_invalid_json() {
let context = HashMap::new();
let capture = ResponseCapture::parse(
"& json(body.result.content[0].text).items[0].id -> $ITEM_ID",
&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 = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap_err();
assert!(err.0.contains("Failed to decode JSON from 'body.result.content[0].text'"));
}
#[test]
fn extracts_header_case_sensitive() {
let context = HashMap::new();
let capture = ResponseCapture::parse("& header.\"Content-Type\" -> $CT", &context).unwrap();
let mut headers = HeaderMap::new();
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
let status = StatusCode::OK;
let body_text = String::from("{}");
let body_json = serde_json::from_str::<Value>(&body_text).ok();
let snapshot = ResponseSnapshot {
status,
headers,
body: body_text.clone(),
json: body_json.clone(),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "CT");
assert_eq!(extracted.1, "application/json");
}
#[test]
fn uses_default_when_body_path_missing() {
let mut context = HashMap::new();
context.insert("fallback".into(), "default".into());
let capture =
ResponseCapture::parse("& body.token -> $TOKEN := {{ fallback }}", &context).unwrap();
let status = StatusCode::OK;
let headers = HeaderMap::new();
let body_text = String::from("{}");
let body_json = serde_json::from_str::<Value>(&body_text).ok();
let snapshot = ResponseSnapshot {
status,
headers,
body: body_text.clone(),
json: body_json.clone(),
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "TOKEN");
assert_eq!(extracted.1, "default");
}
#[test]
fn errors_when_body_path_missing_without_default() {
let context = HashMap::new();
let capture = ResponseCapture::parse("& body.token -> $TOKEN", &context).unwrap();
let status = StatusCode::OK;
let headers = HeaderMap::new();
let body_text = String::from("{}");
let body_json = serde_json::from_str::<Value>(&body_text).ok();
let snapshot = ResponseSnapshot {
status,
headers,
body: body_text.clone(),
json: body_json.clone(),
};
let result = capture.extract_from_snapshot(&snapshot, &HashMap::new());
assert!(result.is_err());
}
#[test]
fn status_capture_defaults_to_code() {
let context = HashMap::new();
let capture = ResponseCapture::parse("& status -> $STATUS_CODE", &context).unwrap();
match capture.target {
CaptureTarget::Status(StatusAccessor::Code) => {}
_ => panic!("expected status code accessor"),
}
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("text/plain"));
let status = StatusCode::IM_A_TEAPOT;
let body_text = String::from("short");
let body_json = serde_json::from_str::<Value>(&body_text).ok();
let snapshot = ResponseSnapshot {
status,
headers,
body: body_text,
json: body_json,
};
let extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "STATUS_CODE");
assert_eq!(extracted.1, status.as_u16().to_string());
}
#[test]
fn assertion_resolution_distinguishes_null_from_missing() {
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(r#"{"present":null}"#),
json: Some(json!({ "present": null })),
};
let present_target = CaptureTarget::parse("body.present").unwrap();
let missing_target = CaptureTarget::parse("body.missing").unwrap();
assert_eq!(
resolve_capture_value_with_path_for_assertion(
&present_target,
"body.present",
&snapshot,
&HashMap::new(),
)
.unwrap()
.value,
CaptureValue::Json(Value::Null)
);
assert_eq!(
resolve_capture_value_with_path_for_assertion(
&missing_target,
"body.missing",
&snapshot,
&HashMap::new(),
)
.unwrap()
.value,
CaptureValue::Missing
);
}
#[test]
fn assertion_resolution_preserves_json_objects_at_body_root() {
let body_json = json!({ "service": "hen", "ok": true });
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: body_json.to_string(),
json: Some(body_json.clone()),
};
let target = CaptureTarget::parse("body").unwrap();
assert_eq!(
resolve_capture_value_with_path_for_assertion(
&target,
"body",
&snapshot,
&HashMap::new(),
)
.unwrap()
.value,
CaptureValue::Json(body_json)
);
}
#[test]
fn parses_filtered_body_capture_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse(
"& body.jobs[? recipient == \"alice@example.com\"].status -> $JOB_STATUS",
&context,
)
.unwrap();
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 extracted = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap()
.unwrap();
assert_eq!(extracted.0, "JOB_STATUS");
assert_eq!(extracted.1, "succeeded");
}
#[test]
fn parses_filtered_body_capture_with_variable_and_extracts_value() {
let context = HashMap::new();
let capture = ResponseCapture::parse(
"& body.jobs[? recipient == $RECIPIENT].status -> $JOB_STATUS",
&context,
)
.unwrap();
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 env = HashMap::from([(String::from("RECIPIENT"), String::from("alice@example.com"))]);
let extracted = capture.extract_from_snapshot(&snapshot, &env).unwrap().unwrap();
assert_eq!(extracted.0, "JOB_STATUS");
assert_eq!(extracted.1, "succeeded");
}
#[test]
fn filtered_body_capture_errors_when_filter_variable_is_missing() {
let context = HashMap::new();
let capture = ResponseCapture::parse(
"& body.jobs[? recipient == $RECIPIENT].status -> $JOB_STATUS",
&context,
)
.unwrap();
let snapshot = ResponseSnapshot {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: String::from(
r#"{"jobs":[{"recipient":"alice@example.com","status":"succeeded"}]}"#,
),
json: Some(json!({
"jobs": [{ "recipient": "alice@example.com", "status": "succeeded" }]
})),
};
let err = capture
.extract_from_snapshot(&snapshot, &HashMap::new())
.unwrap_err();
assert!(err.0.contains("filter variable '$RECIPIENT' was not found"));
}
}
fn parse_capture_source(input: &str) -> Result<(CaptureSource, &str), CaptureError> {
let trimmed = input.trim_start();
if let Some(rest) = trimmed.strip_prefix('[') {
let end = rest
.find(']')
.ok_or_else(|| CaptureError("unterminated dependency reference".into()))?;
let name = rest[..end].trim();
if name.is_empty() {
return Err(CaptureError("dependency reference cannot be empty".into()));
}
let remainder = rest[end + 1..].trim_start();
let remainder = if remainder.starts_with('.') {
&remainder[1..]
} else {
remainder
};
Ok((CaptureSource::Dependency(name.to_string()), remainder))
} else {
Ok((CaptureSource::Current, trimmed))
}
}