use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum CountBound {
Exact(u64),
AtLeast(u64),
AtMost(u64),
Range(u64, u64),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum PathFilter {
Exact(String),
Contains(String),
Matches(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct RequestExpectation {
pub bound: CountBound,
pub method: Option<String>,
pub path: Option<PathFilter>,
pub query: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Expectation {
Equals(serde_json::Value),
Regex(String),
Contains(String),
StartsWith(String),
EndsWith(String),
Exists,
JsonSubset(serde_json::Value),
Any,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RowsExpectation {
pub columns: Option<Vec<String>>,
pub unordered: bool,
pub rows: Option<Vec<Vec<Expectation>>>,
pub bound: Option<CountBound>,
}
pub fn bound_holds(bound: &CountBound, actual: usize) -> bool {
let actual = actual as u64;
match bound {
CountBound::Exact(n) => actual == *n,
CountBound::AtLeast(n) => actual >= *n,
CountBound::AtMost(n) => actual <= *n,
CountBound::Range(min, max) => actual >= *min && actual <= *max,
}
}
pub fn settles_early(bound: &CountBound, actual: usize) -> bool {
match bound {
CountBound::Exact(_) | CountBound::AtLeast(_) => bound_holds(bound, actual),
CountBound::AtMost(_) | CountBound::Range(..) => false,
}
}
pub fn above_ceiling(bound: &CountBound, actual: usize) -> bool {
let actual = actual as u64;
match bound {
CountBound::Exact(_) | CountBound::AtLeast(_) => false,
CountBound::AtMost(n) => actual > *n,
CountBound::Range(_, max) => actual > *max,
}
}
pub fn query_pairs(path_and_query: &str) -> Vec<(String, String)> {
match path_and_query.split_once('?') {
Some((_, query)) => form_urlencoded::parse(query.as_bytes())
.map(|(key, value)| (key.into_owned(), value.into_owned()))
.collect(),
None => Vec::new(),
}
}
pub fn matching_count<'a>(
requests: impl IntoIterator<Item = (&'a str, &'a str)>,
method: Option<&str>,
path_filter: Option<&PathFilter>,
query: Option<&BTreeMap<String, String>>,
) -> usize {
let matches_regex = match path_filter {
Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
_ => None,
};
requests
.into_iter()
.filter(|(request_method, path)| {
let path_matches = match path_filter {
None => true,
Some(PathFilter::Exact(p)) => p.as_str() == *path,
Some(PathFilter::Contains(s)) => path.contains(s.as_str()),
Some(PathFilter::Matches(_)) => {
matches_regex.as_ref().is_some_and(|re| re.is_match(path))
}
};
let query_subset = query.is_none_or(|declared| {
let pairs = query_pairs(path);
declared
.iter()
.all(|(key, value)| pairs.iter().any(|(k, v)| k == key && v == value))
});
method.is_none_or(|m| m.eq_ignore_ascii_case(request_method))
&& path_matches
&& query_subset
})
.count()
}
pub fn render_bound(bound: &CountBound) -> String {
match bound {
CountBound::Exact(n) => format!("expected {n}"),
CountBound::AtLeast(n) => format!("expected at least {n}"),
CountBound::AtMost(n) => format!("expected at most {n}"),
CountBound::Range(min, max) => format!("expected between {min} and {max}"),
}
}
pub fn expectation_matches(expectation: &Expectation, value: &serde_json::Value) -> bool {
match expectation {
Expectation::Equals(expected) => value == expected,
Expectation::Regex(pattern) => {
regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&stringify(value)))
}
Expectation::Contains(needle) => stringify(value).contains(needle),
Expectation::StartsWith(prefix) => stringify(value).starts_with(prefix),
Expectation::EndsWith(suffix) => stringify(value).ends_with(suffix),
Expectation::Exists => value != &serde_json::Value::Null,
Expectation::JsonSubset(pattern) => json_subset(pattern, value),
Expectation::Any => true,
}
}
pub fn rows_match(
expected: &[Vec<Expectation>],
actual: &[Vec<serde_json::Value>],
unordered: bool,
) -> bool {
if expected.len() != actual.len() {
return false;
}
if !unordered {
return expected
.iter()
.zip(actual)
.all(|(pattern, row)| row_pattern_matches(pattern, row));
}
let compat: Vec<Vec<bool>> = expected
.iter()
.map(|pattern| {
actual
.iter()
.map(|row| row_pattern_matches(pattern, row))
.collect()
})
.collect();
let mut match_of_row: Vec<Option<usize>> = vec![None; actual.len()];
for pattern in 0..expected.len() {
let mut visited = vec![false; actual.len()];
if !try_augment(pattern, &compat, &mut match_of_row, &mut visited) {
return false;
}
}
true
}
fn row_pattern_matches(pattern: &[Expectation], row: &[serde_json::Value]) -> bool {
pattern.len() == row.len()
&& pattern
.iter()
.zip(row)
.all(|(expectation, value)| expectation_matches(expectation, value))
}
fn try_augment(
pattern: usize,
compat: &[Vec<bool>],
match_of_row: &mut [Option<usize>],
visited: &mut [bool],
) -> bool {
for (row, &compatible) in compat[pattern].iter().enumerate() {
if !compatible || visited[row] {
continue;
}
visited[row] = true;
match match_of_row[row] {
None => {
match_of_row[row] = Some(pattern);
return true;
}
Some(holder) => {
if try_augment(holder, compat, match_of_row, visited) {
match_of_row[row] = Some(pattern);
return true;
}
}
}
}
false
}
pub fn stringify(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn json_subset(pattern: &serde_json::Value, actual: &serde_json::Value) -> bool {
match (pattern, actual) {
(serde_json::Value::Object(pattern_object), serde_json::Value::Object(actual_object)) => {
pattern_object.iter().all(|(key, pattern_value)| {
actual_object
.get(key)
.is_some_and(|actual_value| json_subset(pattern_value, actual_value))
})
}
_ => pattern == actual,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bound_holds_covers_every_form_at_edges() {
let cases = [
(CountBound::Exact(2), [false, false, true, false, false]),
(CountBound::AtLeast(2), [false, false, true, true, true]),
(CountBound::AtMost(2), [true, true, true, false, false]),
(CountBound::Range(1, 3), [false, true, true, true, false]),
];
for (bound, holds) in cases {
for (count, expected) in holds.into_iter().enumerate() {
assert_eq!(
bound_holds(&bound, count),
expected,
"{bound:?} at count {count}"
);
}
}
}
#[test]
fn settles_early_absence_claims_never_settle() {
for count in 0..=5usize {
assert!(
!settles_early(&CountBound::AtMost(5), count),
"AtMost(5) at count {count}"
);
assert!(
!settles_early(&CountBound::Range(0, 5), count),
"Range(0, 5) at count {count}"
);
assert_eq!(
settles_early(&CountBound::Exact(2), count),
count == 2,
"Exact(2) at count {count}"
);
assert_eq!(
settles_early(&CountBound::AtLeast(2), count),
count >= 2,
"AtLeast(2) at count {count}"
);
}
}
#[test]
fn above_ceiling_only_upper_breaches() {
assert!(above_ceiling(&CountBound::AtMost(2), 3));
assert!(above_ceiling(&CountBound::Range(1, 2), 3));
assert!(!above_ceiling(&CountBound::Exact(2), 99));
assert!(!above_ceiling(&CountBound::AtLeast(2), 99));
assert!(!above_ceiling(&CountBound::AtMost(2), 2));
}
#[test]
fn matching_count_query_subset_order_and_encoding_independent() {
let declared = BTreeMap::from([
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string()),
]);
let reordered_and_encoded = [("GET", "/x?b=2&a=1"), ("POST", "/x?a=%31&b=%32")];
assert_eq!(
matching_count(reordered_and_encoded, None, None, Some(&declared)),
2
);
assert_eq!(
matching_count([("GET", "/x?a=1")], None, None, Some(&declared)),
0
);
let only_a = BTreeMap::from([("a".to_string(), "1".to_string())]);
assert_eq!(
matching_count([("GET", "/x?a=1")], None, None, Some(&only_a)),
1
);
assert_eq!(
matching_count([("GET", "/x?b=2")], None, None, Some(&declared)),
0
);
}
#[test]
fn matching_count_invalid_regex_fails_closed() {
let filter = PathFilter::Matches("(".to_string());
assert_eq!(
matching_count([("GET", "/anything")], None, Some(&filter), None),
0
);
}
#[test]
fn matching_count_method_case_insensitive() {
let requests = [("POST", "/o"), ("GET", "/o")];
assert_eq!(matching_count(requests, Some("post"), None, None), 1);
}
#[test]
fn matching_count_path_forms() {
let requests = [("GET", "/o?a=1"), ("POST", "/o?a=1&x=2"), ("GET", "/diff")];
let exact = PathFilter::Exact("/o?a=1".to_string());
let contains = PathFilter::Contains("/o".to_string());
let matches = PathFilter::Matches("^/o".to_string());
assert_eq!(matching_count(requests, None, Some(&exact), None), 1);
assert_eq!(matching_count(requests, None, Some(&contains), None), 2);
assert_eq!(matching_count(requests, None, Some(&matches), None), 2);
}
#[test]
fn query_pairs_no_question_mark() {
assert!(query_pairs("/noquery").is_empty());
assert_eq!(
query_pairs("/q?a=1"),
vec![("a".to_string(), "1".to_string())]
);
}
#[test]
fn query_pairs_plus_decoding() {
assert_eq!(
query_pairs("/x?a=1+2"),
vec![("a".to_string(), "1 2".to_string())]
);
}
#[test]
fn expectation_matches_string_forms() {
let value = serde_json::json!("hello world");
assert!(expectation_matches(
&Expectation::Contains("world".to_string()),
&value
));
assert!(expectation_matches(
&Expectation::StartsWith("hello".to_string()),
&value
));
assert!(expectation_matches(
&Expectation::EndsWith("world".to_string()),
&value
));
assert!(expectation_matches(&Expectation::Exists, &value));
assert!(expectation_matches(
&Expectation::Regex("^hello".to_string()),
&value
));
assert!(expectation_matches(
&Expectation::Equals(serde_json::json!("hello world")),
&value
));
assert!(!expectation_matches(
&Expectation::Contains("nope".to_string()),
&value
));
}
#[test]
fn expectation_matches_object_forms() {
let value = serde_json::json!({"n": "café", "s": "hello world"});
assert!(expectation_matches(
&Expectation::Equals(serde_json::json!({"n": "café", "s": "hello world"})),
&value
));
assert!(expectation_matches(
&Expectation::JsonSubset(serde_json::json!({"n": "café"})),
&value
));
assert!(expectation_matches(
&Expectation::Regex("caf".to_string()),
&value
));
assert!(expectation_matches(&Expectation::Exists, &value));
assert!(!expectation_matches(
&Expectation::JsonSubset(serde_json::json!({"n": "other"})),
&value
));
assert!(!expectation_matches(
&Expectation::Exists,
&serde_json::Value::Null
));
assert!(!expectation_matches(
&Expectation::Regex("(".to_string()),
&value
));
}
#[test]
fn json_subset_recursive_objects() {
let actual = serde_json::json!({"user": {"name": "María", "role": "admin"}, "extra": 1});
assert!(expectation_matches(
&Expectation::JsonSubset(serde_json::json!({"user": {"name": "María"}})),
&actual
));
assert!(!expectation_matches(
&Expectation::JsonSubset(serde_json::json!({"user": {"name": "other"}})),
&actual
));
}
#[test]
fn any_matches_all_values_including_null() {
for value in [
serde_json::json!(null),
serde_json::json!(0),
serde_json::json!("x"),
serde_json::json!([1, 2]),
serde_json::json!({"k": "v"}),
] {
assert!(
expectation_matches(&Expectation::Any, &value),
"Any vs {value}"
);
}
}
#[test]
fn any_distinct_from_exists() {
assert!(!expectation_matches(
&Expectation::Exists,
&serde_json::Value::Null
));
assert!(expectation_matches(
&Expectation::Any,
&serde_json::Value::Null
));
}
fn two_row_pattern() -> Vec<Vec<Expectation>> {
vec![
vec![
Expectation::Equals(serde_json::json!(1)),
Expectation::Contains("li".to_string()),
],
vec![Expectation::Equals(serde_json::json!(2)), Expectation::Any],
]
}
#[test]
fn rows_match_ordered_positional() {
let expected = two_row_pattern();
let actual = vec![
vec![serde_json::json!(1), serde_json::json!("alice")],
vec![serde_json::json!(2), serde_json::json!("bob")],
];
assert!(rows_match(&expected, &actual, false));
let swapped = vec![actual[1].clone(), actual[0].clone()];
assert!(!rows_match(&expected, &swapped, false));
}
#[test]
fn rows_match_length_mismatch_fails() {
let expected = two_row_pattern();
let actual = vec![vec![serde_json::json!(1), serde_json::json!("alice")]];
assert!(!rows_match(&expected, &actual, false));
assert!(!rows_match(&expected, &actual, true));
}
#[test]
fn rows_match_unordered_reorder() {
let expected = two_row_pattern();
let actual = vec![
vec![serde_json::json!(2), serde_json::json!("bob")],
vec![serde_json::json!(1), serde_json::json!("alice")],
];
assert!(rows_match(&expected, &actual, true));
}
#[test]
fn rows_match_unordered_duplicates() {
let expected = vec![
vec![Expectation::Equals(serde_json::json!(1))],
vec![Expectation::Equals(serde_json::json!(1))],
];
let same = vec![vec![serde_json::json!(1)], vec![serde_json::json!(1)]];
assert!(rows_match(&expected, &same, true));
let mixed = vec![vec![serde_json::json!(1)], vec![serde_json::json!(2)]];
assert!(!rows_match(&expected, &mixed, true));
}
#[test]
fn rows_match_kuhn_needs_augmenting() {
let expected = vec![
vec![Expectation::Any, Expectation::Any],
vec![Expectation::Equals(serde_json::json!(1)), Expectation::Any],
];
let actual = vec![
vec![serde_json::json!(1), serde_json::json!("x")],
vec![serde_json::json!(2), serde_json::json!("a")],
];
assert!(rows_match(&expected, &actual, true));
assert!(!rows_match(&expected, &actual, false));
}
#[test]
fn rows_match_wildcard_including_null_cells() {
let expected = vec![vec![Expectation::Any]];
let actual = vec![vec![serde_json::Value::Null]];
assert!(rows_match(&expected, &actual, false));
assert!(rows_match(&expected, &actual, true));
}
#[test]
fn render_bound_forms() {
let bounds = [
CountBound::Exact(3),
CountBound::AtLeast(3),
CountBound::AtMost(2),
CountBound::Range(2, 4),
];
let rendered: Vec<String> = bounds.iter().map(render_bound).collect();
for text in &rendered {
assert!(!text.is_empty(), "empty render for {text:?}");
}
for (index, left) in rendered.iter().enumerate() {
for right in &rendered[index + 1..] {
assert_ne!(left, right, "duplicate render `{left}`");
}
}
}
}