use std::{collections::HashMap, error::Error, fmt};
use reqwest::{header::HeaderMap, StatusCode};
use serde_json::Value;
use crate::parser::context;
#[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>),
Header(HeaderAccessor),
Status(StatusAccessor),
}
#[derive(Debug, Clone)]
pub enum BodyAccessor {
Key(String),
Index(usize),
}
#[derive(Debug, Clone)]
pub struct HeaderAccessor {
pub name: String,
pub case_sensitive: bool,
}
#[derive(Debug, Clone)]
pub enum StatusAccessor {
Code,
Text,
Family,
}
#[derive(Debug)]
pub struct CaptureError(pub 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,
) -> Result<Option<(String, String)>, CaptureError> {
let value = match resolve_capture_value(&self.target, &self.raw_path, snapshot) {
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,
}
}
}
#[derive(Debug, Clone)]
pub struct ResponseSnapshot {
pub status: StatusCode,
pub headers: HeaderMap,
pub body: String,
pub json: Option<Value>,
}
impl CaptureTarget {
pub fn parse(input: &str) -> Result<Self, CaptureError> {
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))
}
"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))
}
other => Err(CaptureError(format!(
"unsupported capture target '{}'. expected body, header, or status",
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,
) -> 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).ok_or_else(|| {
CaptureError(format!(
"Failed to resolve body path '{}': path not found",
raw_path
))
})
} else {
Err(CaptureError(format!(
"Failed to resolve body path '{}': response body is not valid JSON",
raw_path
)))
}
}
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)
}
}
}
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 mut index_str = String::new();
let mut found_closing = false;
while let Some(inner) = chars.next() {
if inner == ']' {
found_closing = true;
break;
}
index_str.push(inner);
}
if !found_closing {
return Err(CaptureError("unmatched '[' in body path".into()));
}
if index_str.trim().is_empty() {
return Err(CaptureError("array index cannot be empty".into()));
}
let index = index_str
.trim()
.parse::<usize>()
.map_err(|_| CaptureError("array index must be a number".into()))?;
accessors.push(BodyAccessor::Index(index));
} 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 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_quotes = false;
while let Some(ch) = chars.next() {
match ch {
'"' => {
current.push(ch);
in_quotes = !in_quotes;
}
'.' if !in_quotes => {
segments.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
if in_quotes {
return Err(CaptureError("unterminated quoted segment".into()));
}
if !current.trim().is_empty() {
segments.push(current.trim().to_string());
}
Ok(segments)
}
fn extract_json_value(json: &Value, segments: &[BodyAccessor]) -> Option<String> {
let mut current = json;
for segment in segments {
match (segment, current) {
(&BodyAccessor::Key(ref key), Value::Object(map)) => {
current = map.get(key)?;
}
(&BodyAccessor::Index(index), Value::Array(list)) => {
current = list.get(index)?;
}
_ => return None,
}
}
match current {
Value::Null => None,
Value::Bool(b) => Some(b.to_string()),
Value::Number(num) => Some(num.to_string()),
Value::String(s) => Some(s.clone()),
_ => Some(current.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
}
})
}
#[cfg(test)]
mod tests {
use super::*;
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).unwrap().unwrap();
assert_eq!(extracted.0, "TOKEN");
assert_eq!(extracted.1, "abc123");
}
#[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).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).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);
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).unwrap().unwrap();
assert_eq!(extracted.0, "STATUS_CODE");
assert_eq!(extracted.1, status.as_u16().to_string());
}
}
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))
}
}