Skip to main content

camel_matchers/
lib.rs

1//! Pure matcher algebra shared by the test tiers: the message
2//! expectation grammar, the recorded-request count bound grammar, and
3//! the pure predicates over them. The crate has zero camel
4//! dependencies so unit and integration tiers can share one matcher
5//! implementation (ADR-0072).
6
7use std::collections::BTreeMap;
8
9/// The recorded-request count bound of a [`RequestExpectation`]:
10/// exactly one bound form per expectation. Poll semantics per bound
11/// (arrivals only add, so the filtered count is monotone
12/// non-decreasing):
13///
14/// - Without a deadline, one immediate snapshot decides for every
15///   bound.
16/// - [`CountBound::Exact`] polls until a snapshot's count equals `n`;
17///   a snapshot above never passes.
18/// - [`CountBound::AtLeast`] succeeds early, once the count reaches
19///   `n` (sound: the count only grows).
20/// - [`CountBound::AtMost`] is an absence claim over the window: it
21///   waits the full deadline, fails immediately on any snapshot above
22///   `n`, and decides on the final snapshot — an early passing
23///   snapshot cannot prove the count stays within bounds.
24/// - [`CountBound::Range`] fails immediately above the maximum and
25///   otherwise waits the full deadline, deciding on the final
26///   snapshot within `[min, max]`.
27///
28/// These semantics document a monotone subject: arrivals only add,
29/// so the filtered count is non-decreasing and a reached count stays
30/// reached. SQL row counts are NOT monotone — a DELETE shrinks the
31/// row set — so SQL count assertions never settle early; the final
32/// snapshot at the deadline decides.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub enum CountBound {
36    /// Exactly `n` matching requests.
37    Exact(u64),
38    /// At least `n` matching requests (early success at `n` or more).
39    AtLeast(u64),
40    /// At most `n` matching requests (absence claim over the window).
41    AtMost(u64),
42    /// Between `min` and `max` matching requests, inclusive.
43    Range(u64, u64),
44}
45
46/// The path filter of a [`RequestExpectation`] over the recorded
47/// path-and-query; at most one filter per expectation.
48#[derive(Debug, Clone, PartialEq)]
49#[non_exhaustive]
50pub enum PathFilter {
51    /// Exact path-and-query match (strict bytes).
52    Exact(String),
53    /// Substring containment against the recorded path-and-query.
54    Contains(String),
55    /// Regular expression match, compile-verified at load time.
56    Matches(String),
57}
58
59/// A recorded-request expectation: a count bound plus optional
60/// `method`, `path`, and `query` subset filters.
61#[derive(Debug, Clone, PartialEq)]
62pub struct RequestExpectation {
63    /// The count bound the recorded requests must satisfy.
64    pub bound: CountBound,
65    /// Optional request-method filter.
66    pub method: Option<String>,
67    /// Optional request-path filter (path-and-query).
68    pub path: Option<PathFilter>,
69    /// Optional query subset filter: every declared pair must be
70    /// present (order- and encoding-independent) in the recorded
71    /// request's percent-decoded query.
72    pub query: Option<BTreeMap<String, String>>,
73    /// Per-request shape asserts, positional over the filtered
74    /// recorded sequence; `None` on count-only expectations.
75    ///
76    /// When `Some`, the tier grammar synthesizes `bound =
77    /// Exact(requests.len())` and the judgment path keys off the list
78    /// length, not the bound; a hand-constructed literal that violates
79    /// this invariant misrenders diagnostics but never misjudges.
80    pub requests: Option<Vec<RequestShape>>,
81}
82
83/// The per-request shape of a `requests` entry: the same filter trio
84/// [`RequestExpectation`] carries — `method`, one [`PathFilter`], a
85/// query subset — plus an optional body [`Expectation`] over the
86/// projected body value.
87#[derive(Debug, Clone, PartialEq)]
88pub struct RequestShape {
89    /// Optional request-method filter.
90    pub method: Option<String>,
91    /// Optional request-path filter (path-and-query).
92    pub path: Option<PathFilter>,
93    /// Optional query subset filter: every declared pair must be
94    /// present (order- and encoding-independent) in the recorded
95    /// request's percent-decoded query.
96    pub query: Option<BTreeMap<String, String>>,
97    /// Optional body expectation over the projected body value,
98    /// decided by [`expectation_matches`].
99    pub body: Option<Expectation>,
100}
101
102/// The failed aspect of a [`RequestShape`] mismatch: which shape check
103/// the request failed.
104#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub enum ShapeAspect {
107    /// The request method differs.
108    Method,
109    /// The path filter rejects the recorded path-and-query.
110    Path,
111    /// The declared query subset is not present.
112    Query,
113    /// The body expectation rejects the projected body value.
114    Body,
115}
116
117/// A validation expectation. The grammar keys mirror the mock-testkit
118/// matcher rules: `equals`, `regex`, `contains`, `startsWith`,
119/// `endsWith`, `exists`, `jsonSubset`.
120///
121/// Grammar (dual, value-style): a bare value is a literal
122/// `equals`; an object with exactly one recognized matcher key is that
123/// matcher (this reading takes precedence over the literal one); any
124/// other object — zero, multiple, or unrecognized keys — is a literal
125/// `equals` compared structurally. `regex` patterns are
126/// compile-verified at load time, matching the unit-tier matcher
127/// rules.
128#[derive(Debug, Clone, PartialEq)]
129#[non_exhaustive]
130pub enum Expectation {
131    /// Exact equality against the value.
132    Equals(serde_json::Value),
133    /// Regular expression match, compile-verified at load time.
134    Regex(String),
135    /// Substring containment.
136    Contains(String),
137    /// Prefix match.
138    StartsWith(String),
139    /// Suffix match.
140    EndsWith(String),
141    /// The value under validation is present.
142    Exists,
143    /// Recursive-subset match against an object.
144    JsonSubset(serde_json::Value),
145    /// Matches any value including null; the wildcard verb (`ignore`
146    /// at the grammar layer, the Citrus `@ignore@` equivalent).
147    Any,
148}
149
150/// The sql-target row-shape expectation: exactly one row shape is
151/// populated at parse time — concrete row patterns or a row-count
152/// bound (`rows` XOR `bound`).
153///
154/// `columns` names the projection the assertion applies to; it is
155/// applied at the call site, which projects the observed rows by
156/// column name before matching (ADR-0072 §3 — the algebra is
157/// parameterized, observation is per-tier).
158#[derive(Debug, Clone, PartialEq)]
159pub struct RowsExpectation {
160    /// Optional projection: the column names the observed rows are
161    /// narrowed to before matching, applied by name at the call site.
162    pub columns: Option<Vec<String>>,
163    /// Whether the rows may match in any order; `false` matches
164    /// positionally in declaration order.
165    pub unordered: bool,
166    /// Concrete row patterns, one expectation per projected cell.
167    /// Populated exactly when `bound` is `None`.
168    pub rows: Option<Vec<Vec<Expectation>>>,
169    /// Row-count bound over the projected rows. Populated exactly
170    /// when `rows` is `None`.
171    pub bound: Option<CountBound>,
172}
173
174/// Whether one snapshot's filtered count satisfies the bound: the
175/// decision predicate of the no-deadline read and of the final expiry
176/// snapshot.
177pub fn bound_holds(bound: &CountBound, actual: usize) -> bool {
178    let actual = actual as u64;
179    match bound {
180        CountBound::Exact(n) => actual == *n,
181        CountBound::AtLeast(n) => actual >= *n,
182        CountBound::AtMost(n) => actual <= *n,
183        CountBound::Range(min, max) => actual >= *min && actual <= *max,
184    }
185}
186
187/// Whether the poll may settle early on this snapshot. `Exact`
188/// settles at equality and `AtLeast` at its floor — both sound
189/// because arrivals only add, so a reached count stays reached.
190/// `AtMost` and a `Range` never settle early: a passing snapshot
191/// cannot prove the count stays within bounds while the window is
192/// open.
193///
194/// Early-settle soundness assumes a monotone subject (arrivals only
195/// add). SQL row sets are NOT monotone — a DELETE shrinks them — so
196/// SQL validation must not settle early; the final snapshot at
197/// deadline decides (papal e_opus, bd rc-25lup.2, 2026-09-09).
198pub fn settles_early(bound: &CountBound, actual: usize) -> bool {
199    match bound {
200        CountBound::Exact(_) | CountBound::AtLeast(_) => bound_holds(bound, actual),
201        CountBound::AtMost(_) | CountBound::Range(..) => false,
202    }
203}
204
205/// Whether this snapshot has already broken an upper bound beyond
206/// recovery (`AtMost` above its ceiling, a `Range` above its
207/// maximum): arrivals only add, so the claim fails on the first
208/// observation instead of waiting the window out.
209pub fn above_ceiling(bound: &CountBound, actual: usize) -> bool {
210    let actual = actual as u64;
211    match bound {
212        CountBound::Exact(_) | CountBound::AtLeast(_) => false,
213        CountBound::AtMost(n) => actual > *n,
214        CountBound::Range(_, max) => actual > *max,
215    }
216}
217
218/// The percent-decoded query pairs of a path-and-query: everything
219/// after the first `?`, parsed with `form_urlencoded`, which
220/// percent-decodes `%XX` and `+`. A path without a query yields no
221/// pairs.
222pub fn query_pairs(path_and_query: &str) -> Vec<(String, String)> {
223    match path_and_query.split_once('?') {
224        Some((_, query)) => form_urlencoded::parse(query.as_bytes())
225            .map(|(key, value)| (key.into_owned(), value.into_owned()))
226            .collect(),
227        None => Vec::new(),
228    }
229}
230
231/// Counts the recorded requests that pass all filters: `method`
232/// compares ASCII-case-insensitively (callers may project any
233/// casing), the path filter matches
234/// the recorded path-and-query — `Exact` byte-for-byte, `Contains`
235/// by substring, `Matches` by regex — and the `query` subset requires
236/// every declared pair to appear among the request's
237/// percent-decoded query pairs, in any position order. A `None`
238/// filter passes everything, and all filters combine conjunctively.
239///
240/// Each request is projected as its `(method, path_and_query)` pair.
241/// The regex of a `Matches` filter compiles once per call, not once
242/// per recorded request; an invalid pattern matches nothing, failing
243/// closed. The per-request filter predicate is shared with
244/// [`request_shape_mismatch`].
245pub fn matching_count<'a>(
246    requests: impl IntoIterator<Item = (&'a str, &'a str)>,
247    method: Option<&str>,
248    path_filter: Option<&PathFilter>,
249    query: Option<&BTreeMap<String, String>>,
250) -> usize {
251    // The regex of a `Matches` filter compiles once per call, not once
252    // per recorded request. An invalid pattern (the parser rejects it
253    // first) matches nothing, failing closed.
254    let matches_regex = match path_filter {
255        Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
256        _ => None,
257    };
258    requests
259        .into_iter()
260        .filter(|(request_method, path)| {
261            shape_filter_aspect(
262                method,
263                path_filter,
264                query,
265                matches_regex.as_ref(),
266                request_method,
267                path,
268            )
269            .is_none()
270        })
271        .count()
272}
273
274/// The shared per-request filter predicate of [`matching_count`] and
275/// [`request_shape_mismatch`]: `None` when the request's
276/// `(method, path_and_query)` projection passes all three filters,
277/// otherwise the first [`ShapeAspect`] that failed — method, then
278/// path, then query. The regex of a `Matches` filter arrives
279/// pre-compiled from the caller; `None` matches nothing, failing
280/// closed.
281fn shape_filter_aspect(
282    method: Option<&str>,
283    path_filter: Option<&PathFilter>,
284    query: Option<&BTreeMap<String, String>>,
285    matches_regex: Option<&regex::Regex>,
286    request_method: &str,
287    path: &str,
288) -> Option<ShapeAspect> {
289    if !method.is_none_or(|m| m.eq_ignore_ascii_case(request_method)) {
290        return Some(ShapeAspect::Method);
291    }
292    let path_matches = match path_filter {
293        None => true,
294        Some(PathFilter::Exact(p)) => p.as_str() == path,
295        Some(PathFilter::Contains(s)) => path.contains(s.as_str()),
296        Some(PathFilter::Matches(_)) => matches_regex.is_some_and(|re| re.is_match(path)),
297    };
298    if !path_matches {
299        return Some(ShapeAspect::Path);
300    }
301    let query_subset = query.is_none_or(|declared| {
302        let pairs = query_pairs(path);
303        declared
304            .iter()
305            .all(|(key, value)| pairs.iter().any(|(k, v)| k == key && v == value))
306    });
307    if !query_subset {
308        return Some(ShapeAspect::Query);
309    }
310    None
311}
312
313/// The first positional mismatch of the per-request shapes over the
314/// filtered recorded sequence: `Some((index, aspect))` names the
315/// zero-based request that failed and the [`ShapeAspect`] it failed;
316/// `None` when every present pair matches. Shapes and projections zip
317/// positionally, and a projection sequence shorter than `shapes` is
318/// not a mismatch of this function — the length is the count bound's
319/// business.
320///
321/// Each projection is one request's `(method, path_and_query, body)`
322/// triple. Per pair the aspects check in order: `method` compares
323/// ASCII-case-insensitively, `path` applies the [`PathFilter`]
324/// semantics of [`matching_count`], `query` the same percent-decoded
325/// subset predicate, and `body` applies [`expectation_matches`] to
326/// the projected body value. The regex of a `Matches` filter compiles
327/// once per call, not once per shape; an invalid pattern matches
328/// nothing, failing closed.
329pub fn request_shape_mismatch<'a>(
330    shapes: &[RequestShape],
331    projections: impl Iterator<Item = (&'a str, &'a str, &'a serde_json::Value)>,
332) -> Option<(usize, ShapeAspect)> {
333    // The regex of a `Matches` filter compiles once per call, not once
334    // per shape. An invalid pattern (the parser rejects it first)
335    // matches nothing, failing closed.
336    let regexes: Vec<Option<regex::Regex>> = shapes
337        .iter()
338        .map(|shape| match shape.path.as_ref() {
339            Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
340            _ => None,
341        })
342        .collect();
343    shapes
344        .iter()
345        .zip(projections)
346        .enumerate()
347        .find_map(|(index, (shape, (method, path, body)))| {
348            if let Some(aspect) = shape_filter_aspect(
349                shape.method.as_deref(),
350                shape.path.as_ref(),
351                shape.query.as_ref(),
352                regexes[index].as_ref(),
353                method,
354                path,
355            ) {
356                return Some((index, aspect));
357            }
358            if shape
359                .body
360                .as_ref()
361                .is_some_and(|expectation| !expectation_matches(expectation, body))
362            {
363                return Some((index, ShapeAspect::Body));
364            }
365            None
366        })
367}
368
369/// Renders a count bound in its own grammar for mismatch details:
370/// `Exact(3)` renders `expected 3` — the historical phrasing the
371/// exact-count tests pin byte-for-byte — `AtLeast(3)` renders
372/// `expected at least 3`, `AtMost(2)` renders `expected at most 2`,
373/// and `Range(2, 4)` renders `expected between 2 and 4`.
374pub fn render_bound(bound: &CountBound) -> String {
375    match bound {
376        CountBound::Exact(n) => format!("expected {n}"),
377        CountBound::AtLeast(n) => format!("expected at least {n}"),
378        CountBound::AtMost(n) => format!("expected at most {n}"),
379        CountBound::Range(min, max) => format!("expected between {min} and {max}"),
380    }
381}
382
383/// The pure per-form boolean of a validation expectation against a
384/// value: `Equals` compares by equality; `Regex` failing to compile
385/// matches nothing (fail closed), otherwise matching the stringified
386/// value; `Contains`/`StartsWith`/`EndsWith` match the stringified
387/// value; `Exists` holds for any non-null value; `Any` matches every
388/// value including null; `JsonSubset` recursive-subset matches via
389/// `json_subset`.
390pub fn expectation_matches(expectation: &Expectation, value: &serde_json::Value) -> bool {
391    match expectation {
392        Expectation::Equals(expected) => value == expected,
393        Expectation::Regex(pattern) => {
394            regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&stringify(value)))
395        }
396        Expectation::Contains(needle) => stringify(value).contains(needle),
397        Expectation::StartsWith(prefix) => stringify(value).starts_with(prefix),
398        Expectation::EndsWith(suffix) => stringify(value).ends_with(suffix),
399        Expectation::Exists => value != &serde_json::Value::Null,
400        Expectation::JsonSubset(pattern) => json_subset(pattern, value),
401        Expectation::Any => true,
402    }
403}
404
405/// Whether a set of row patterns matches observed rows. Ordered
406/// (`unordered == false`): the row counts are equal and every pattern
407/// row satisfies its expectations positionally against the actual row
408/// at the same index. Unordered: the row counts are equal and a
409/// perfect matching exists between pattern rows and actual rows —
410/// decided with Kuhn's augmenting-path bipartite matching over the
411/// cell-compatibility matrix, never factorial backtracking. Expected
412/// rows are iterated in declaration order, so the decision is
413/// deterministic.
414pub fn rows_match(
415    expected: &[Vec<Expectation>],
416    actual: &[Vec<serde_json::Value>],
417    unordered: bool,
418) -> bool {
419    if expected.len() != actual.len() {
420        return false;
421    }
422    if !unordered {
423        return expected
424            .iter()
425            .zip(actual)
426            .all(|(pattern, row)| row_pattern_matches(pattern, row));
427    }
428    // Compatibility matrix: `compat[p][r]` — pattern row `p` matches
429    // actual row `r`.
430    let compat: Vec<Vec<bool>> = expected
431        .iter()
432        .map(|pattern| {
433            actual
434                .iter()
435                .map(|row| row_pattern_matches(pattern, row))
436                .collect()
437        })
438        .collect();
439    // `match_of_row[r]` is the pattern row currently assigned to
440    // actual row `r`.
441    let mut match_of_row: Vec<Option<usize>> = vec![None; actual.len()];
442    for pattern in 0..expected.len() {
443        let mut visited = vec![false; actual.len()];
444        if !try_augment(pattern, &compat, &mut match_of_row, &mut visited) {
445            return false;
446        }
447    }
448    true
449}
450
451/// Whether one pattern row matches one actual row: same length and
452/// every cell satisfies its expectation.
453fn row_pattern_matches(pattern: &[Expectation], row: &[serde_json::Value]) -> bool {
454    pattern.len() == row.len()
455        && pattern
456            .iter()
457            .zip(row)
458            .all(|(expectation, value)| expectation_matches(expectation, value))
459}
460
461/// Kuhn's augmenting path: whether pattern row `pattern` can reach an
462/// unmatched actual row by reassigning the patterns currently holding
463/// the rows it is compatible with. `visited` marks the actual rows
464/// probed on this path.
465fn try_augment(
466    pattern: usize,
467    compat: &[Vec<bool>],
468    match_of_row: &mut [Option<usize>],
469    visited: &mut [bool],
470) -> bool {
471    for (row, &compatible) in compat[pattern].iter().enumerate() {
472        if !compatible || visited[row] {
473            continue;
474        }
475        visited[row] = true;
476        match match_of_row[row] {
477            None => {
478                match_of_row[row] = Some(pattern);
479                return true;
480            }
481            Some(holder) => {
482                if try_augment(holder, compat, match_of_row, visited) {
483                    match_of_row[row] = Some(pattern);
484                    return true;
485                }
486            }
487        }
488    }
489    false
490}
491
492/// Renders a value for string matchers: strings as-is, anything else
493/// as its JSON form.
494pub fn stringify(value: &serde_json::Value) -> String {
495    match value {
496        serde_json::Value::String(text) => text.clone(),
497        other => other.to_string(),
498    }
499}
500
501/// Recursive-subset match: every key in `pattern` must exist in
502/// `actual` with a recursively subset-matching value; values outside
503/// `pattern` are ignored. Non-object patterns compare by equality.
504fn json_subset(pattern: &serde_json::Value, actual: &serde_json::Value) -> bool {
505    match (pattern, actual) {
506        (serde_json::Value::Object(pattern_object), serde_json::Value::Object(actual_object)) => {
507            pattern_object.iter().all(|(key, pattern_value)| {
508                actual_object
509                    .get(key)
510                    .is_some_and(|actual_value| json_subset(pattern_value, actual_value))
511            })
512        }
513        _ => pattern == actual,
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn bound_holds_covers_every_form_at_edges() {
523        let cases = [
524            (CountBound::Exact(2), [false, false, true, false, false]),
525            (CountBound::AtLeast(2), [false, false, true, true, true]),
526            (CountBound::AtMost(2), [true, true, true, false, false]),
527            (CountBound::Range(1, 3), [false, true, true, true, false]),
528        ];
529        for (bound, holds) in cases {
530            for (count, expected) in holds.into_iter().enumerate() {
531                assert_eq!(
532                    bound_holds(&bound, count),
533                    expected,
534                    "{bound:?} at count {count}"
535                );
536            }
537        }
538    }
539
540    #[test]
541    fn settles_early_absence_claims_never_settle() {
542        for count in 0..=5usize {
543            assert!(
544                !settles_early(&CountBound::AtMost(5), count),
545                "AtMost(5) at count {count}"
546            );
547            assert!(
548                !settles_early(&CountBound::Range(0, 5), count),
549                "Range(0, 5) at count {count}"
550            );
551            assert_eq!(
552                settles_early(&CountBound::Exact(2), count),
553                count == 2,
554                "Exact(2) at count {count}"
555            );
556            assert_eq!(
557                settles_early(&CountBound::AtLeast(2), count),
558                count >= 2,
559                "AtLeast(2) at count {count}"
560            );
561        }
562    }
563
564    #[test]
565    fn above_ceiling_only_upper_breaches() {
566        assert!(above_ceiling(&CountBound::AtMost(2), 3));
567        assert!(above_ceiling(&CountBound::Range(1, 2), 3));
568        assert!(!above_ceiling(&CountBound::Exact(2), 99));
569        assert!(!above_ceiling(&CountBound::AtLeast(2), 99));
570        assert!(!above_ceiling(&CountBound::AtMost(2), 2));
571    }
572
573    #[test]
574    fn matching_count_query_subset_order_and_encoding_independent() {
575        let declared = BTreeMap::from([
576            ("a".to_string(), "1".to_string()),
577            ("b".to_string(), "2".to_string()),
578        ]);
579        let reordered_and_encoded = [("GET", "/x?b=2&a=1"), ("POST", "/x?a=%31&b=%32")];
580        assert_eq!(
581            matching_count(reordered_and_encoded, None, None, Some(&declared)),
582            2
583        );
584        assert_eq!(
585            matching_count([("GET", "/x?a=1")], None, None, Some(&declared)),
586            0
587        );
588
589        let only_a = BTreeMap::from([("a".to_string(), "1".to_string())]);
590        assert_eq!(
591            matching_count([("GET", "/x?a=1")], None, None, Some(&only_a)),
592            1
593        );
594        assert_eq!(
595            matching_count([("GET", "/x?b=2")], None, None, Some(&declared)),
596            0
597        );
598    }
599
600    #[test]
601    fn matching_count_invalid_regex_fails_closed() {
602        let filter = PathFilter::Matches("(".to_string());
603        assert_eq!(
604            matching_count([("GET", "/anything")], None, Some(&filter), None),
605            0
606        );
607    }
608
609    #[test]
610    fn matching_count_method_case_insensitive() {
611        let requests = [("POST", "/o"), ("GET", "/o")];
612        assert_eq!(matching_count(requests, Some("post"), None, None), 1);
613    }
614
615    #[test]
616    fn matching_count_path_forms() {
617        let requests = [("GET", "/o?a=1"), ("POST", "/o?a=1&x=2"), ("GET", "/diff")];
618        let exact = PathFilter::Exact("/o?a=1".to_string());
619        let contains = PathFilter::Contains("/o".to_string());
620        let matches = PathFilter::Matches("^/o".to_string());
621        assert_eq!(matching_count(requests, None, Some(&exact), None), 1);
622        assert_eq!(matching_count(requests, None, Some(&contains), None), 2);
623        assert_eq!(matching_count(requests, None, Some(&matches), None), 2);
624    }
625
626    #[test]
627    fn query_pairs_no_question_mark() {
628        assert!(query_pairs("/noquery").is_empty());
629        assert_eq!(
630            query_pairs("/q?a=1"),
631            vec![("a".to_string(), "1".to_string())]
632        );
633    }
634
635    #[test]
636    fn query_pairs_plus_decoding() {
637        assert_eq!(
638            query_pairs("/x?a=1+2"),
639            vec![("a".to_string(), "1 2".to_string())]
640        );
641    }
642
643    #[test]
644    fn expectation_matches_string_forms() {
645        let value = serde_json::json!("hello world");
646        assert!(expectation_matches(
647            &Expectation::Contains("world".to_string()),
648            &value
649        ));
650        assert!(expectation_matches(
651            &Expectation::StartsWith("hello".to_string()),
652            &value
653        ));
654        assert!(expectation_matches(
655            &Expectation::EndsWith("world".to_string()),
656            &value
657        ));
658        assert!(expectation_matches(&Expectation::Exists, &value));
659        assert!(expectation_matches(
660            &Expectation::Regex("^hello".to_string()),
661            &value
662        ));
663        assert!(expectation_matches(
664            &Expectation::Equals(serde_json::json!("hello world")),
665            &value
666        ));
667        assert!(!expectation_matches(
668            &Expectation::Contains("nope".to_string()),
669            &value
670        ));
671    }
672
673    #[test]
674    fn expectation_matches_object_forms() {
675        let value = serde_json::json!({"n": "café", "s": "hello world"});
676        assert!(expectation_matches(
677            &Expectation::Equals(serde_json::json!({"n": "café", "s": "hello world"})),
678            &value
679        ));
680        assert!(expectation_matches(
681            &Expectation::JsonSubset(serde_json::json!({"n": "café"})),
682            &value
683        ));
684        assert!(expectation_matches(
685            &Expectation::Regex("caf".to_string()),
686            &value
687        ));
688        assert!(expectation_matches(&Expectation::Exists, &value));
689        assert!(!expectation_matches(
690            &Expectation::JsonSubset(serde_json::json!({"n": "other"})),
691            &value
692        ));
693        assert!(!expectation_matches(
694            &Expectation::Exists,
695            &serde_json::Value::Null
696        ));
697        assert!(!expectation_matches(
698            &Expectation::Regex("(".to_string()),
699            &value
700        ));
701    }
702
703    #[test]
704    fn json_subset_recursive_objects() {
705        let actual = serde_json::json!({"user": {"name": "María", "role": "admin"}, "extra": 1});
706        assert!(expectation_matches(
707            &Expectation::JsonSubset(serde_json::json!({"user": {"name": "María"}})),
708            &actual
709        ));
710        assert!(!expectation_matches(
711            &Expectation::JsonSubset(serde_json::json!({"user": {"name": "other"}})),
712            &actual
713        ));
714    }
715
716    #[test]
717    fn any_matches_all_values_including_null() {
718        for value in [
719            serde_json::json!(null),
720            serde_json::json!(0),
721            serde_json::json!("x"),
722            serde_json::json!([1, 2]),
723            serde_json::json!({"k": "v"}),
724        ] {
725            assert!(
726                expectation_matches(&Expectation::Any, &value),
727                "Any vs {value}"
728            );
729        }
730    }
731
732    #[test]
733    fn any_distinct_from_exists() {
734        assert!(!expectation_matches(
735            &Expectation::Exists,
736            &serde_json::Value::Null
737        ));
738        assert!(expectation_matches(
739            &Expectation::Any,
740            &serde_json::Value::Null
741        ));
742    }
743
744    fn two_row_pattern() -> Vec<Vec<Expectation>> {
745        vec![
746            vec![
747                Expectation::Equals(serde_json::json!(1)),
748                Expectation::Contains("li".to_string()),
749            ],
750            vec![Expectation::Equals(serde_json::json!(2)), Expectation::Any],
751        ]
752    }
753
754    #[test]
755    fn rows_match_ordered_positional() {
756        let expected = two_row_pattern();
757        let actual = vec![
758            vec![serde_json::json!(1), serde_json::json!("alice")],
759            vec![serde_json::json!(2), serde_json::json!("bob")],
760        ];
761        assert!(rows_match(&expected, &actual, false));
762        let swapped = vec![actual[1].clone(), actual[0].clone()];
763        assert!(!rows_match(&expected, &swapped, false));
764    }
765
766    #[test]
767    fn rows_match_length_mismatch_fails() {
768        let expected = two_row_pattern();
769        let actual = vec![vec![serde_json::json!(1), serde_json::json!("alice")]];
770        assert!(!rows_match(&expected, &actual, false));
771        assert!(!rows_match(&expected, &actual, true));
772    }
773
774    #[test]
775    fn rows_match_unordered_reorder() {
776        let expected = two_row_pattern();
777        let actual = vec![
778            vec![serde_json::json!(2), serde_json::json!("bob")],
779            vec![serde_json::json!(1), serde_json::json!("alice")],
780        ];
781        assert!(rows_match(&expected, &actual, true));
782    }
783
784    #[test]
785    fn rows_match_unordered_duplicates() {
786        let expected = vec![
787            vec![Expectation::Equals(serde_json::json!(1))],
788            vec![Expectation::Equals(serde_json::json!(1))],
789        ];
790        let same = vec![vec![serde_json::json!(1)], vec![serde_json::json!(1)]];
791        assert!(rows_match(&expected, &same, true));
792        let mixed = vec![vec![serde_json::json!(1)], vec![serde_json::json!(2)]];
793        assert!(!rows_match(&expected, &mixed, true));
794    }
795
796    #[test]
797    fn rows_match_kuhn_needs_augmenting() {
798        let expected = vec![
799            vec![Expectation::Any, Expectation::Any],
800            vec![Expectation::Equals(serde_json::json!(1)), Expectation::Any],
801        ];
802        let actual = vec![
803            vec![serde_json::json!(1), serde_json::json!("x")],
804            vec![serde_json::json!(2), serde_json::json!("a")],
805        ];
806        // Pattern 0 matches both rows, pattern 1 only the first:
807        // greedy first-fit strands pattern 1, the augmenting path
808        // reassigns pattern 0 to the second row.
809        assert!(rows_match(&expected, &actual, true));
810        assert!(!rows_match(&expected, &actual, false));
811    }
812
813    #[test]
814    fn rows_match_wildcard_including_null_cells() {
815        let expected = vec![vec![Expectation::Any]];
816        let actual = vec![vec![serde_json::Value::Null]];
817        assert!(rows_match(&expected, &actual, false));
818        assert!(rows_match(&expected, &actual, true));
819    }
820
821    #[test]
822    fn render_bound_forms() {
823        let bounds = [
824            CountBound::Exact(3),
825            CountBound::AtLeast(3),
826            CountBound::AtMost(2),
827            CountBound::Range(2, 4),
828        ];
829        let rendered: Vec<String> = bounds.iter().map(render_bound).collect();
830        for text in &rendered {
831            assert!(!text.is_empty(), "empty render for {text:?}");
832        }
833        for (index, left) in rendered.iter().enumerate() {
834            for right in &rendered[index + 1..] {
835                assert_ne!(left, right, "duplicate render `{left}`");
836            }
837        }
838    }
839
840    #[test]
841    fn shape_positional_match_on_present_elements() {
842        let shapes = vec![
843            RequestShape {
844                method: Some("POST".to_string()),
845                path: None,
846                query: None,
847                body: Some(Expectation::JsonSubset(serde_json::json!({"k": "1"}))),
848            },
849            RequestShape {
850                method: Some("GET".to_string()),
851                path: None,
852                query: None,
853                body: None,
854            },
855        ];
856        let first_body = serde_json::json!({"k": "1", "x": 2});
857        let null = serde_json::Value::Null;
858        let projections = [("POST", "/o", &first_body), ("GET", "/h", &null)];
859        assert_eq!(
860            request_shape_mismatch(&shapes, projections.into_iter()),
861            None
862        );
863    }
864
865    #[test]
866    fn shape_first_mismatch_wins_with_index_and_aspect() {
867        let shapes = vec![
868            RequestShape {
869                method: Some("POST".to_string()),
870                path: None,
871                query: None,
872                body: None,
873            },
874            RequestShape {
875                method: Some("GET".to_string()),
876                path: None,
877                query: None,
878                body: None,
879            },
880        ];
881        let null = serde_json::Value::Null;
882        let projections = [("POST", "/o", &null), ("POST", "/h", &null)];
883        assert_eq!(
884            request_shape_mismatch(&shapes, projections.into_iter()),
885            Some((1, ShapeAspect::Method))
886        );
887    }
888
889    #[test]
890    fn shape_body_contains_over_text_projection() {
891        let shapes = vec![RequestShape {
892            method: None,
893            path: None,
894            query: None,
895            body: Some(Expectation::Contains("idempotency".to_string())),
896        }];
897        let body = serde_json::json!("idempotency-key-42");
898        let projections = [("POST", "/o", &body)];
899        assert_eq!(
900            request_shape_mismatch(&shapes, projections.into_iter()),
901            None
902        );
903    }
904
905    #[test]
906    fn shape_short_projection_is_not_a_mismatch() {
907        let shapes = vec![
908            RequestShape {
909                method: Some("POST".to_string()),
910                path: None,
911                query: None,
912                body: None,
913            },
914            RequestShape {
915                method: Some("POST".to_string()),
916                path: None,
917                query: None,
918                body: None,
919            },
920        ];
921        let null = serde_json::Value::Null;
922        let projections = [("POST", "/o", &null)];
923        assert_eq!(
924            request_shape_mismatch(&shapes, projections.into_iter()),
925            None
926        );
927    }
928
929    #[test]
930    fn shape_path_and_query_aspects() {
931        let null = serde_json::Value::Null;
932        let shapes = vec![RequestShape {
933            method: None,
934            path: Some(PathFilter::Exact("/o?a=1".to_string())),
935            query: Some(BTreeMap::from([("a".to_string(), "1".to_string())])),
936            body: None,
937        }];
938        let ok = [("POST", "/o?a=1", &null)];
939        assert_eq!(request_shape_mismatch(&shapes, ok.into_iter()), None);
940        let query_shapes = vec![RequestShape {
941            method: None,
942            path: Some(PathFilter::Contains("/o".to_string())),
943            query: Some(BTreeMap::from([("a".to_string(), "1".to_string())])),
944            body: None,
945        }];
946        let query_off = [("POST", "/o?a=2", &null)];
947        assert_eq!(
948            request_shape_mismatch(&query_shapes, query_off.into_iter()),
949            Some((0, ShapeAspect::Query))
950        );
951        let contains = vec![RequestShape {
952            method: None,
953            path: Some(PathFilter::Contains("/ord".to_string())),
954            query: None,
955            body: None,
956        }];
957        let path_off = [("GET", "/x", &null)];
958        assert_eq!(
959            request_shape_mismatch(&contains, path_off.into_iter()),
960            Some((0, ShapeAspect::Path))
961        );
962    }
963}