1use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub enum CountBound {
36 Exact(u64),
38 AtLeast(u64),
40 AtMost(u64),
42 Range(u64, u64),
44}
45
46#[derive(Debug, Clone, PartialEq)]
49#[non_exhaustive]
50pub enum PathFilter {
51 Exact(String),
53 Contains(String),
55 Matches(String),
57}
58
59#[derive(Debug, Clone, PartialEq)]
62pub struct RequestExpectation {
63 pub bound: CountBound,
65 pub method: Option<String>,
67 pub path: Option<PathFilter>,
69 pub query: Option<BTreeMap<String, String>>,
73 pub requests: Option<Vec<RequestShape>>,
81}
82
83#[derive(Debug, Clone, PartialEq)]
88pub struct RequestShape {
89 pub method: Option<String>,
91 pub path: Option<PathFilter>,
93 pub query: Option<BTreeMap<String, String>>,
97 pub body: Option<Expectation>,
100}
101
102#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub enum ShapeAspect {
107 Method,
109 Path,
111 Query,
113 Body,
115}
116
117#[derive(Debug, Clone, PartialEq)]
129#[non_exhaustive]
130pub enum Expectation {
131 Equals(serde_json::Value),
133 Regex(String),
135 Contains(String),
137 StartsWith(String),
139 EndsWith(String),
141 Exists,
143 JsonSubset(serde_json::Value),
145 Any,
148}
149
150#[derive(Debug, Clone, PartialEq)]
159pub struct RowsExpectation {
160 pub columns: Option<Vec<String>>,
163 pub unordered: bool,
166 pub rows: Option<Vec<Vec<Expectation>>>,
169 pub bound: Option<CountBound>,
172}
173
174pub 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
187pub 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
205pub 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
218pub 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
231pub 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 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
274fn shape_filter_aspect(
282 method: Option<&str>,
283 path_filter: Option<&PathFilter>,
284 query: Option<&BTreeMap<String, String>>,
285 matches_regex: Option<®ex::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
313pub 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 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
369pub 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
383pub 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
405pub 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 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 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
451fn 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
461fn 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
492pub 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
501fn 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 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}