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#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub enum CountBound {
30    /// Exactly `n` matching requests.
31    Exact(u64),
32    /// At least `n` matching requests (early success at `n` or more).
33    AtLeast(u64),
34    /// At most `n` matching requests (absence claim over the window).
35    AtMost(u64),
36    /// Between `min` and `max` matching requests, inclusive.
37    Range(u64, u64),
38}
39
40/// The path filter of a [`RequestExpectation`] over the recorded
41/// path-and-query; at most one filter per expectation.
42#[derive(Debug, Clone, PartialEq)]
43#[non_exhaustive]
44pub enum PathFilter {
45    /// Exact path-and-query match (strict bytes).
46    Exact(String),
47    /// Substring containment against the recorded path-and-query.
48    Contains(String),
49    /// Regular expression match, compile-verified at load time.
50    Matches(String),
51}
52
53/// A recorded-request expectation: a count bound plus optional
54/// `method`, `path`, and `query` subset filters.
55#[derive(Debug, Clone, PartialEq)]
56pub struct RequestExpectation {
57    /// The count bound the recorded requests must satisfy.
58    pub bound: CountBound,
59    /// Optional request-method filter.
60    pub method: Option<String>,
61    /// Optional request-path filter (path-and-query).
62    pub path: Option<PathFilter>,
63    /// Optional query subset filter: every declared pair must be
64    /// present (order- and encoding-independent) in the recorded
65    /// request's percent-decoded query.
66    pub query: Option<BTreeMap<String, String>>,
67}
68
69/// A validation expectation. The grammar keys mirror the mock-testkit
70/// matcher rules: `equals`, `regex`, `contains`, `startsWith`,
71/// `endsWith`, `exists`, `jsonSubset`.
72///
73/// Grammar (dual, value-style): a bare value is a literal
74/// `equals`; an object with exactly one recognized matcher key is that
75/// matcher (this reading takes precedence over the literal one); any
76/// other object — zero, multiple, or unrecognized keys — is a literal
77/// `equals` compared structurally. `regex` patterns are
78/// compile-verified at load time, matching the unit-tier matcher
79/// rules.
80#[derive(Debug, Clone, PartialEq)]
81#[non_exhaustive]
82pub enum Expectation {
83    /// Exact equality against the value.
84    Equals(serde_json::Value),
85    /// Regular expression match, compile-verified at load time.
86    Regex(String),
87    /// Substring containment.
88    Contains(String),
89    /// Prefix match.
90    StartsWith(String),
91    /// Suffix match.
92    EndsWith(String),
93    /// The value under validation is present.
94    Exists,
95    /// Recursive-subset match against an object.
96    JsonSubset(serde_json::Value),
97}
98
99/// Whether one snapshot's filtered count satisfies the bound: the
100/// decision predicate of the no-deadline read and of the final expiry
101/// snapshot.
102pub fn bound_holds(bound: &CountBound, actual: usize) -> bool {
103    let actual = actual as u64;
104    match bound {
105        CountBound::Exact(n) => actual == *n,
106        CountBound::AtLeast(n) => actual >= *n,
107        CountBound::AtMost(n) => actual <= *n,
108        CountBound::Range(min, max) => actual >= *min && actual <= *max,
109    }
110}
111
112/// Whether the poll may settle early on this snapshot. `Exact`
113/// settles at equality and `AtLeast` at its floor — both sound
114/// because arrivals only add, so a reached count stays reached.
115/// `AtMost` and a `Range` never settle early: a passing snapshot
116/// cannot prove the count stays within bounds while the window is
117/// open.
118pub fn settles_early(bound: &CountBound, actual: usize) -> bool {
119    match bound {
120        CountBound::Exact(_) | CountBound::AtLeast(_) => bound_holds(bound, actual),
121        CountBound::AtMost(_) | CountBound::Range(..) => false,
122    }
123}
124
125/// Whether this snapshot has already broken an upper bound beyond
126/// recovery (`AtMost` above its ceiling, a `Range` above its
127/// maximum): arrivals only add, so the claim fails on the first
128/// observation instead of waiting the window out.
129pub fn above_ceiling(bound: &CountBound, actual: usize) -> bool {
130    let actual = actual as u64;
131    match bound {
132        CountBound::Exact(_) | CountBound::AtLeast(_) => false,
133        CountBound::AtMost(n) => actual > *n,
134        CountBound::Range(_, max) => actual > *max,
135    }
136}
137
138/// The percent-decoded query pairs of a path-and-query: everything
139/// after the first `?`, parsed with `form_urlencoded`, which
140/// percent-decodes `%XX` and `+`. A path without a query yields no
141/// pairs.
142pub fn query_pairs(path_and_query: &str) -> Vec<(String, String)> {
143    match path_and_query.split_once('?') {
144        Some((_, query)) => form_urlencoded::parse(query.as_bytes())
145            .map(|(key, value)| (key.into_owned(), value.into_owned()))
146            .collect(),
147        None => Vec::new(),
148    }
149}
150
151/// Counts the recorded requests that pass all filters: `method`
152/// compares ASCII-case-insensitively (callers may project any
153/// casing), the path filter matches
154/// the recorded path-and-query — `Exact` byte-for-byte, `Contains`
155/// by substring, `Matches` by regex — and the `query` subset requires
156/// every declared pair to appear among the request's
157/// percent-decoded query pairs, in any position order. A `None`
158/// filter passes everything, and all filters combine conjunctively.
159///
160/// Each request is projected as its `(method, path_and_query)` pair.
161/// The regex of a `Matches` filter compiles once per call, not once
162/// per recorded request; an invalid pattern matches nothing, failing
163/// closed.
164pub fn matching_count<'a>(
165    requests: impl IntoIterator<Item = (&'a str, &'a str)>,
166    method: Option<&str>,
167    path_filter: Option<&PathFilter>,
168    query: Option<&BTreeMap<String, String>>,
169) -> usize {
170    // The regex of a `Matches` filter compiles once per call, not once
171    // per recorded request. An invalid pattern (the parser rejects it
172    // first) matches nothing, failing closed.
173    let matches_regex = match path_filter {
174        Some(PathFilter::Matches(pattern)) => regex::Regex::new(pattern).ok(),
175        _ => None,
176    };
177    requests
178        .into_iter()
179        .filter(|(request_method, path)| {
180            let path_matches = match path_filter {
181                None => true,
182                Some(PathFilter::Exact(p)) => p.as_str() == *path,
183                Some(PathFilter::Contains(s)) => path.contains(s.as_str()),
184                Some(PathFilter::Matches(_)) => {
185                    matches_regex.as_ref().is_some_and(|re| re.is_match(path))
186                }
187            };
188            let query_subset = query.is_none_or(|declared| {
189                let pairs = query_pairs(path);
190                declared
191                    .iter()
192                    .all(|(key, value)| pairs.iter().any(|(k, v)| k == key && v == value))
193            });
194            method.is_none_or(|m| m.eq_ignore_ascii_case(request_method))
195                && path_matches
196                && query_subset
197        })
198        .count()
199}
200
201/// Renders a count bound in its own grammar for mismatch details:
202/// `Exact(3)` renders `expected 3` — the historical phrasing the
203/// exact-count tests pin byte-for-byte — `AtLeast(3)` renders
204/// `expected at least 3`, `AtMost(2)` renders `expected at most 2`,
205/// and `Range(2, 4)` renders `expected between 2 and 4`.
206pub fn render_bound(bound: &CountBound) -> String {
207    match bound {
208        CountBound::Exact(n) => format!("expected {n}"),
209        CountBound::AtLeast(n) => format!("expected at least {n}"),
210        CountBound::AtMost(n) => format!("expected at most {n}"),
211        CountBound::Range(min, max) => format!("expected between {min} and {max}"),
212    }
213}
214
215/// The pure per-form boolean of a validation expectation against a
216/// value: `Equals` compares by equality; `Regex` failing to compile
217/// matches nothing (fail closed), otherwise matching the stringified
218/// value; `Contains`/`StartsWith`/`EndsWith` match the stringified
219/// value; `Exists` holds for any non-null value; `JsonSubset`
220/// recursive-subset matches via [`json_subset`].
221pub fn expectation_matches(expectation: &Expectation, value: &serde_json::Value) -> bool {
222    match expectation {
223        Expectation::Equals(expected) => value == expected,
224        Expectation::Regex(pattern) => {
225            regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&stringify(value)))
226        }
227        Expectation::Contains(needle) => stringify(value).contains(needle),
228        Expectation::StartsWith(prefix) => stringify(value).starts_with(prefix),
229        Expectation::EndsWith(suffix) => stringify(value).ends_with(suffix),
230        Expectation::Exists => value != &serde_json::Value::Null,
231        Expectation::JsonSubset(pattern) => json_subset(pattern, value),
232    }
233}
234
235/// Renders a value for string matchers: strings as-is, anything else
236/// as its JSON form.
237pub fn stringify(value: &serde_json::Value) -> String {
238    match value {
239        serde_json::Value::String(text) => text.clone(),
240        other => other.to_string(),
241    }
242}
243
244/// Recursive-subset match: every key in `pattern` must exist in
245/// `actual` with a recursively subset-matching value; values outside
246/// `pattern` are ignored. Non-object patterns compare by equality.
247fn json_subset(pattern: &serde_json::Value, actual: &serde_json::Value) -> bool {
248    match (pattern, actual) {
249        (serde_json::Value::Object(pattern_object), serde_json::Value::Object(actual_object)) => {
250            pattern_object.iter().all(|(key, pattern_value)| {
251                actual_object
252                    .get(key)
253                    .is_some_and(|actual_value| json_subset(pattern_value, actual_value))
254            })
255        }
256        _ => pattern == actual,
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn bound_holds_covers_every_form_at_edges() {
266        let cases = [
267            (CountBound::Exact(2), [false, false, true, false, false]),
268            (CountBound::AtLeast(2), [false, false, true, true, true]),
269            (CountBound::AtMost(2), [true, true, true, false, false]),
270            (CountBound::Range(1, 3), [false, true, true, true, false]),
271        ];
272        for (bound, holds) in cases {
273            for (count, expected) in holds.into_iter().enumerate() {
274                assert_eq!(
275                    bound_holds(&bound, count),
276                    expected,
277                    "{bound:?} at count {count}"
278                );
279            }
280        }
281    }
282
283    #[test]
284    fn settles_early_absence_claims_never_settle() {
285        for count in 0..=5usize {
286            assert!(
287                !settles_early(&CountBound::AtMost(5), count),
288                "AtMost(5) at count {count}"
289            );
290            assert!(
291                !settles_early(&CountBound::Range(0, 5), count),
292                "Range(0, 5) at count {count}"
293            );
294            assert_eq!(
295                settles_early(&CountBound::Exact(2), count),
296                count == 2,
297                "Exact(2) at count {count}"
298            );
299            assert_eq!(
300                settles_early(&CountBound::AtLeast(2), count),
301                count >= 2,
302                "AtLeast(2) at count {count}"
303            );
304        }
305    }
306
307    #[test]
308    fn above_ceiling_only_upper_breaches() {
309        assert!(above_ceiling(&CountBound::AtMost(2), 3));
310        assert!(above_ceiling(&CountBound::Range(1, 2), 3));
311        assert!(!above_ceiling(&CountBound::Exact(2), 99));
312        assert!(!above_ceiling(&CountBound::AtLeast(2), 99));
313        assert!(!above_ceiling(&CountBound::AtMost(2), 2));
314    }
315
316    #[test]
317    fn matching_count_query_subset_order_and_encoding_independent() {
318        let declared = BTreeMap::from([
319            ("a".to_string(), "1".to_string()),
320            ("b".to_string(), "2".to_string()),
321        ]);
322        let reordered_and_encoded = [("GET", "/x?b=2&a=1"), ("POST", "/x?a=%31&b=%32")];
323        assert_eq!(
324            matching_count(reordered_and_encoded, None, None, Some(&declared)),
325            2
326        );
327        assert_eq!(
328            matching_count([("GET", "/x?a=1")], None, None, Some(&declared)),
329            0
330        );
331
332        let only_a = BTreeMap::from([("a".to_string(), "1".to_string())]);
333        assert_eq!(
334            matching_count([("GET", "/x?a=1")], None, None, Some(&only_a)),
335            1
336        );
337        assert_eq!(
338            matching_count([("GET", "/x?b=2")], None, None, Some(&declared)),
339            0
340        );
341    }
342
343    #[test]
344    fn matching_count_invalid_regex_fails_closed() {
345        let filter = PathFilter::Matches("(".to_string());
346        assert_eq!(
347            matching_count([("GET", "/anything")], None, Some(&filter), None),
348            0
349        );
350    }
351
352    #[test]
353    fn matching_count_method_case_insensitive() {
354        let requests = [("POST", "/o"), ("GET", "/o")];
355        assert_eq!(matching_count(requests, Some("post"), None, None), 1);
356    }
357
358    #[test]
359    fn matching_count_path_forms() {
360        let requests = [("GET", "/o?a=1"), ("POST", "/o?a=1&x=2"), ("GET", "/diff")];
361        let exact = PathFilter::Exact("/o?a=1".to_string());
362        let contains = PathFilter::Contains("/o".to_string());
363        let matches = PathFilter::Matches("^/o".to_string());
364        assert_eq!(matching_count(requests, None, Some(&exact), None), 1);
365        assert_eq!(matching_count(requests, None, Some(&contains), None), 2);
366        assert_eq!(matching_count(requests, None, Some(&matches), None), 2);
367    }
368
369    #[test]
370    fn query_pairs_no_question_mark() {
371        assert!(query_pairs("/noquery").is_empty());
372        assert_eq!(
373            query_pairs("/q?a=1"),
374            vec![("a".to_string(), "1".to_string())]
375        );
376    }
377
378    #[test]
379    fn query_pairs_plus_decoding() {
380        assert_eq!(
381            query_pairs("/x?a=1+2"),
382            vec![("a".to_string(), "1 2".to_string())]
383        );
384    }
385
386    #[test]
387    fn expectation_matches_string_forms() {
388        let value = serde_json::json!("hello world");
389        assert!(expectation_matches(
390            &Expectation::Contains("world".to_string()),
391            &value
392        ));
393        assert!(expectation_matches(
394            &Expectation::StartsWith("hello".to_string()),
395            &value
396        ));
397        assert!(expectation_matches(
398            &Expectation::EndsWith("world".to_string()),
399            &value
400        ));
401        assert!(expectation_matches(&Expectation::Exists, &value));
402        assert!(expectation_matches(
403            &Expectation::Regex("^hello".to_string()),
404            &value
405        ));
406        assert!(expectation_matches(
407            &Expectation::Equals(serde_json::json!("hello world")),
408            &value
409        ));
410        assert!(!expectation_matches(
411            &Expectation::Contains("nope".to_string()),
412            &value
413        ));
414    }
415
416    #[test]
417    fn expectation_matches_object_forms() {
418        let value = serde_json::json!({"n": "café", "s": "hello world"});
419        assert!(expectation_matches(
420            &Expectation::Equals(serde_json::json!({"n": "café", "s": "hello world"})),
421            &value
422        ));
423        assert!(expectation_matches(
424            &Expectation::JsonSubset(serde_json::json!({"n": "café"})),
425            &value
426        ));
427        assert!(expectation_matches(
428            &Expectation::Regex("caf".to_string()),
429            &value
430        ));
431        assert!(expectation_matches(&Expectation::Exists, &value));
432        assert!(!expectation_matches(
433            &Expectation::JsonSubset(serde_json::json!({"n": "other"})),
434            &value
435        ));
436        assert!(!expectation_matches(
437            &Expectation::Exists,
438            &serde_json::Value::Null
439        ));
440        assert!(!expectation_matches(
441            &Expectation::Regex("(".to_string()),
442            &value
443        ));
444    }
445
446    #[test]
447    fn json_subset_recursive_objects() {
448        let actual = serde_json::json!({"user": {"name": "María", "role": "admin"}, "extra": 1});
449        assert!(expectation_matches(
450            &Expectation::JsonSubset(serde_json::json!({"user": {"name": "María"}})),
451            &actual
452        ));
453        assert!(!expectation_matches(
454            &Expectation::JsonSubset(serde_json::json!({"user": {"name": "other"}})),
455            &actual
456        ));
457    }
458
459    #[test]
460    fn render_bound_forms() {
461        let bounds = [
462            CountBound::Exact(3),
463            CountBound::AtLeast(3),
464            CountBound::AtMost(2),
465            CountBound::Range(2, 4),
466        ];
467        let rendered: Vec<String> = bounds.iter().map(render_bound).collect();
468        for text in &rendered {
469            assert!(!text.is_empty(), "empty render for {text:?}");
470        }
471        for (index, left) in rendered.iter().enumerate() {
472            for right in &rendered[index + 1..] {
473                assert_ne!(left, right, "duplicate render `{left}`");
474            }
475        }
476    }
477}