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}
74
75/// A validation expectation. The grammar keys mirror the mock-testkit
76/// matcher rules: `equals`, `regex`, `contains`, `startsWith`,
77/// `endsWith`, `exists`, `jsonSubset`.
78///
79/// Grammar (dual, value-style): a bare value is a literal
80/// `equals`; an object with exactly one recognized matcher key is that
81/// matcher (this reading takes precedence over the literal one); any
82/// other object — zero, multiple, or unrecognized keys — is a literal
83/// `equals` compared structurally. `regex` patterns are
84/// compile-verified at load time, matching the unit-tier matcher
85/// rules.
86#[derive(Debug, Clone, PartialEq)]
87#[non_exhaustive]
88pub enum Expectation {
89    /// Exact equality against the value.
90    Equals(serde_json::Value),
91    /// Regular expression match, compile-verified at load time.
92    Regex(String),
93    /// Substring containment.
94    Contains(String),
95    /// Prefix match.
96    StartsWith(String),
97    /// Suffix match.
98    EndsWith(String),
99    /// The value under validation is present.
100    Exists,
101    /// Recursive-subset match against an object.
102    JsonSubset(serde_json::Value),
103    /// Matches any value including null; the wildcard verb (`ignore`
104    /// at the grammar layer, the Citrus `@ignore@` equivalent).
105    Any,
106}
107
108/// The sql-target row-shape expectation: exactly one row shape is
109/// populated at parse time — concrete row patterns or a row-count
110/// bound (`rows` XOR `bound`).
111///
112/// `columns` names the projection the assertion applies to; it is
113/// applied at the call site, which projects the observed rows by
114/// column name before matching (ADR-0072 §3 — the algebra is
115/// parameterized, observation is per-tier).
116#[derive(Debug, Clone, PartialEq)]
117pub struct RowsExpectation {
118    /// Optional projection: the column names the observed rows are
119    /// narrowed to before matching, applied by name at the call site.
120    pub columns: Option<Vec<String>>,
121    /// Whether the rows may match in any order; `false` matches
122    /// positionally in declaration order.
123    pub unordered: bool,
124    /// Concrete row patterns, one expectation per projected cell.
125    /// Populated exactly when `bound` is `None`.
126    pub rows: Option<Vec<Vec<Expectation>>>,
127    /// Row-count bound over the projected rows. Populated exactly
128    /// when `rows` is `None`.
129    pub bound: Option<CountBound>,
130}
131
132/// Whether one snapshot's filtered count satisfies the bound: the
133/// decision predicate of the no-deadline read and of the final expiry
134/// snapshot.
135pub fn bound_holds(bound: &CountBound, actual: usize) -> bool {
136    let actual = actual as u64;
137    match bound {
138        CountBound::Exact(n) => actual == *n,
139        CountBound::AtLeast(n) => actual >= *n,
140        CountBound::AtMost(n) => actual <= *n,
141        CountBound::Range(min, max) => actual >= *min && actual <= *max,
142    }
143}
144
145/// Whether the poll may settle early on this snapshot. `Exact`
146/// settles at equality and `AtLeast` at its floor — both sound
147/// because arrivals only add, so a reached count stays reached.
148/// `AtMost` and a `Range` never settle early: a passing snapshot
149/// cannot prove the count stays within bounds while the window is
150/// open.
151///
152/// Early-settle soundness assumes a monotone subject (arrivals only
153/// add). SQL row sets are NOT monotone — a DELETE shrinks them — so
154/// SQL validation must not settle early; the final snapshot at
155/// deadline decides (papal e_opus, bd rc-25lup.2, 2026-09-09).
156pub fn settles_early(bound: &CountBound, actual: usize) -> bool {
157    match bound {
158        CountBound::Exact(_) | CountBound::AtLeast(_) => bound_holds(bound, actual),
159        CountBound::AtMost(_) | CountBound::Range(..) => false,
160    }
161}
162
163/// Whether this snapshot has already broken an upper bound beyond
164/// recovery (`AtMost` above its ceiling, a `Range` above its
165/// maximum): arrivals only add, so the claim fails on the first
166/// observation instead of waiting the window out.
167pub fn above_ceiling(bound: &CountBound, actual: usize) -> bool {
168    let actual = actual as u64;
169    match bound {
170        CountBound::Exact(_) | CountBound::AtLeast(_) => false,
171        CountBound::AtMost(n) => actual > *n,
172        CountBound::Range(_, max) => actual > *max,
173    }
174}
175
176/// The percent-decoded query pairs of a path-and-query: everything
177/// after the first `?`, parsed with `form_urlencoded`, which
178/// percent-decodes `%XX` and `+`. A path without a query yields no
179/// pairs.
180pub fn query_pairs(path_and_query: &str) -> Vec<(String, String)> {
181    match path_and_query.split_once('?') {
182        Some((_, query)) => form_urlencoded::parse(query.as_bytes())
183            .map(|(key, value)| (key.into_owned(), value.into_owned()))
184            .collect(),
185        None => Vec::new(),
186    }
187}
188
189/// Counts the recorded requests that pass all filters: `method`
190/// compares ASCII-case-insensitively (callers may project any
191/// casing), the path filter matches
192/// the recorded path-and-query — `Exact` byte-for-byte, `Contains`
193/// by substring, `Matches` by regex — and the `query` subset requires
194/// every declared pair to appear among the request's
195/// percent-decoded query pairs, in any position order. A `None`
196/// filter passes everything, and all filters combine conjunctively.
197///
198/// Each request is projected as its `(method, path_and_query)` pair.
199/// The regex of a `Matches` filter compiles once per call, not once
200/// per recorded request; an invalid pattern matches nothing, failing
201/// closed.
202pub fn matching_count<'a>(
203    requests: impl IntoIterator<Item = (&'a str, &'a str)>,
204    method: Option<&str>,
205    path_filter: Option<&PathFilter>,
206    query: Option<&BTreeMap<String, String>>,
207) -> usize {
208    // The regex of a `Matches` filter compiles once per call, not once
209    // per recorded request. An invalid pattern (the parser rejects it
210    // first) matches nothing, failing closed.
211    let matches_regex = match path_filter {
212        Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
213        _ => None,
214    };
215    requests
216        .into_iter()
217        .filter(|(request_method, path)| {
218            let path_matches = match path_filter {
219                None => true,
220                Some(PathFilter::Exact(p)) => p.as_str() == *path,
221                Some(PathFilter::Contains(s)) => path.contains(s.as_str()),
222                Some(PathFilter::Matches(_)) => {
223                    matches_regex.as_ref().is_some_and(|re| re.is_match(path))
224                }
225            };
226            let query_subset = query.is_none_or(|declared| {
227                let pairs = query_pairs(path);
228                declared
229                    .iter()
230                    .all(|(key, value)| pairs.iter().any(|(k, v)| k == key && v == value))
231            });
232            method.is_none_or(|m| m.eq_ignore_ascii_case(request_method))
233                && path_matches
234                && query_subset
235        })
236        .count()
237}
238
239/// Renders a count bound in its own grammar for mismatch details:
240/// `Exact(3)` renders `expected 3` — the historical phrasing the
241/// exact-count tests pin byte-for-byte — `AtLeast(3)` renders
242/// `expected at least 3`, `AtMost(2)` renders `expected at most 2`,
243/// and `Range(2, 4)` renders `expected between 2 and 4`.
244pub fn render_bound(bound: &CountBound) -> String {
245    match bound {
246        CountBound::Exact(n) => format!("expected {n}"),
247        CountBound::AtLeast(n) => format!("expected at least {n}"),
248        CountBound::AtMost(n) => format!("expected at most {n}"),
249        CountBound::Range(min, max) => format!("expected between {min} and {max}"),
250    }
251}
252
253/// The pure per-form boolean of a validation expectation against a
254/// value: `Equals` compares by equality; `Regex` failing to compile
255/// matches nothing (fail closed), otherwise matching the stringified
256/// value; `Contains`/`StartsWith`/`EndsWith` match the stringified
257/// value; `Exists` holds for any non-null value; `Any` matches every
258/// value including null; `JsonSubset` recursive-subset matches via
259/// `json_subset`.
260pub fn expectation_matches(expectation: &Expectation, value: &serde_json::Value) -> bool {
261    match expectation {
262        Expectation::Equals(expected) => value == expected,
263        Expectation::Regex(pattern) => {
264            regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&stringify(value)))
265        }
266        Expectation::Contains(needle) => stringify(value).contains(needle),
267        Expectation::StartsWith(prefix) => stringify(value).starts_with(prefix),
268        Expectation::EndsWith(suffix) => stringify(value).ends_with(suffix),
269        Expectation::Exists => value != &serde_json::Value::Null,
270        Expectation::JsonSubset(pattern) => json_subset(pattern, value),
271        Expectation::Any => true,
272    }
273}
274
275/// Whether a set of row patterns matches observed rows. Ordered
276/// (`unordered == false`): the row counts are equal and every pattern
277/// row satisfies its expectations positionally against the actual row
278/// at the same index. Unordered: the row counts are equal and a
279/// perfect matching exists between pattern rows and actual rows —
280/// decided with Kuhn's augmenting-path bipartite matching over the
281/// cell-compatibility matrix, never factorial backtracking. Expected
282/// rows are iterated in declaration order, so the decision is
283/// deterministic.
284pub fn rows_match(
285    expected: &[Vec<Expectation>],
286    actual: &[Vec<serde_json::Value>],
287    unordered: bool,
288) -> bool {
289    if expected.len() != actual.len() {
290        return false;
291    }
292    if !unordered {
293        return expected
294            .iter()
295            .zip(actual)
296            .all(|(pattern, row)| row_pattern_matches(pattern, row));
297    }
298    // Compatibility matrix: `compat[p][r]` — pattern row `p` matches
299    // actual row `r`.
300    let compat: Vec<Vec<bool>> = expected
301        .iter()
302        .map(|pattern| {
303            actual
304                .iter()
305                .map(|row| row_pattern_matches(pattern, row))
306                .collect()
307        })
308        .collect();
309    // `match_of_row[r]` is the pattern row currently assigned to
310    // actual row `r`.
311    let mut match_of_row: Vec<Option<usize>> = vec![None; actual.len()];
312    for pattern in 0..expected.len() {
313        let mut visited = vec![false; actual.len()];
314        if !try_augment(pattern, &compat, &mut match_of_row, &mut visited) {
315            return false;
316        }
317    }
318    true
319}
320
321/// Whether one pattern row matches one actual row: same length and
322/// every cell satisfies its expectation.
323fn row_pattern_matches(pattern: &[Expectation], row: &[serde_json::Value]) -> bool {
324    pattern.len() == row.len()
325        && pattern
326            .iter()
327            .zip(row)
328            .all(|(expectation, value)| expectation_matches(expectation, value))
329}
330
331/// Kuhn's augmenting path: whether pattern row `pattern` can reach an
332/// unmatched actual row by reassigning the patterns currently holding
333/// the rows it is compatible with. `visited` marks the actual rows
334/// probed on this path.
335fn try_augment(
336    pattern: usize,
337    compat: &[Vec<bool>],
338    match_of_row: &mut [Option<usize>],
339    visited: &mut [bool],
340) -> bool {
341    for (row, &compatible) in compat[pattern].iter().enumerate() {
342        if !compatible || visited[row] {
343            continue;
344        }
345        visited[row] = true;
346        match match_of_row[row] {
347            None => {
348                match_of_row[row] = Some(pattern);
349                return true;
350            }
351            Some(holder) => {
352                if try_augment(holder, compat, match_of_row, visited) {
353                    match_of_row[row] = Some(pattern);
354                    return true;
355                }
356            }
357        }
358    }
359    false
360}
361
362/// Renders a value for string matchers: strings as-is, anything else
363/// as its JSON form.
364pub fn stringify(value: &serde_json::Value) -> String {
365    match value {
366        serde_json::Value::String(text) => text.clone(),
367        other => other.to_string(),
368    }
369}
370
371/// Recursive-subset match: every key in `pattern` must exist in
372/// `actual` with a recursively subset-matching value; values outside
373/// `pattern` are ignored. Non-object patterns compare by equality.
374fn json_subset(pattern: &serde_json::Value, actual: &serde_json::Value) -> bool {
375    match (pattern, actual) {
376        (serde_json::Value::Object(pattern_object), serde_json::Value::Object(actual_object)) => {
377            pattern_object.iter().all(|(key, pattern_value)| {
378                actual_object
379                    .get(key)
380                    .is_some_and(|actual_value| json_subset(pattern_value, actual_value))
381            })
382        }
383        _ => pattern == actual,
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn bound_holds_covers_every_form_at_edges() {
393        let cases = [
394            (CountBound::Exact(2), [false, false, true, false, false]),
395            (CountBound::AtLeast(2), [false, false, true, true, true]),
396            (CountBound::AtMost(2), [true, true, true, false, false]),
397            (CountBound::Range(1, 3), [false, true, true, true, false]),
398        ];
399        for (bound, holds) in cases {
400            for (count, expected) in holds.into_iter().enumerate() {
401                assert_eq!(
402                    bound_holds(&bound, count),
403                    expected,
404                    "{bound:?} at count {count}"
405                );
406            }
407        }
408    }
409
410    #[test]
411    fn settles_early_absence_claims_never_settle() {
412        for count in 0..=5usize {
413            assert!(
414                !settles_early(&CountBound::AtMost(5), count),
415                "AtMost(5) at count {count}"
416            );
417            assert!(
418                !settles_early(&CountBound::Range(0, 5), count),
419                "Range(0, 5) at count {count}"
420            );
421            assert_eq!(
422                settles_early(&CountBound::Exact(2), count),
423                count == 2,
424                "Exact(2) at count {count}"
425            );
426            assert_eq!(
427                settles_early(&CountBound::AtLeast(2), count),
428                count >= 2,
429                "AtLeast(2) at count {count}"
430            );
431        }
432    }
433
434    #[test]
435    fn above_ceiling_only_upper_breaches() {
436        assert!(above_ceiling(&CountBound::AtMost(2), 3));
437        assert!(above_ceiling(&CountBound::Range(1, 2), 3));
438        assert!(!above_ceiling(&CountBound::Exact(2), 99));
439        assert!(!above_ceiling(&CountBound::AtLeast(2), 99));
440        assert!(!above_ceiling(&CountBound::AtMost(2), 2));
441    }
442
443    #[test]
444    fn matching_count_query_subset_order_and_encoding_independent() {
445        let declared = BTreeMap::from([
446            ("a".to_string(), "1".to_string()),
447            ("b".to_string(), "2".to_string()),
448        ]);
449        let reordered_and_encoded = [("GET", "/x?b=2&a=1"), ("POST", "/x?a=%31&b=%32")];
450        assert_eq!(
451            matching_count(reordered_and_encoded, None, None, Some(&declared)),
452            2
453        );
454        assert_eq!(
455            matching_count([("GET", "/x?a=1")], None, None, Some(&declared)),
456            0
457        );
458
459        let only_a = BTreeMap::from([("a".to_string(), "1".to_string())]);
460        assert_eq!(
461            matching_count([("GET", "/x?a=1")], None, None, Some(&only_a)),
462            1
463        );
464        assert_eq!(
465            matching_count([("GET", "/x?b=2")], None, None, Some(&declared)),
466            0
467        );
468    }
469
470    #[test]
471    fn matching_count_invalid_regex_fails_closed() {
472        let filter = PathFilter::Matches("(".to_string());
473        assert_eq!(
474            matching_count([("GET", "/anything")], None, Some(&filter), None),
475            0
476        );
477    }
478
479    #[test]
480    fn matching_count_method_case_insensitive() {
481        let requests = [("POST", "/o"), ("GET", "/o")];
482        assert_eq!(matching_count(requests, Some("post"), None, None), 1);
483    }
484
485    #[test]
486    fn matching_count_path_forms() {
487        let requests = [("GET", "/o?a=1"), ("POST", "/o?a=1&x=2"), ("GET", "/diff")];
488        let exact = PathFilter::Exact("/o?a=1".to_string());
489        let contains = PathFilter::Contains("/o".to_string());
490        let matches = PathFilter::Matches("^/o".to_string());
491        assert_eq!(matching_count(requests, None, Some(&exact), None), 1);
492        assert_eq!(matching_count(requests, None, Some(&contains), None), 2);
493        assert_eq!(matching_count(requests, None, Some(&matches), None), 2);
494    }
495
496    #[test]
497    fn query_pairs_no_question_mark() {
498        assert!(query_pairs("/noquery").is_empty());
499        assert_eq!(
500            query_pairs("/q?a=1"),
501            vec![("a".to_string(), "1".to_string())]
502        );
503    }
504
505    #[test]
506    fn query_pairs_plus_decoding() {
507        assert_eq!(
508            query_pairs("/x?a=1+2"),
509            vec![("a".to_string(), "1 2".to_string())]
510        );
511    }
512
513    #[test]
514    fn expectation_matches_string_forms() {
515        let value = serde_json::json!("hello world");
516        assert!(expectation_matches(
517            &Expectation::Contains("world".to_string()),
518            &value
519        ));
520        assert!(expectation_matches(
521            &Expectation::StartsWith("hello".to_string()),
522            &value
523        ));
524        assert!(expectation_matches(
525            &Expectation::EndsWith("world".to_string()),
526            &value
527        ));
528        assert!(expectation_matches(&Expectation::Exists, &value));
529        assert!(expectation_matches(
530            &Expectation::Regex("^hello".to_string()),
531            &value
532        ));
533        assert!(expectation_matches(
534            &Expectation::Equals(serde_json::json!("hello world")),
535            &value
536        ));
537        assert!(!expectation_matches(
538            &Expectation::Contains("nope".to_string()),
539            &value
540        ));
541    }
542
543    #[test]
544    fn expectation_matches_object_forms() {
545        let value = serde_json::json!({"n": "café", "s": "hello world"});
546        assert!(expectation_matches(
547            &Expectation::Equals(serde_json::json!({"n": "café", "s": "hello world"})),
548            &value
549        ));
550        assert!(expectation_matches(
551            &Expectation::JsonSubset(serde_json::json!({"n": "café"})),
552            &value
553        ));
554        assert!(expectation_matches(
555            &Expectation::Regex("caf".to_string()),
556            &value
557        ));
558        assert!(expectation_matches(&Expectation::Exists, &value));
559        assert!(!expectation_matches(
560            &Expectation::JsonSubset(serde_json::json!({"n": "other"})),
561            &value
562        ));
563        assert!(!expectation_matches(
564            &Expectation::Exists,
565            &serde_json::Value::Null
566        ));
567        assert!(!expectation_matches(
568            &Expectation::Regex("(".to_string()),
569            &value
570        ));
571    }
572
573    #[test]
574    fn json_subset_recursive_objects() {
575        let actual = serde_json::json!({"user": {"name": "María", "role": "admin"}, "extra": 1});
576        assert!(expectation_matches(
577            &Expectation::JsonSubset(serde_json::json!({"user": {"name": "María"}})),
578            &actual
579        ));
580        assert!(!expectation_matches(
581            &Expectation::JsonSubset(serde_json::json!({"user": {"name": "other"}})),
582            &actual
583        ));
584    }
585
586    #[test]
587    fn any_matches_all_values_including_null() {
588        for value in [
589            serde_json::json!(null),
590            serde_json::json!(0),
591            serde_json::json!("x"),
592            serde_json::json!([1, 2]),
593            serde_json::json!({"k": "v"}),
594        ] {
595            assert!(
596                expectation_matches(&Expectation::Any, &value),
597                "Any vs {value}"
598            );
599        }
600    }
601
602    #[test]
603    fn any_distinct_from_exists() {
604        assert!(!expectation_matches(
605            &Expectation::Exists,
606            &serde_json::Value::Null
607        ));
608        assert!(expectation_matches(
609            &Expectation::Any,
610            &serde_json::Value::Null
611        ));
612    }
613
614    fn two_row_pattern() -> Vec<Vec<Expectation>> {
615        vec![
616            vec![
617                Expectation::Equals(serde_json::json!(1)),
618                Expectation::Contains("li".to_string()),
619            ],
620            vec![Expectation::Equals(serde_json::json!(2)), Expectation::Any],
621        ]
622    }
623
624    #[test]
625    fn rows_match_ordered_positional() {
626        let expected = two_row_pattern();
627        let actual = vec![
628            vec![serde_json::json!(1), serde_json::json!("alice")],
629            vec![serde_json::json!(2), serde_json::json!("bob")],
630        ];
631        assert!(rows_match(&expected, &actual, false));
632        let swapped = vec![actual[1].clone(), actual[0].clone()];
633        assert!(!rows_match(&expected, &swapped, false));
634    }
635
636    #[test]
637    fn rows_match_length_mismatch_fails() {
638        let expected = two_row_pattern();
639        let actual = vec![vec![serde_json::json!(1), serde_json::json!("alice")]];
640        assert!(!rows_match(&expected, &actual, false));
641        assert!(!rows_match(&expected, &actual, true));
642    }
643
644    #[test]
645    fn rows_match_unordered_reorder() {
646        let expected = two_row_pattern();
647        let actual = vec![
648            vec![serde_json::json!(2), serde_json::json!("bob")],
649            vec![serde_json::json!(1), serde_json::json!("alice")],
650        ];
651        assert!(rows_match(&expected, &actual, true));
652    }
653
654    #[test]
655    fn rows_match_unordered_duplicates() {
656        let expected = vec![
657            vec![Expectation::Equals(serde_json::json!(1))],
658            vec![Expectation::Equals(serde_json::json!(1))],
659        ];
660        let same = vec![vec![serde_json::json!(1)], vec![serde_json::json!(1)]];
661        assert!(rows_match(&expected, &same, true));
662        let mixed = vec![vec![serde_json::json!(1)], vec![serde_json::json!(2)]];
663        assert!(!rows_match(&expected, &mixed, true));
664    }
665
666    #[test]
667    fn rows_match_kuhn_needs_augmenting() {
668        let expected = vec![
669            vec![Expectation::Any, Expectation::Any],
670            vec![Expectation::Equals(serde_json::json!(1)), Expectation::Any],
671        ];
672        let actual = vec![
673            vec![serde_json::json!(1), serde_json::json!("x")],
674            vec![serde_json::json!(2), serde_json::json!("a")],
675        ];
676        // Pattern 0 matches both rows, pattern 1 only the first:
677        // greedy first-fit strands pattern 1, the augmenting path
678        // reassigns pattern 0 to the second row.
679        assert!(rows_match(&expected, &actual, true));
680        assert!(!rows_match(&expected, &actual, false));
681    }
682
683    #[test]
684    fn rows_match_wildcard_including_null_cells() {
685        let expected = vec![vec![Expectation::Any]];
686        let actual = vec![vec![serde_json::Value::Null]];
687        assert!(rows_match(&expected, &actual, false));
688        assert!(rows_match(&expected, &actual, true));
689    }
690
691    #[test]
692    fn render_bound_forms() {
693        let bounds = [
694            CountBound::Exact(3),
695            CountBound::AtLeast(3),
696            CountBound::AtMost(2),
697            CountBound::Range(2, 4),
698        ];
699        let rendered: Vec<String> = bounds.iter().map(render_bound).collect();
700        for text in &rendered {
701            assert!(!text.is_empty(), "empty render for {text:?}");
702        }
703        for (index, left) in rendered.iter().enumerate() {
704            for right in &rendered[index + 1..] {
705                assert_ne!(left, right, "duplicate render `{left}`");
706            }
707        }
708    }
709}