use crate::i18n::Strings;
use serde_json::Value;
use std::ops::Range;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Subject {
Status,
Duration,
Header(String),
Json(String),
JsonCount(String),
Body,
}
impl Subject {
pub fn query(&self) -> String {
match self {
Subject::Status => "status".to_string(),
Subject::Duration => "duration".to_string(),
Subject::Header(name) => format!("header \"{}\"", escape_hurl(name)),
Subject::Json(path) => format!("jsonpath \"{}\"", escape_hurl(path)),
Subject::JsonCount(path) => format!("jsonpath \"{}\" count", escape_hurl(path)),
Subject::Body => "body".to_string(),
}
}
fn numeric(&self) -> bool {
matches!(
self,
Subject::Duration | Subject::JsonCount(_) | Subject::Status
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Predicate {
Eq(String),
Ne(String),
Gt(String),
Lt(String),
Contains(String),
StartsWith(String),
Exists,
Empty(bool),
}
pub fn assert_line(subject: Subject, predicate: Predicate) -> Option<String> {
if matches!(subject, Subject::Status) {
return None;
}
let query = subject.query();
let numeric = subject.numeric();
let line = match predicate {
Predicate::Eq(v) => format!("{query} == {v}"),
Predicate::Ne(v) => format!("{query} != {v}"),
Predicate::Gt(v) => format!("{query} > {v}"),
Predicate::Lt(v) => format!("{query} < {v}"),
Predicate::Contains(v) if !numeric => format!("{query} contains {v}"),
Predicate::StartsWith(v) if !numeric => format!("{query} startsWith {v}"),
Predicate::Exists => format!("{query} exists"),
Predicate::Empty(true) if !numeric => format!("{query} isEmpty"),
Predicate::Empty(false) if !numeric => format!("{query} not isEmpty"),
_ => return None,
};
match subject {
Subject::Duration if !matches!(line.split(' ').nth(1), Some("<" | ">" | "==")) => None,
_ => Some(line),
}
}
pub fn capture_row(subject: &Subject, name: &str) -> (String, String) {
(name.to_string(), subject.query())
}
pub fn push_key(path: &mut String, key: &str) {
let simple = !key.is_empty()
&& !key.starts_with(|c: char| c.is_ascii_digit())
&& key.chars().all(|c| c.is_alphanumeric() || c == '_');
if simple {
path.push('.');
path.push_str(key);
} else {
let escaped = key.replace('\\', "\\\\").replace('\'', "\\'");
path.push_str(&format!("['{escaped}']"));
}
}
fn escape_hurl(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'{' => out.push_str("\\u{007b}"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{{{:04x}}}", c as u32)),
c => out.push(c),
}
}
out
}
pub fn literal(v: &Value) -> Option<String> {
match v {
Value::Null => Some("null".to_string()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => plain_number(n),
Value::String(s) => Some(format!("\"{}\"", escape_hurl(s))),
Value::Array(_) | Value::Object(_) => None,
}
}
fn plain_number(n: &serde_json::Number) -> Option<String> {
let s = n.to_string();
let Some((mantissa, exponent)) = s.split_once(['e', 'E']) else {
return Some(s);
};
let exponent: i32 = exponent.parse().ok()?;
let (sign, mantissa) = match mantissa.strip_prefix('-') {
Some(rest) => ("-", rest),
None => ("", mantissa),
};
let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let digits = format!("{whole}{fraction}");
let point = whole.len() as i32 + exponent;
if point.abs() > 40 {
return None;
}
let mut out = if point <= 0 {
format!("0.{}{}", "0".repeat(-point as usize), digits)
} else if point as usize >= digits.len() {
format!("{}{}", digits, "0".repeat(point as usize - digits.len()))
} else {
let (l, r) = digits.split_at(point as usize);
format!("{l}.{r}")
};
if out.contains('.') {
out = out.trim_end_matches('0').trim_end_matches('.').to_string();
}
Some(format!("{sign}{out}"))
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum Verb {
Assert(Predicate),
ExpectStatus(u16),
Capture,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Probe {
pub subject: Subject,
pub value: Option<Value>,
}
pub const MAX_PROBES: usize = 5_000;
const MAX_DEPTH: usize = 128;
pub fn probes(
status: u16,
duration_ms: Option<u64>,
headers: &[(String, String)],
body: &str,
) -> Vec<Probe> {
let mut out = Vec::new();
if status != 0 {
out.push(Probe {
subject: Subject::Status,
value: Some(Value::from(status)),
});
}
if let Some(ms) = duration_ms {
out.push(Probe {
subject: Subject::Duration,
value: Some(Value::from(ms)),
});
}
for (name, value) in headers {
out.push(Probe {
subject: Subject::Header(name.clone()),
value: Some(Value::String(value.clone())),
});
}
match serde_json::from_str::<Value>(body) {
Ok(v) => walk(&v, &mut String::from("$"), 0, &mut out),
Err(_) if !body.trim().is_empty() => out.push(Probe {
subject: Subject::Body,
value: None,
}),
Err(_) => {}
}
out
}
fn walk(v: &Value, path: &mut String, depth: usize, out: &mut Vec<Probe>) {
if out.len() >= MAX_PROBES || depth > MAX_DEPTH {
return;
}
match v {
Value::Array(items) => {
out.push(Probe {
subject: Subject::JsonCount(path.clone()),
value: Some(Value::from(items.len())),
});
for (i, item) in items.iter().enumerate() {
let mark = path.len();
path.push_str(&format!("[{i}]"));
walk(item, path, depth + 1, out);
path.truncate(mark);
}
}
Value::Object(map) => {
out.push(Probe {
subject: Subject::Json(path.clone()),
value: Some(v.clone()),
});
for (k, item) in map {
let mark = path.len();
push_key(path, k);
walk(item, path, depth + 1, out);
path.truncate(mark);
}
}
scalar => out.push(Probe {
subject: Subject::Json(path.clone()),
value: Some(scalar.clone()),
}),
}
}
pub fn deep_equality(base: &str, value: &Value) -> Vec<(Subject, Predicate)> {
let mut out = Vec::new();
let mut path = base.to_string();
deep_walk(value, &mut path, 0, &mut out);
out
}
fn deep_walk(v: &Value, path: &mut String, depth: usize, out: &mut Vec<(Subject, Predicate)>) {
if depth > MAX_DEPTH {
return;
}
match v {
Value::Array(items) => {
out.push((
Subject::JsonCount(path.clone()),
Predicate::Eq(items.len().to_string()),
));
for (i, item) in items.iter().enumerate() {
let mark = path.len();
path.push_str(&format!("[{i}]"));
deep_walk(item, path, depth + 1, out);
path.truncate(mark);
}
}
Value::Object(map) => {
for (k, item) in map {
let mark = path.len();
push_key(path, k);
deep_walk(item, path, depth + 1, out);
path.truncate(mark);
}
}
scalar => {
if let Some(lit) = literal(scalar) {
out.push((Subject::Json(path.clone()), Predicate::Eq(lit)));
}
}
}
}
pub fn default_predicates(probe: &Probe) -> Vec<Predicate> {
let mut out = Vec::new();
let value = probe.value.as_ref();
match &probe.subject {
Subject::Status => {
if let Some(v) = value.and_then(literal) {
out.push(Predicate::Eq(v));
}
}
Subject::Duration => {
let ms = value.and_then(|v| v.as_u64()).unwrap_or(0);
let budget = (ms.max(100) * 2).div_ceil(100) * 100;
out.push(Predicate::Lt(budget.to_string()));
}
Subject::Header(_) | Subject::Json(_) => {
let container = matches!(value, Some(Value::Object(_) | Value::Array(_)));
if let Some(v) = value.and_then(literal) {
out.push(Predicate::Eq(v.clone()));
out.push(Predicate::Ne(v.clone()));
if matches!(value, Some(Value::String(_))) {
out.push(Predicate::Contains(v.clone()));
out.push(Predicate::StartsWith(v));
}
}
out.push(Predicate::Exists);
if container || matches!(value, Some(Value::String(_)) | None) {
out.push(Predicate::Empty(false));
out.push(Predicate::Empty(true));
}
}
Subject::JsonCount(_) => {
if let Some(v) = value.and_then(literal) {
out.push(Predicate::Eq(v));
}
out.push(Predicate::Gt("0".to_string()));
}
Subject::Body => {
out.push(Predicate::Contains(String::new()));
out.push(Predicate::Exists);
}
}
out
}
pub fn suggest_name(subject: &Subject, taken: &[String]) -> String {
let base = match subject {
Subject::Status => "status".to_string(),
Subject::Duration => "duration".to_string(),
Subject::Header(name) => sanitise(name),
Subject::Json(path) | Subject::JsonCount(path) => {
let leaf = path
.rsplit(|c| c == '.' || c == '[')
.map(|s| s.trim_end_matches([']', '\'']).trim_start_matches('\''))
.find(|s| !s.is_empty() && !s.bytes().all(|b| b.is_ascii_digit()))
.unwrap_or("value");
sanitise(leaf)
}
Subject::Body => "body".to_string(),
};
let base = if base.is_empty() {
"value".to_string()
} else {
base
};
if !taken.iter().any(|t| t == &base) {
return base;
}
(2..)
.map(|n| format!("{base}{n}"))
.find(|c| !taken.iter().any(|t| t == c))
.unwrap_or(base)
}
fn sanitise(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c.is_alphanumeric() || c == '_' {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('_') && !out.is_empty() {
out.push('_');
}
}
let trimmed = out.trim_matches('_').to_string();
if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
format!("v{trimmed}")
} else {
trimmed
}
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn probe_at(body: &str, offset: usize) -> Option<Probe> {
let value: Value = serde_json::from_str(body).ok()?;
let mut spans: Vec<(Range<usize>, String)> = Vec::new();
let mut scan = Scan {
s: body.as_bytes(),
i: 0,
};
scan.ws();
scan.value(&mut String::from("$"), 0, &mut spans)?;
let path = spans
.into_iter()
.filter(|(r, _)| r.contains(&offset) || r.end == offset)
.min_by_key(|(r, _)| r.end - r.start)
.map(|(_, p)| p)?;
let at = value_at(&value, &path)?;
Some(Probe {
subject: match at {
Value::Array(_) => Subject::JsonCount(path),
_ => Subject::Json(path),
},
value: Some(at.clone()),
})
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
fn value_at<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = root;
let mut s = path.strip_prefix('$')?;
while !s.is_empty() {
if let Some(rest) = s.strip_prefix('.') {
let end = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
cur = cur.get(&rest[..end])?;
s = &rest[end..];
} else {
let rest = s.strip_prefix('[')?;
if let Some(quoted) = rest.strip_prefix('\'') {
let (key, after) = unescape_bracket_key(quoted)?;
cur = cur.get(&key)?;
s = after;
} else {
let close = rest.find(']')?;
cur = cur.get(rest[..close].parse::<usize>().ok()?)?;
s = &rest[close + 1..];
}
}
}
Some(cur)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
fn unescape_bracket_key(s: &str) -> Option<(String, &str)> {
let mut key = String::new();
let mut it = s.char_indices();
while let Some((i, c)) = it.next() {
match c {
'\\' => key.push(it.next()?.1),
'\'' => return s[i + 1..].strip_prefix(']').map(|rest| (key, rest)),
c => key.push(c),
}
}
None
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
struct Scan<'a> {
s: &'a [u8],
i: usize,
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
impl Scan<'_> {
fn ws(&mut self) {
while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() {
self.i += 1;
}
}
fn value(
&mut self,
path: &mut String,
depth: usize,
out: &mut Vec<(Range<usize>, String)>,
) -> Option<()> {
if depth > MAX_DEPTH {
return None;
}
let start = self.i;
match *self.s.get(self.i)? {
b'{' => {
self.i += 1;
loop {
self.ws();
match *self.s.get(self.i)? {
b'}' => {
self.i += 1;
break;
}
b',' => {
self.i += 1;
continue;
}
b'"' => {}
_ => return None,
}
let key_start = self.i;
let key = self.string()?;
let key_span = key_start..self.i;
self.ws();
if *self.s.get(self.i)? != b':' {
return None;
}
self.i += 1;
self.ws();
let mark = path.len();
push_key(path, &key);
out.push((key_span, path.clone()));
self.value(path, depth + 1, out)?;
path.truncate(mark);
}
}
b'[' => {
self.i += 1;
let mut idx = 0usize;
loop {
self.ws();
match *self.s.get(self.i)? {
b']' => {
self.i += 1;
break;
}
b',' => {
self.i += 1;
continue;
}
_ => {}
}
let mark = path.len();
path.push_str(&format!("[{idx}]"));
self.value(path, depth + 1, out)?;
path.truncate(mark);
idx += 1;
}
}
b'"' => {
self.string()?;
}
_ => {
let end = self.s[self.i..]
.iter()
.position(|b| matches!(b, b',' | b'}' | b']') || b.is_ascii_whitespace())
.map(|n| self.i + n)
.unwrap_or(self.s.len());
if end == self.i {
return None;
}
self.i = end;
}
}
out.push((start..self.i, path.clone()));
Some(())
}
fn string(&mut self) -> Option<String> {
if *self.s.get(self.i)? != b'"' {
return None;
}
self.i += 1;
let start = self.i;
while self.i < self.s.len() {
match self.s[self.i] {
b'\\' => self.i += 2,
b'"' => {
let raw = std::str::from_utf8(&self.s[start..self.i]).ok()?;
self.i += 1;
return serde_json::from_str::<String>(&format!("\"{raw}\"")).ok();
}
_ => self.i += 1,
}
}
None
}
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn span_of(body: &str, subject: &Subject) -> Option<Range<usize>> {
spans_of(body, subject).map(|(_, value)| value)
}
#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn key_span_of(body: &str, subject: &Subject) -> Option<Range<usize>> {
let (key, value) = spans_of(body, subject)?;
(key != value).then_some(key)
}
fn spans_of(body: &str, subject: &Subject) -> Option<(Range<usize>, Range<usize>)> {
let wanted = match subject {
Subject::Json(p) | Subject::JsonCount(p) => p,
_ => return None,
};
serde_json::from_str::<Value>(body).ok()?;
let mut spans: Vec<(Range<usize>, String)> = Vec::new();
let mut scan = Scan {
s: body.as_bytes(),
i: 0,
};
scan.ws();
scan.value(&mut String::from("$"), 0, &mut spans)?;
let mut mine = spans
.into_iter()
.filter(|(_, p)| p == wanted)
.map(|(r, _)| r);
let first = mine.next()?;
let last = mine.next_back().unwrap_or_else(|| first.clone());
Some((first, last))
}
pub(crate) fn verbs_for(probe: &Probe, selection: Option<&str>) -> Vec<Verb> {
let mut out = Vec::new();
for predicate in default_predicates(probe) {
match (&probe.subject, &predicate) {
(Subject::Status, Predicate::Eq(v)) => match v.parse::<u16>() {
Ok(code) => out.push(Verb::ExpectStatus(code)),
Err(_) => continue,
},
(Subject::Body, Predicate::Contains(_)) => {
let Some(text) = selection.map(str::trim).filter(|t| !t.is_empty()) else {
continue;
};
out.push(Verb::Assert(Predicate::Contains(
literal(&serde_json::Value::String(text.to_string())).unwrap_or_default(),
)));
}
_ => {
if assert_line(probe.subject.clone(), predicate.clone()).is_some() {
out.push(Verb::Assert(predicate));
}
}
}
}
if !matches!(probe.subject, Subject::Body) {
out.push(Verb::Capture);
}
out
}
pub(crate) fn subject_label(subject: &Subject) -> String {
match subject {
Subject::Json(path) => path.clone(),
Subject::JsonCount(path) => format!("{path} []"),
Subject::Header(name) => format!("header {name}"),
Subject::Status => "status".to_string(),
Subject::Duration => "duration".to_string(),
Subject::Body => "body".to_string(),
}
}
pub(crate) fn value_preview(value: Option<&serde_json::Value>, max: usize) -> String {
let Some(value) = value else {
return String::new();
};
let raw = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(m) => format!("{{{}}}", m.len()),
serde_json::Value::Array(a) => format!("[{}]", a.len()),
other => other.to_string(),
};
let raw = raw.replace(['\n', '\r', '\t'], " ");
if max == usize::MAX || raw.chars().count() <= max {
return raw;
}
let head: String = raw.chars().take(max.saturating_sub(1)).collect();
format!("{head}…")
}
pub(crate) fn verb_label(subject: &Subject, verb: &Verb, s: &Strings) -> String {
match verb {
Verb::Assert(predicate) => assert_line(subject.clone(), predicate.clone())
.unwrap_or_else(|| s.probe_verb_unavailable.to_string()),
Verb::ExpectStatus(code) => format!("HTTP {code}"),
Verb::Capture => s.probe_verb_capture.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn paths(body: &str) -> Vec<String> {
probes(0, None, &[], body)
.into_iter()
.map(|p| match p.subject {
Subject::Json(p) => p,
Subject::JsonCount(p) => format!("{p} count"),
other => other.query(),
})
.collect()
}
#[test]
fn a_path_points_back_at_the_bytes_it_came_from() {
let body = r#"{"status":"ok","token":"abc","n":[1,2]}"#;
let at = |path: &str| {
let r = span_of(body, &Subject::Json(path.to_string())).expect(path);
body[r].to_string()
};
assert_eq!(at("$.token"), r#""abc""#);
assert_eq!(at("$.status"), r#""ok""#);
assert_eq!(at("$.n[1]"), "2");
assert_eq!(
span_of(body, &Subject::JsonCount("$.n".into())).map(|r| body[r].to_string()),
Some("[1,2]".to_string())
);
assert_eq!(span_of(body, &Subject::Json("$.nope".into())), None);
assert_eq!(span_of(body, &Subject::Header("X".into())), None);
assert_eq!(span_of(body, &Subject::Status), None);
assert_eq!(span_of("not json", &Subject::Json("$".into())), None);
}
#[test]
fn a_field_can_also_point_back_at_its_name() {
let body = r#"{"status":"ok","token":"abc","n":[1,2]}"#;
let at = |path: &str| {
key_span_of(body, &Subject::Json(path.to_string())).map(|r| body[r].to_string())
};
assert_eq!(at("$.token"), Some(r#""token""#.to_string()));
assert_eq!(at("$.n"), Some(r#""n""#.to_string()));
assert_eq!(at("$.n[1]"), None, "an array element has no name");
assert_eq!(at("$"), None, "the whole body has no name");
assert_eq!(at("$.nope"), None);
}
#[test]
fn the_span_and_the_offset_agree_with_each_other() {
let body = r#"{"a":{"b":[{"c":"x"}]},"d":12}"#;
for path in ["$.a.b[0].c", "$.d", "$.a"] {
let subject = Subject::Json(path.to_string());
let span = span_of(body, &subject).expect(path);
let back = probe_at(body, span.start).expect(path);
let back = match back.subject {
Subject::Json(p) | Subject::JsonCount(p) => p,
other => other.query(),
};
assert_eq!(back, path);
}
}
#[test]
fn an_awkward_key_still_maps_back_to_its_value() {
let body = r#"{"a.b":1,"c\"d":2}"#;
let mut p1 = "$".to_string();
push_key(&mut p1, "a.b");
let mut p2 = "$".to_string();
push_key(&mut p2, "c\"d");
assert_eq!(
span_of(body, &Subject::Json(p1)).map(|r| body[r].to_string()),
Some("1".to_string())
);
assert_eq!(
span_of(body, &Subject::Json(p2)).map(|r| body[r].to_string()),
Some("2".to_string())
);
}
#[test]
fn every_value_in_a_body_is_offered_in_document_order() {
let body = r#"{"id":7,"user":{"name":"ada","tags":["a","b"]}}"#;
assert_eq!(
paths(body),
[
"$",
"$.id",
"$.user",
"$.user.name",
"$.user.tags count",
"$.user.tags[0]",
"$.user.tags[1]",
]
);
}
#[test]
fn keys_that_are_not_identifiers_are_bracket_quoted() {
assert_eq!(
paths(r#"{"content-type":1,"2fa":2,"it's":3}"#),
["$", "$['content-type']", "$['2fa']", "$['it\\'s']"]
);
}
#[test]
fn the_exchange_itself_is_assertable() {
let headers = [("Content-Type".to_string(), "application/json".to_string())];
let list = probes(201, Some(42), &headers, "null");
assert_eq!(list[0].subject, Subject::Status);
assert_eq!(list[0].value, Some(json!(201)));
assert_eq!(list[1].subject, Subject::Duration);
assert_eq!(list[2].subject, Subject::Header("Content-Type".into()));
assert_eq!(list[3].subject, Subject::Json("$".into()));
}
#[test]
fn a_body_that_is_not_json_falls_back_to_the_text() {
let list = probes(500, None, &[], "<html>boom</html>");
assert_eq!(list.last().unwrap().subject, Subject::Body);
assert!(
probes(204, None, &[], " ")
.iter()
.all(|p| p.subject != Subject::Body)
);
}
#[test]
fn a_huge_body_stops_at_the_cap() {
let body = format!("[{}]", vec!["1"; MAX_PROBES + 500].join(","));
assert!(probes(0, None, &[], &body).len() <= MAX_PROBES + 1);
}
#[test]
fn values_are_escaped_into_hurl_literals() {
assert_eq!(literal(&json!("a\"b\\c\nd")).unwrap(), r#""a\"b\\c\nd""#);
assert_eq!(literal(&json!(1.5)).unwrap(), "1.5");
assert_eq!(literal(&json!(true)).unwrap(), "true");
assert_eq!(literal(&json!(null)).unwrap(), "null");
assert!(literal(&json!({"a":1})).is_none());
assert!(literal(&json!([1])).is_none());
}
#[test]
fn assert_lines_read_as_hurl() {
let eq = |s: Subject, v: &str| assert_line(s, Predicate::Eq(v.to_string()));
assert_eq!(
eq(Subject::Json("$.a".into()), "\"x\"").unwrap(),
r#"jsonpath "$.a" == "x""#
);
assert_eq!(
eq(Subject::JsonCount("$.a".into()), "3").unwrap(),
r#"jsonpath "$.a" count == 3"#
);
assert_eq!(
eq(Subject::Header("X-Id".into()), "\"1\"").unwrap(),
r#"header "X-Id" == "1""#
);
assert_eq!(
assert_line(Subject::Json("$.a".into()), Predicate::Empty(false)).unwrap(),
r#"jsonpath "$.a" not isEmpty"#
);
assert!(eq(Subject::Status, "200").is_none());
assert!(
assert_line(
Subject::JsonCount("$.a".into()),
Predicate::Contains("\"x\"".into())
)
.is_none()
);
assert!(assert_line(Subject::Duration, Predicate::Exists).is_none());
}
#[test]
fn the_first_predicate_offered_pins_the_value_that_came_back() {
let p = Probe {
subject: Subject::Json("$.name".into()),
value: Some(json!("ada")),
};
assert_eq!(default_predicates(&p)[0], Predicate::Eq("\"ada\"".into()));
assert!(default_predicates(&p).contains(&Predicate::Contains("\"ada\"".into())));
let n = Probe {
subject: Subject::Json("$.n".into()),
value: Some(json!(4)),
};
assert!(
!default_predicates(&n)
.iter()
.any(|p| matches!(p, Predicate::Contains(_)))
);
let d = Probe {
subject: Subject::Duration,
value: Some(json!(214)),
};
assert_eq!(default_predicates(&d), [Predicate::Lt("500".into())]);
}
#[test]
fn capture_names_come_from_the_field_they_capture() {
let taken = ["token".to_string()];
assert_eq!(
suggest_name(&Subject::Json("$.data.token".into()), &[]),
"token"
);
assert_eq!(
suggest_name(&Subject::Json("$.data.token".into()), &taken),
"token2"
);
assert_eq!(
suggest_name(&Subject::Json("$.items[3]".into()), &[]),
"items"
);
assert_eq!(
suggest_name(&Subject::Header("Content-Type".into()), &[]),
"content_type"
);
assert_eq!(suggest_name(&Subject::Json("$['2fa']".into()), &[]), "v2fa");
}
#[test]
fn a_capture_row_is_the_query_under_a_name() {
assert_eq!(
capture_row(&Subject::Json("$.token".into()), "tok"),
("tok".to_string(), "jsonpath \"$.token\"".to_string())
);
}
#[test]
fn an_offset_resolves_to_the_value_under_it() {
let body = r#"{"id":7,"user":{"name":"ada","tags":["a","bb"]}}"#;
let at = |needle: &str| {
probe_at(body, body.find(needle).unwrap())
.map(|p| p.subject)
.unwrap()
};
assert_eq!(at("7"), Subject::Json("$.id".into()));
assert_eq!(at("\"ada\""), Subject::Json("$.user.name".into()));
assert_eq!(at("\"bb\""), Subject::Json("$.user.tags[1]".into()));
assert_eq!(at("\"name\""), Subject::Json("$.user.name".into()));
assert_eq!(at("[\"a\""), Subject::JsonCount("$.user.tags".into()));
assert_eq!(
probe_at(body, 0).unwrap().subject,
Subject::Json("$".into())
);
}
#[test]
fn pointing_works_on_a_pretty_printed_body_too() {
let body = "{\n \"a\": {\n \"b\": [1, 22, 333]\n }\n}";
let p = probe_at(body, body.find("22").unwrap()).unwrap();
assert_eq!(p.subject, Subject::Json("$.a.b[1]".into()));
assert_eq!(p.value, Some(json!(22)));
}
#[test]
fn pointing_at_an_awkward_key_round_trips() {
let body = r#"{"a-b":{"c":"\u00e9 \"q\""}}"#;
let p = probe_at(body, body.find("\\u00e9").unwrap()).unwrap();
assert_eq!(p.subject, Subject::Json("$['a-b'].c".into()));
assert_eq!(p.value, Some(json!("é \"q\"")));
}
fn hurl_reads(line: &str) -> Result<(), String> {
let text = format!("GET http://h/a\nHTTP 200\n[Asserts]\n{line}\n");
hurl_core::parser::parse_hurl_file(&text)
.map(|_| ())
.map_err(|e| format!("{:?} at {:?}", e.kind, e.pos))
}
#[test]
fn a_key_hurl_would_choke_on_is_escaped_for_both_layers() {
for (_body, key) in [
(r#"{"a\"b":1}"#, "a\"b"),
(r#"{"a\\b":1}"#, "a\\b"),
(r#"{"a\nb":1}"#, "a\nb"),
] {
let subject = Subject::Json({
let mut path = "$".to_string();
push_key(&mut path, key);
path
});
let line = assert_line(subject, Predicate::Eq("1".into())).unwrap();
assert!(hurl_reads(&line).is_ok(), "{line}: {:?}", hurl_reads(&line));
}
}
#[test]
fn a_backslash_in_a_key_reaches_the_evaluator_unchanged() {
let mut path = "$".to_string();
push_key(&mut path, "a\\b");
let line = assert_line(Subject::Json(path.clone()), Predicate::Eq("1".into())).unwrap();
let text = format!("GET http://h/a\nHTTP 200\n[Asserts]\n{line}\n");
let file = hurl_core::parser::parse_hurl_file(&text).unwrap();
let source = format!("{:?}", file);
assert!(
source.contains(&format!("value: {path:?}")),
"the evaluator must receive the path we built, not a decoded copy: {line}"
);
}
#[test]
fn a_response_value_that_looks_like_a_placeholder_is_compared_as_text() {
let line = assert_line(
Subject::Json("$.greeting".into()),
Predicate::Eq(literal(&json!("Hello {{ user.name }}")).unwrap()),
)
.unwrap();
assert!(hurl_reads(&line).is_ok(), "{line}");
let text = format!("GET http://h/a\nHTTP 200\n[Asserts]\n{line}\n");
let file = hurl_core::parser::parse_hurl_file(&text).unwrap();
assert!(
!format!("{:?}", file).contains("Placeholder"),
"the value came back as a template, not as text: {line}"
);
}
#[test]
fn an_unbalanced_brace_pair_still_leaves_a_readable_file() {
let line = assert_line(
Subject::Json("$.greeting".into()),
Predicate::Eq(literal(&json!("Hello {{ user.name }")).unwrap()),
)
.unwrap();
assert!(hurl_reads(&line).is_ok(), "{line}");
}
#[test]
fn every_number_a_response_can_carry_becomes_a_line_hurl_reads() {
for body in [
r#"{"n":1.5e-3}"#,
r#"{"n":1e10}"#,
r#"{"n":1E+2}"#,
r#"{"n":-2.5E3}"#,
r#"{"n":0.0015}"#,
r#"{"n":3.14}"#,
r#"{"n":12345678901234567890}"#,
r#"{"n":-0}"#,
] {
let probe = probes(0, None, &[], body)
.into_iter()
.find(|p| matches!(&p.subject, Subject::Json(p) if p == "$.n"))
.unwrap();
let line =
assert_line(probe.subject.clone(), default_predicates(&probe)[0].clone()).unwrap();
assert!(hurl_reads(&line).is_ok(), "{body} -> {line}");
}
}
#[test]
fn an_expanded_number_still_says_what_the_response_said() {
for (src, want) in [
("1.5e-3", "0.0015"),
("1e10", "10000000000"),
("1E+2", "100"),
("-2.5E3", "-2500"),
("3.14", "3.14"),
] {
let v: Value = serde_json::from_str(src).unwrap();
assert_eq!(literal(&v).as_deref(), Some(want), "{src}");
}
}
#[test]
fn a_number_too_big_to_write_out_declines_rather_than_rounding() {
let v: Value = serde_json::from_str("1e300").unwrap();
assert_eq!(literal(&v), None);
}
#[test]
fn pointing_into_something_that_is_not_json_finds_nothing() {
assert!(probe_at("<html>", 2).is_none());
assert!(probe_at("", 0).is_none());
}
}