Skip to main content

apif_assert/
comparator.rs

1use super::engine::AssertionResult;
2use apif_ast::ast::InlineOptions;
3use serde_json::Value;
4
5pub struct JsonComparator;
6
7impl JsonComparator {
8    pub fn compare(
9        actual: &Value,
10        expected: &Value,
11        options: &InlineOptions,
12    ) -> Vec<AssertionResult> {
13        let mut results = Vec::new();
14
15        // 1. Redact fields if needed
16        if options.redact.is_empty() {
17            Self::compare_recursive(actual, expected, "$", options, &mut results);
18        } else {
19            let mut actual_redacted = actual.clone();
20            Self::redact_value(&mut actual_redacted, &options.redact);
21            Self::compare_recursive(&actual_redacted, expected, "$", options, &mut results);
22        }
23
24        results
25    }
26
27    fn redact_value(value: &mut Value, fields: &[String]) {
28        match value {
29            Value::Object(map) => {
30                for field in fields {
31                    map.remove(field);
32                }
33                for (_, v) in map.iter_mut() {
34                    Self::redact_value(v, fields);
35                }
36            }
37            Value::Array(arr) => {
38                for v in arr.iter_mut() {
39                    Self::redact_value(v, fields);
40                }
41            }
42            _ => {}
43        }
44    }
45
46    fn compare_recursive(
47        actual: &Value,
48        expected: &Value,
49        path: &str,
50        options: &InlineOptions,
51        results: &mut Vec<AssertionResult>,
52    ) {
53        // Handle Wildcard "*"
54        if let Value::String(s) = expected
55            && s == "*"
56        {
57            return; // Matches anything
58        }
59
60        // Type mismatch check
61        // Numbers can be float/int, so strictly checking discriminants might be too harsh if serde parses differently.
62        // But generally types should match.
63        // Exception: expected "*" string matches any actual type (handled above).
64
65        match (actual, expected) {
66            (Value::Object(act_map), Value::Object(exp_map)) => {
67                // For objects, iterate over EXPECTED keys
68                for (k, exp_val) in exp_map {
69                    let new_path = format!("{}.{}", path, k);
70
71                    if let Some(act_val) = act_map.get(k) {
72                        Self::compare_recursive(act_val, exp_val, &new_path, options, results);
73                    } else {
74                        // Proto JSON may omit fields with default values.
75                        // If expected value is a default, treat missing key as acceptable.
76                        if !is_protojson_default_value(exp_val) {
77                            results.push(AssertionResult::fail(format!(
78                                "Key '{}' missing in actual response",
79                                new_path
80                            )));
81                        }
82                    }
83                }
84
85                // If NOT partial, check that actual doesn't have extra keys
86                if !options.partial {
87                    for k in act_map.keys() {
88                        if !exp_map.contains_key(k) {
89                            results.push(AssertionResult::fail(format!(
90                                "Unexpected key '{}.{}' in actual response",
91                                path, k
92                            )));
93                        }
94                    }
95                }
96            }
97            (Value::Array(act_arr), Value::Array(exp_arr)) => {
98                // Array length check
99                if !options.partial && act_arr.len() != exp_arr.len() {
100                    results.push(AssertionResult::fail_with_diff(
101                        format!(
102                            "Array length mismatch at '{}': expected {}, got {}",
103                            path,
104                            exp_arr.len(),
105                            act_arr.len()
106                        ),
107                        format!("length: {}", exp_arr.len()),
108                        format!("length: {}", act_arr.len()),
109                    ));
110                }
111
112                // If unordered_arrays is set, we need special handling
113                if options.unordered_arrays {
114                    // OPTIMIZED: Hash-based O(n) comparison instead of O(n²)
115                    // Strategy: Hash each item and compare hash sets
116                    // For items with same hash, do deep comparison
117
118                    let mut matched_actual_indices = std::collections::HashSet::new();
119                    let mut hash_to_indices: std::collections::HashMap<u64, Vec<usize>> =
120                        std::collections::HashMap::new();
121
122                    // Build hash map for actual items
123                    for (i, act_item) in act_arr.iter().enumerate() {
124                        let hash = Self::hash_value(act_item);
125                        hash_to_indices.entry(hash).or_default().push(i);
126                    }
127
128                    // Match expected items against actual items using hash map
129                    for exp_item in exp_arr {
130                        let exp_hash = Self::hash_value(exp_item);
131                        let mut found = false;
132
133                        if let Some(indices) = hash_to_indices.get_mut(&exp_hash) {
134                            for &idx in indices.iter() {
135                                if matched_actual_indices.contains(&idx) {
136                                    continue;
137                                }
138
139                                // Verify with deep comparison
140                                let mut temp_results = Vec::new();
141                                Self::compare_recursive(
142                                    &act_arr[idx],
143                                    exp_item,
144                                    &format!("{}[{}]", path, idx),
145                                    options,
146                                    &mut temp_results,
147                                );
148
149                                if temp_results.is_empty() {
150                                    matched_actual_indices.insert(idx);
151                                    found = true;
152                                    break;
153                                }
154                            }
155                        }
156
157                        if !found && !options.partial {
158                            results.push(AssertionResult::fail(format!(
159                                "Missing expected item in unordered array at '{}': {:?}",
160                                path, exp_item
161                            )));
162                        }
163                    }
164
165                    // Check for extra items in actual (if not partial)
166                    if !options.partial && matched_actual_indices.len() < act_arr.len() {
167                        results.push(AssertionResult::fail(format!(
168                            "Unordered array at '{}' has {} extra items",
169                            path,
170                            act_arr.len() - matched_actual_indices.len()
171                        )));
172                    }
173
174                    return;
175                }
176
177                // Iterate (up to min length)
178                // If partial is true, we usually still expect the items we defined to match the *first* N items
179                // OR we strictly match what we have.
180                // Let's implement strict index matching for the common case.
181
182                let len = std::cmp::min(act_arr.len(), exp_arr.len());
183                for i in 0..len {
184                    let new_path = format!("{}[{}]", path, i);
185                    Self::compare_recursive(&act_arr[i], &exp_arr[i], &new_path, options, results);
186                }
187
188                // If expected is longer than actual, that's always a fail (missing items)
189                if exp_arr.len() > act_arr.len() {
190                    for i in act_arr.len()..exp_arr.len() {
191                        results.push(AssertionResult::fail(format!(
192                            "Missing array item at '{}[{}]'",
193                            path, i
194                        )));
195                    }
196                }
197            }
198            (Value::String(a), Value::String(e)) => {
199                if a != e {
200                    results.push(AssertionResult::fail_with_diff(
201                        format!(
202                            "Value mismatch at '{}': expected \"{}\", got \"{}\"",
203                            path, e, a
204                        ),
205                        e,
206                        a,
207                    ));
208                }
209            }
210            (Value::Number(a), Value::Number(e)) => {
211                // Handle tolerance if provided
212                if let Some(tol) = options.tolerance
213                    && let (Some(af), Some(ef)) = (a.as_f64(), e.as_f64())
214                {
215                    if (af - ef).abs() > tol {
216                        results.push(AssertionResult::fail_with_diff(
217                            format!(
218                                "Value mismatch at '{}': expected {} (tolerance {}), got {}",
219                                path, ef, tol, af
220                            ),
221                            format!("{} (±{})", ef, tol),
222                            format!("{}", af),
223                        ));
224                    }
225                    return;
226                }
227
228                // Treat numerically-equal values as equal, even if JSON representation differs
229                // (e.g. 60 vs 60.0).
230                if let (Some(af), Some(ef)) = (a.as_f64(), e.as_f64())
231                    && (af == ef || (af - ef).abs() <= 1e-6)
232                {
233                    return;
234                }
235
236                if a != e {
237                    results.push(AssertionResult::fail_with_diff(
238                        format!("Value mismatch at '{}': expected {}, got {}", path, e, a),
239                        format!("{}", e),
240                        format!("{}", a),
241                    ));
242                }
243            }
244            (Value::Bool(a), Value::Bool(e)) => {
245                if a != e {
246                    results.push(AssertionResult::fail_with_diff(
247                        format!("Value mismatch at '{}': expected {}, got {}", path, e, a),
248                        format!("{}", e),
249                        format!("{}", a),
250                    ));
251                }
252            }
253            (Value::Null, Value::Null) => {}
254            _ => {
255                // Type mismatch
256                results.push(AssertionResult::fail_with_diff(
257                    format!(
258                        "Type mismatch at '{}': expected {:?}, got {:?}",
259                        path, expected, actual
260                    ),
261                    format!("{:?}", expected),
262                    format!("{:?}", actual),
263                ));
264            }
265        }
266    }
267
268    /// Hash a JSON value for fast comparison
269    /// Uses a simple hash combining approach for efficiency
270    fn hash_value(value: &Value) -> u64 {
271        use std::collections::hash_map::DefaultHasher;
272        use std::hash::{Hash, Hasher};
273
274        let mut hasher = DefaultHasher::new();
275
276        match value {
277            Value::Null => 0u8.hash(&mut hasher),
278            Value::Bool(b) => (1u8, b).hash(&mut hasher),
279            Value::Number(n) => {
280                (2u8, n.as_i64(), n.as_u64(), n.as_f64().map(|f| f.to_bits())).hash(&mut hasher)
281            }
282            Value::String(s) => (3u8, s).hash(&mut hasher),
283            Value::Array(arr) => {
284                (4u8, arr.len()).hash(&mut hasher);
285                for item in arr {
286                    Self::hash_value(item).hash(&mut hasher);
287                }
288            }
289            Value::Object(obj) => {
290                (5u8, obj.len()).hash(&mut hasher);
291                // Sort keys for consistent hashing
292                let mut keys: Vec<_> = obj.keys().collect();
293                keys.sort();
294                for key in keys {
295                    key.hash(&mut hasher);
296                    Self::hash_value(&obj[key]).hash(&mut hasher);
297                }
298            }
299        }
300
301        hasher.finish()
302    }
303}
304
305fn is_protojson_default_value(value: &Value) -> bool {
306    match value {
307        Value::Null => true,
308        Value::Bool(b) => !*b,
309        Value::Number(n) => {
310            if let Some(i) = n.as_i64() {
311                i == 0
312            } else if let Some(u) = n.as_u64() {
313                u == 0
314            } else if let Some(f) = n.as_f64() {
315                f == 0.0
316            } else {
317                false
318            }
319        }
320        Value::String(s) => s.is_empty(),
321        Value::Array(arr) => arr.is_empty(),
322        Value::Object(map) => map.is_empty(),
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use serde_json::json;
330
331    #[test]
332    fn test_compare_exact_match() {
333        let actual = json!({"foo": "bar", "num": 1});
334        let expected = json!({"foo": "bar", "num": 1});
335        let options = InlineOptions::default();
336
337        let results = JsonComparator::compare(&actual, &expected, &options);
338        assert!(results.is_empty());
339    }
340
341    #[test]
342    fn test_compare_numeric_representation_match() {
343        let actual = json!({"result": 60.0});
344        let expected = json!({"result": 60});
345        let options = InlineOptions::default();
346
347        let results = JsonComparator::compare(&actual, &expected, &options);
348        assert!(results.is_empty());
349    }
350
351    #[test]
352    fn test_compare_mismatch() {
353        let actual = json!({"foo": "bar"});
354        let expected = json!({"foo": "baz"});
355        let options = InlineOptions::default();
356
357        let results = JsonComparator::compare(&actual, &expected, &options);
358        assert_eq!(results.len(), 1);
359        if let AssertionResult::Fail { message: msg, .. } = &results[0] {
360            assert!(msg.contains("Value mismatch"));
361        } else {
362            panic!("Expected Fail");
363        }
364    }
365
366    #[test]
367    fn test_compare_partial_object() {
368        let actual = json!({"foo": "bar", "extra": "field"});
369        let expected = json!({"foo": "bar"});
370
371        // Without partial, this should fail (unexpected key)
372        let options = InlineOptions::default();
373        let results = JsonComparator::compare(&actual, &expected, &options);
374        assert_eq!(results.len(), 1);
375
376        // With partial, this should pass
377        let options = InlineOptions {
378            partial: true,
379            ..Default::default()
380        };
381        let results = JsonComparator::compare(&actual, &expected, &options);
382        assert!(results.is_empty());
383    }
384
385    #[test]
386    fn test_wildcard() {
387        let actual = json!({"id": 12345, "name": "test"});
388        let expected = json!({"id": "*", "name": "test"});
389        let options = InlineOptions::default();
390
391        let results = JsonComparator::compare(&actual, &expected, &options);
392        assert!(results.is_empty());
393    }
394
395    #[test]
396    fn test_redact() {
397        let actual = json!({"id": 12345, "secret": "hidden", "name": "test"});
398        // If we redact "secret", it's removed from actual.
399        // If expected doesn't have it, strict match should pass.
400        let expected = json!({"id": 12345, "name": "test"});
401
402        let options = InlineOptions {
403            redact: vec!["secret".to_string()],
404            ..Default::default()
405        };
406
407        let results = JsonComparator::compare(&actual, &expected, &options);
408        assert!(results.is_empty());
409    }
410
411    #[test]
412    fn test_tolerance() {
413        let actual = json!({"val": 10.005});
414        let expected = json!({"val": 10.0});
415
416        let mut options = InlineOptions {
417            tolerance: Some(0.01),
418            ..Default::default()
419        };
420
421        let results = JsonComparator::compare(&actual, &expected, &options);
422        assert!(results.is_empty());
423
424        options.tolerance = Some(0.001);
425        let results = JsonComparator::compare(&actual, &expected, &options);
426        assert_eq!(results.len(), 1);
427    }
428
429    #[test]
430    fn test_compare_empty_objects() {
431        let actual = json!({});
432        let expected = json!({});
433        let options = InlineOptions::default();
434
435        let results = JsonComparator::compare(&actual, &expected, &options);
436        assert!(results.is_empty());
437    }
438
439    #[test]
440    fn test_compare_empty_arrays() {
441        let actual = json!([]);
442        let expected = json!([]);
443        let options = InlineOptions::default();
444
445        let results = JsonComparator::compare(&actual, &expected, &options);
446        assert!(results.is_empty());
447    }
448
449    #[test]
450    fn test_compare_null_values() {
451        let actual = json!({"val": null});
452        let expected = json!({"val": null});
453        let options = InlineOptions::default();
454
455        let results = JsonComparator::compare(&actual, &expected, &options);
456        assert!(results.is_empty());
457    }
458
459    #[test]
460    fn test_compare_null_mismatch() {
461        let actual = json!({"val": "not null"});
462        let expected = json!({"val": null});
463        let options = InlineOptions::default();
464
465        let results = JsonComparator::compare(&actual, &expected, &options);
466        assert_eq!(results.len(), 1);
467    }
468
469    #[test]
470    fn test_compare_boolean_values() {
471        let actual = json!({"active": true, "deleted": false});
472        let expected = json!({"active": true, "deleted": false});
473        let options = InlineOptions::default();
474
475        let results = JsonComparator::compare(&actual, &expected, &options);
476        assert!(results.is_empty());
477    }
478
479    #[test]
480    fn test_compare_boolean_mismatch() {
481        let actual = json!({"active": true});
482        let expected = json!({"active": false});
483        let options = InlineOptions::default();
484
485        let results = JsonComparator::compare(&actual, &expected, &options);
486        assert_eq!(results.len(), 1);
487    }
488
489    #[test]
490    fn test_compare_nested_objects() {
491        let actual = json!({"user": {"name": "test", "age": 25}});
492        let expected = json!({"user": {"name": "test", "age": 25}});
493        let options = InlineOptions::default();
494
495        let results = JsonComparator::compare(&actual, &expected, &options);
496        assert!(results.is_empty());
497    }
498
499    #[test]
500    fn test_compare_nested_mismatch() {
501        let actual = json!({"user": {"name": "test"}});
502        let expected = json!({"user": {"name": "other"}});
503        let options = InlineOptions::default();
504
505        let results = JsonComparator::compare(&actual, &expected, &options);
506        assert_eq!(results.len(), 1);
507    }
508
509    #[test]
510    fn test_compare_arrays_different_lengths() {
511        let actual = json!([1, 2, 3]);
512        let expected = json!([1, 2]);
513        let options = InlineOptions::default();
514
515        let results = JsonComparator::compare(&actual, &expected, &options);
516        assert!(!results.is_empty());
517    }
518
519    #[test]
520    fn test_compare_arrays_with_objects() {
521        let actual = json!([{"id": 1}, {"id": 2}]);
522        let expected = json!([{"id": 1}, {"id": 2}]);
523        let options = InlineOptions::default();
524
525        let results = JsonComparator::compare(&actual, &expected, &options);
526        assert!(results.is_empty());
527    }
528
529    #[test]
530    fn test_compare_partial_nested_object() {
531        let actual = json!({"user": {"name": "test", "age": 25, "extra": "field"}});
532        let expected = json!({"user": {"name": "test"}});
533        let options = InlineOptions {
534            partial: true,
535            ..Default::default()
536        };
537
538        let results = JsonComparator::compare(&actual, &expected, &options);
539        assert!(results.is_empty());
540    }
541
542    #[test]
543    fn test_redact_nested() {
544        let actual = json!({"user": {"password": "secret", "name": "test"}});
545        let expected = json!({"user": {"name": "test"}});
546
547        let options = InlineOptions {
548            redact: vec!["password".to_string()],
549            ..Default::default()
550        };
551
552        let results = JsonComparator::compare(&actual, &expected, &options);
553        assert!(results.is_empty());
554    }
555
556    #[test]
557    fn test_is_protojson_default_value() {
558        assert!(is_protojson_default_value(&Value::String("".to_string())));
559        assert!(is_protojson_default_value(&Value::Number(0.into())));
560        assert!(is_protojson_default_value(&Value::Bool(false)));
561        assert!(is_protojson_default_value(&Value::Array(vec![])));
562        assert!(is_protojson_default_value(&Value::Object(
563            serde_json::Map::new()
564        )));
565        assert!(!is_protojson_default_value(&Value::String(
566            "not empty".to_string()
567        )));
568        assert!(!is_protojson_default_value(&Value::Number(1.into())));
569        assert!(!is_protojson_default_value(&Value::Bool(true)));
570    }
571
572    #[test]
573    fn test_unordered_arrays_optimized() {
574        // Test that unordered arrays work correctly with hash-based optimization
575        let actual = json!([3, 1, 2]);
576        let expected = json!([1, 2, 3]);
577        let options = InlineOptions {
578            unordered_arrays: true,
579            ..Default::default()
580        };
581
582        let results = JsonComparator::compare(&actual, &expected, &options);
583        assert!(results.is_empty());
584    }
585
586    #[test]
587    fn test_unordered_arrays_with_objects() {
588        // Test unordered arrays with complex objects
589        let actual = json!([
590            {"id": 3, "name": "c"},
591            {"id": 1, "name": "a"},
592            {"id": 2, "name": "b"}
593        ]);
594        let expected = json!([
595            {"id": 1, "name": "a"},
596            {"id": 2, "name": "b"},
597            {"id": 3, "name": "c"}
598        ]);
599        let options = InlineOptions {
600            unordered_arrays: true,
601            ..Default::default()
602        };
603
604        let results = JsonComparator::compare(&actual, &expected, &options);
605        assert!(results.is_empty());
606    }
607
608    #[test]
609    fn test_unordered_arrays_missing_item() {
610        // Test that missing items are detected
611        let actual = json!([1, 2]);
612        let expected = json!([1, 2, 3]);
613        let options = InlineOptions {
614            unordered_arrays: true,
615            ..Default::default()
616        };
617
618        let results = JsonComparator::compare(&actual, &expected, &options);
619        assert!(!results.is_empty());
620    }
621
622    #[test]
623    fn test_unordered_arrays_extra_item() {
624        // Test that extra items are detected
625        let actual = json!([1, 2, 3, 4]);
626        let expected = json!([1, 2, 3]);
627        let options = InlineOptions {
628            unordered_arrays: true,
629            ..Default::default()
630        };
631
632        let results = JsonComparator::compare(&actual, &expected, &options);
633        assert!(!results.is_empty());
634    }
635
636    #[test]
637    fn test_unordered_arrays_partial() {
638        // Test that partial matching works with unordered arrays
639        let actual = json!([1, 2, 3, 4]);
640        let expected = json!([1, 3]);
641        let options = InlineOptions {
642            unordered_arrays: true,
643            partial: true,
644            ..Default::default()
645        };
646
647        let results = JsonComparator::compare(&actual, &expected, &options);
648        assert!(results.is_empty());
649    }
650
651    #[test]
652    fn test_hash_value_consistency() {
653        // Test that hash_value produces consistent results
654        let value1 = json!({"id": 1, "name": "test"});
655        let value2 = json!({"id": 1, "name": "test"});
656        let value3 = json!({"name": "test", "id": 1}); // Different key order
657
658        // Hash should be the same for equal values (including different key order)
659        let hash1 = JsonComparator::hash_value(&value1);
660        let hash2 = JsonComparator::hash_value(&value2);
661        let hash3 = JsonComparator::hash_value(&value3);
662
663        assert_eq!(hash1, hash2);
664        assert_eq!(hash2, hash3);
665    }
666}