Skip to main content

cool_diff/
diff.rs

1use serde_json::Value;
2use snafu::Snafu;
3
4use crate::config::{AmbiguousMatchStrategy, ArrayMatchMode, DiffConfig};
5use crate::model::{ChildKind, DiffKind, DiffNode, DiffTree, PathSegment};
6
7type Result<T, E = Error> = std::result::Result<T, E>;
8
9/// Named constant to signify no differences were found.
10const NO_DIFFERENCES: Vec<DiffNode> = vec![];
11
12/// Errors that can occur during diffing.
13#[derive(Debug, Snafu)]
14pub enum Error {
15    /// An expected array element is missing the distinguished key field
16    /// required for key-based matching.
17    #[snafu(display(
18        "expected array element at path `{path}` is missing the key field `{key_field}` required for matching"
19    ))]
20    MissingKeyField {
21        /// The dot-separated path to the array.
22        path: String,
23
24        /// The key field that was expected.
25        key_field: String,
26    },
27
28    /// Multiple actual array elements matched a single expected element
29    /// and the ambiguity strategy is set to Strict.
30    #[snafu(display("ambiguous match at path `{path}`: {count} candidates matched"))]
31    AmbiguousMatch {
32        /// The dot-separated path to the array.
33        path: String,
34
35        /// The number of candidates that matched.
36        count: u16,
37    },
38}
39
40/// Computes a diff tree between `actual` and `expected` values.
41///
42/// The walk is driven by `expected`. Only paths present in the expected
43/// value are compared. Fields in `actual` that have no corresponding
44/// expected entry are counted as omitted but not diffed.
45pub fn diff(actual: &Value, expected: &Value, config: &DiffConfig) -> Result<DiffTree> {
46    // The root of the diff tree has an empty path
47    let path = "";
48    let roots = match diff_values(actual, expected, config, path)? {
49        // e.g. actual = 42, expected = 42
50        // or actual = {...}, expected = {...}
51        // or actual = [...], expected = [...]
52        DiffResult::Equal => NO_DIFFERENCES,
53        // e.g. actual = 42, expected = "hello"
54        // Root-level leaf diffs have no parent segment, so we synthesize
55        // a single-element tree without a container wrapper.
56        DiffResult::Leaf(_kind) => NO_DIFFERENCES,
57        // e.g. actual = {a: 1, b: 2}, expected = {a: 1, b: 3}
58        DiffResult::Children { nodes, .. } => nodes,
59    };
60    Ok(DiffTree { roots })
61}
62
63/// The result of comparing two values. Separates "what kind of diff" from
64/// node construction, since the caller provides the `PathSegment`.
65enum DiffResult {
66    /// Values are equal.
67    Equal,
68
69    /// A leaf-level difference (scalar mismatch or type mismatch).
70    /// The caller wraps this in a `DiffNode::Leaf` with the appropriate segment.
71    Leaf(DiffKind),
72
73    /// Child diff nodes from comparing container contents (objects or arrays).
74    /// The caller wraps this in a `DiffNode::Container` with the appropriate segment.
75    Children {
76        child_kind: ChildKind,
77        nodes: Vec<DiffNode>,
78        omitted_count: u16,
79    },
80}
81
82/// Recursively compares two values and returns a diff result.
83///
84/// `path` is the dot-separated path to the current position, used to look up
85/// array match configuration.
86fn diff_values(
87    actual: &Value,
88    expected: &Value,
89    config: &DiffConfig,
90    path: &str,
91) -> Result<DiffResult> {
92    // Type mismatch at the discriminant level (e.g. string vs number,
93    // object vs array).
94    if std::mem::discriminant(actual) != std::mem::discriminant(expected) {
95        return Ok(DiffResult::Leaf(DiffKind::type_mismatch(
96            actual.clone(),
97            value_type_name(actual),
98            expected.clone(),
99            value_type_name(expected),
100        )));
101    }
102
103    match (actual, expected) {
104        // Scalars: direct comparison.
105        (Value::Null, Value::Null) => Ok(DiffResult::Equal),
106        (Value::Bool(a), Value::Bool(e)) if a == e => Ok(DiffResult::Equal),
107        (Value::Number(a), Value::Number(e)) if a == e => Ok(DiffResult::Equal),
108        (Value::String(a), Value::String(e)) if a == e => Ok(DiffResult::Equal),
109
110        // Scalar mismatch (same type, different value).
111        (Value::Bool(_), Value::Bool(_))
112        | (Value::Number(_), Value::Number(_))
113        | (Value::String(_), Value::String(_)) => Ok(DiffResult::Leaf(DiffKind::changed(
114            actual.clone(),
115            expected.clone(),
116        ))),
117
118        // object comparison
119        (Value::Object(actual_map), Value::Object(expected_map)) => {
120            diff_objects(actual_map, expected_map, config, path)
121        }
122
123        // Array comparison, dispatched by match mode
124        (Value::Array(actual_arr), Value::Array(expected_arr)) => {
125            diff_arrays(actual_arr, expected_arr, config, path)
126        }
127
128        _ => unreachable!("discriminant check above ensures matching types"),
129    }
130}
131
132/// Compares two objects and returns a diff result.
133///
134/// Iterates expected keys. For each key:
135/// - Missing from actual: produces a `Missing` leaf.
136/// - Present in actual: recurses via `diff_values` and wraps the result.
137///
138/// `omitted_count` tracks actual keys not present in expected.
139fn diff_objects(
140    actual_map: &serde_json::Map<String, Value>,
141    expected_map: &serde_json::Map<String, Value>,
142    config: &DiffConfig,
143    path: &str,
144) -> Result<DiffResult> {
145    let mut children = Vec::new();
146
147    // Loop through the expected map pairs and then check each against the
148    // actual map for the same key.
149    for (key, expected_val) in expected_map {
150        // Build the dot-separated path for config lookups (e.g. "spec.containers").
151        // At the root level, path is empty so we avoid a leading dot.
152        let child_path = if path.is_empty() {
153            key.clone()
154        } else {
155            format!("{path}.{key}")
156        };
157        let segment = PathSegment::Key(key.clone());
158
159        match actual_map.get(key) {
160            // Expected key doesn't exist in actual
161            None => {
162                let kind = DiffKind::missing(expected_val.clone());
163                children.push(DiffNode::leaf(segment, kind));
164            }
165
166            // Key exists in both, recurse to compare values
167            Some(actual_val) => {
168                match diff_values(actual_val, expected_val, config, &child_path)? {
169                    // Values are equal, nothing to record
170                    DiffResult::Equal => {}
171
172                    // Scalar or type mismatch, wrap as a leaf node
173                    DiffResult::Leaf(kind) => {
174                        children.push(DiffNode::leaf(segment, kind));
175                    }
176
177                    // Nested differences in a child object or array
178                    DiffResult::Children {
179                        child_kind,
180                        nodes,
181                        omitted_count,
182                    } => {
183                        children.push(DiffNode::container(
184                            segment,
185                            child_kind,
186                            omitted_count,
187                            nodes,
188                        ));
189                    }
190                }
191            }
192        }
193    }
194
195    // no differences
196    if children.is_empty() {
197        return Ok(DiffResult::Equal);
198    }
199
200    // Count of actual keys not checked because they have no corresponding
201    // expected key. The renderer uses this for "# N fields omitted" markers.
202    let omitted_count = actual_map.len().saturating_sub(expected_map.len()) as u16;
203    Ok(DiffResult::Children {
204        child_kind: ChildKind::Fields,
205        nodes: children,
206        omitted_count,
207    })
208}
209
210/// Compares two arrays and returns a diff result.
211///
212/// Looks up the `ArrayMatchMode` for the current path and dispatches
213/// to the appropriate matching strategy.
214fn diff_arrays(
215    actual_arr: &[Value],
216    expected_arr: &[Value],
217    config: &DiffConfig,
218    path: &str,
219) -> Result<DiffResult> {
220    let path_config = config.match_config().config_at(path);
221    let mode = path_config
222        .map(|c| c.mode())
223        .unwrap_or(config.default_array_mode());
224    let ambiguous_strategy = path_config
225        .and_then(|c| c.ambiguous_strategy())
226        .unwrap_or(config.default_ambiguous_strategy());
227
228    match mode {
229        ArrayMatchMode::Index => diff_arrays_by_index(actual_arr, expected_arr, config, path),
230        ArrayMatchMode::Key(key_field) => diff_arrays_by_key(
231            actual_arr,
232            expected_arr,
233            key_field,
234            ambiguous_strategy,
235            config,
236            path,
237        ),
238        ArrayMatchMode::Contains => {
239            diff_arrays_by_contains(actual_arr, expected_arr, ambiguous_strategy, config, path)
240        }
241    }
242}
243
244/// Index-based array matching. Compares elements at the same position.
245///
246/// For each expected element, if the actual array has an element at that
247/// index, recurse. Otherwise, produce a `Missing` leaf.
248fn diff_arrays_by_index(
249    actual_arr: &[Value],
250    expected_arr: &[Value],
251    config: &DiffConfig,
252    path: &str,
253) -> Result<DiffResult> {
254    let mut children = Vec::new();
255
256    // Loop through the expected array items and then check each against the
257    // actual array for the element of the same index.
258    for (i, expected_elem) in expected_arr.iter().enumerate() {
259        let segment = PathSegment::Index(i as u16);
260
261        match actual_arr.get(i) {
262            // Expected index is beyond the actual array length
263            None => {
264                let kind = DiffKind::missing(expected_elem.clone());
265                children.push(DiffNode::leaf(segment, kind));
266            }
267
268            // Both sides have an element at this index, recurse
269            Some(actual_elem) => {
270                match diff_values(actual_elem, expected_elem, config, path)? {
271                    // Values are equal, nothing to record
272                    DiffResult::Equal => {}
273
274                    // Scalar or type mismatch, wrap as a leaf node
275                    DiffResult::Leaf(kind) => {
276                        children.push(DiffNode::leaf(segment, kind));
277                    }
278
279                    // Nested differences in a child object or array
280                    DiffResult::Children {
281                        child_kind,
282                        nodes,
283                        omitted_count,
284                    } => {
285                        children.push(DiffNode::container(
286                            segment,
287                            child_kind,
288                            omitted_count,
289                            nodes,
290                        ));
291                    }
292                }
293            }
294        }
295    }
296
297    // no differences
298    if children.is_empty() {
299        return Ok(DiffResult::Equal);
300    }
301
302    // Extra elements in actual that have no corresponding expected element.
303    // The renderer uses this for "# N items omitted" markers.
304    let omitted_count = actual_arr.len().saturating_sub(expected_arr.len()) as u16;
305    Ok(DiffResult::Children {
306        child_kind: ChildKind::Items,
307        nodes: children,
308        omitted_count,
309    })
310}
311
312/// Key-based array matching. Matches elements by a distinguished key field.
313///
314/// For each expected element, extracts the value of `key_field` and scans
315/// the actual array for an element with the same key value. If found,
316/// recurse to compare them. If not found, produce a `Missing` leaf.
317fn diff_arrays_by_key(
318    actual_arr: &[Value],
319    expected_arr: &[Value],
320    key_field: &str,
321    ambiguous_strategy: &AmbiguousMatchStrategy,
322    config: &DiffConfig,
323    path: &str,
324) -> Result<DiffResult> {
325    let mut children = Vec::new();
326    // Track which actual elements were matched so we can count omitted ones
327    let mut matched_count: u16 = 0;
328
329    // Loop through the expected array items and then check each against the
330    // actual array for the element with the matching key.
331    for expected_elem in expected_arr {
332        // Extract the key value from the expected element.
333        // If the expected element doesn't have the key field, that's a
334        // configuration error.
335        let expected_key_val = expected_elem
336            .get(key_field)
337            .and_then(|v| v.as_str())
338            .ok_or_else(|| Error::MissingKeyField {
339                path: path.to_owned(),
340                key_field: key_field.to_owned(),
341            })?;
342
343        // Find the matching element in the actual array
344        let candidates: Vec<&Value> = actual_arr
345            .iter()
346            .filter(|elem| elem.get(key_field).and_then(|v| v.as_str()) == Some(expected_key_val))
347            .collect();
348
349        match candidates.len() {
350            // No actual element has this key value
351            0 => {
352                let kind = DiffKind::missing(expected_elem.clone());
353                children.push(DiffNode::leaf(PathSegment::Unmatched, kind));
354            }
355
356            // Exactly one match, recurse to compare
357            1 => {
358                matched_count += 1;
359                let segment = PathSegment::NamedElement {
360                    match_key: key_field.to_owned(),
361                    match_value: expected_key_val.to_owned(),
362                };
363                match diff_values(candidates[0], expected_elem, config, path)? {
364                    // Values are equal, nothing to record
365                    DiffResult::Equal => {}
366
367                    // Scalar or type mismatch, wrap as a leaf node
368                    DiffResult::Leaf(kind) => {
369                        children.push(DiffNode::leaf(segment, kind));
370                    }
371
372                    // Nested differences in a child object or array
373                    DiffResult::Children {
374                        child_kind,
375                        nodes,
376                        omitted_count,
377                    } => {
378                        children.push(DiffNode::container(
379                            segment,
380                            child_kind,
381                            omitted_count,
382                            nodes,
383                        ));
384                    }
385                }
386            }
387
388            // Multiple actual elements share the same key value
389            _ => match ambiguous_strategy {
390                AmbiguousMatchStrategy::Strict => {
391                    return Err(Error::AmbiguousMatch {
392                        path: path.to_owned(),
393                        count: candidates.len() as u16,
394                    });
395                }
396
397                AmbiguousMatchStrategy::BestMatch | AmbiguousMatchStrategy::Silent => {
398                    matched_count += 1;
399                    let segment = PathSegment::NamedElement {
400                        match_key: key_field.to_owned(),
401                        match_value: expected_key_val.to_owned(),
402                    };
403                    // Pick the candidate with the fewest diffs
404                    let best =
405                        pick_best_match(candidates.iter().copied(), expected_elem, config, path)?;
406                    push_diff_result(&mut children, segment, best);
407                }
408            },
409        }
410    }
411
412    // no difference
413    if children.is_empty() {
414        return Ok(DiffResult::Equal);
415    }
416
417    // Elements in actual that were not matched by any expected element
418    let omitted_count = (actual_arr.len() as u16).saturating_sub(matched_count);
419    Ok(DiffResult::Children {
420        child_kind: ChildKind::Items,
421        nodes: children,
422        omitted_count,
423    })
424}
425
426/// Contains-based array matching. Finds a matching element anywhere in the
427/// actual array.
428///
429/// For scalars, matches by exact value equality. For objects, matches by
430/// recursive subset comparison (all expected fields must match).
431fn diff_arrays_by_contains(
432    actual_arr: &[Value],
433    expected_arr: &[Value],
434    ambiguous_strategy: &AmbiguousMatchStrategy,
435    _config: &DiffConfig,
436    path: &str,
437) -> Result<DiffResult> {
438    let mut children = Vec::new();
439    // Track which actual indices were matched so we can count omitted ones
440    let mut matched_count: u16 = 0;
441
442    for expected_elem in expected_arr {
443        // Find all actual elements that are equal or a superset of expected.
444        // Because value_contains verifies all expected fields match recursively,
445        // a matched candidate will always produce Equal from diff_values, so we
446        // don't need to recurse into matched elements.
447        let candidates: Vec<(usize, &Value)> = actual_arr
448            .iter()
449            .enumerate()
450            .filter(|(_, actual_elem)| value_contains(actual_elem, expected_elem))
451            .collect();
452
453        match candidates.len() {
454            // No actual element matches the expected element
455            0 => {
456                let kind = DiffKind::missing(expected_elem.clone());
457                children.push(DiffNode::leaf(PathSegment::Unmatched, kind));
458            }
459
460            // Exactly one match. No need to diff since value_contains
461            // already verified all expected fields match.
462            1 => {
463                matched_count += 1;
464            }
465
466            // Multiple actual elements match
467            _ => match ambiguous_strategy {
468                AmbiguousMatchStrategy::Strict => {
469                    return Err(Error::AmbiguousMatch {
470                        path: path.to_owned(),
471                        count: candidates.len() as u16,
472                    });
473                }
474
475                // All candidates are supersets of expected, so they all
476                // produce Equal. Just count any one as matched.
477                AmbiguousMatchStrategy::BestMatch | AmbiguousMatchStrategy::Silent => {
478                    matched_count += 1;
479                }
480            },
481        }
482    }
483
484    if children.is_empty() {
485        return Ok(DiffResult::Equal);
486    }
487
488    // Elements in actual that were not matched by any expected element
489    let omitted_count = (actual_arr.len() as u16).saturating_sub(matched_count);
490    Ok(DiffResult::Children {
491        child_kind: ChildKind::Items,
492        nodes: children,
493        omitted_count,
494    })
495}
496
497/// Checks whether `actual` contains all the data in `expected`.
498///
499/// For scalars, this is exact equality. For objects, every key in `expected`
500/// must exist in `actual` with a matching value (recursively). For arrays,
501/// this is exact equality (element-by-element).
502fn value_contains(actual: &Value, expected: &Value) -> bool {
503    match (actual, expected) {
504        // Different types never match
505        _ if std::mem::discriminant(actual) != std::mem::discriminant(expected) => false,
506
507        // Scalars: exact equality
508        (Value::Null, Value::Null) => true,
509        (Value::Bool(a), Value::Bool(e)) => a == e,
510        (Value::Number(a), Value::Number(e)) => a == e,
511        (Value::String(a), Value::String(e)) => a == e,
512
513        // Objects: every expected key must exist and match in actual
514        (Value::Object(actual_map), Value::Object(expected_map)) => {
515            expected_map.iter().all(|(key, expected_val)| {
516                actual_map
517                    .get(key)
518                    .is_some_and(|actual_val| value_contains(actual_val, expected_val))
519            })
520        }
521
522        // Arrays: exact element-by-element equality
523        (Value::Array(a), Value::Array(e)) => a == e,
524
525        _ => unreachable!("discriminant check above ensures matching types"),
526    }
527}
528
529/// Picks the candidate with the fewest diffs against the expected element.
530///
531/// Returns the `DiffResult` from comparing the best candidate. If any
532/// candidate produces `Equal`, that one wins immediately.
533fn pick_best_match<'a>(
534    candidates: impl Iterator<Item = &'a Value>,
535    expected: &Value,
536    config: &DiffConfig,
537    path: &str,
538) -> Result<DiffResult> {
539    let mut best: Option<DiffResult> = None;
540    // Fewest diffs wins. None means no candidate has been evaluated yet.
541    let mut best_count: Option<usize> = None;
542
543    for candidate in candidates {
544        let result = diff_values(candidate, expected, config, path)?;
545
546        // An exact match is the best possible outcome
547        if matches!(result, DiffResult::Equal) {
548            return Ok(result);
549        }
550
551        // Count direct child diffs as a rough proxy for "how different".
552        // A recursive leaf count would be more accurate but this is good
553        // enough for picking the least-bad match.
554        let count = match &result {
555            DiffResult::Children { nodes, .. } => nodes.len(),
556            DiffResult::Leaf(_) => 1,
557            DiffResult::Equal => unreachable!("handled above"),
558        };
559
560        // When a new best match is found, update it
561        if best_count.is_none() || count < best_count.expect("guarded by is_none check") {
562            best = Some(result);
563            best_count = Some(count);
564        }
565    }
566
567    Ok(best.unwrap_or(DiffResult::Equal))
568}
569
570/// Pushes a `DiffResult` as a child node, if it represents a difference.
571fn push_diff_result(children: &mut Vec<DiffNode>, segment: PathSegment, result: DiffResult) {
572    match result {
573        // Noop, no difference to push
574        DiffResult::Equal => {}
575
576        // Scalar or type mismatch, wrap as a leaf node
577        DiffResult::Leaf(kind) => {
578            children.push(DiffNode::leaf(segment, kind));
579        }
580
581        // Nested differences in a child object or array
582        DiffResult::Children {
583            child_kind,
584            nodes,
585            omitted_count,
586        } => {
587            children.push(DiffNode::container(segment, child_kind, omitted_count, nodes));
588        }
589    }
590}
591
592/// Returns a human-readable type name for a JSON value.
593fn value_type_name(value: &Value) -> &'static str {
594    match value {
595        Value::Null => "null",
596        Value::Bool(_) => "bool",
597        Value::Number(_) => "number",
598        Value::String(_) => "string",
599        Value::Array(_) => "array",
600        Value::Object(_) => "object",
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use serde_json::json;
608
609    fn default_config() -> DiffConfig {
610        DiffConfig::default()
611    }
612
613    // Key ordering in objects is irrelevant because serde_json::Map uses
614    // BTreeMap, which normalises key order after deserialization. These
615    // tests document that guarantee so a future change (e.g. switching
616    // to preserve-order) would surface here.
617
618    #[test]
619    fn object_key_order_does_not_affect_equality() {
620        let actual = json!({"z": 1, "a": 2, "m": 3});
621        let expected = json!({"m": 3, "z": 1, "a": 2});
622        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
623        assert!(tree.is_empty());
624    }
625
626    #[test]
627    fn object_key_order_does_not_affect_diffs() {
628        // Keys written in different order, but the diff should only
629        // reflect the value difference, not ordering.
630        let actual = json!({"z": 1, "a": 2});
631        let expected = json!({"a": 99, "z": 1});
632        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
633
634        assert_eq!(tree.roots.len(), 1);
635        let DiffNode::Leaf { segment, kind } = &tree.roots[0] else {
636            panic!("expected Leaf");
637        };
638        assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
639        assert!(matches!(kind, DiffKind::Changed { .. }));
640    }
641
642    #[test]
643    fn equal_objects_produce_empty_diff() {
644        let actual = json!({"a": 1, "b": "hello"});
645        let expected = json!({"a": 1, "b": "hello"});
646        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
647        assert!(tree.is_empty());
648    }
649
650    #[test]
651    fn scalar_changed() {
652        let actual = json!({"a": {"b": {"c": "foo"}}});
653        let expected = json!({"a": {"b": {"c": "bar"}}});
654        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
655
656        // Should produce: a -> b -> c: Changed("foo" -> "bar")
657        assert_eq!(tree.roots.len(), 1);
658        let DiffNode::Container {
659            segment, children, ..
660        } = &tree.roots[0]
661        else {
662            panic!("expected Container");
663        };
664        assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
665
666        let DiffNode::Container {
667            segment, children, ..
668        } = &children[0]
669        else {
670            panic!("expected Container");
671        };
672        assert!(matches!(segment, PathSegment::Key(k) if k == "b"));
673
674        let DiffNode::Leaf { segment, kind } = &children[0] else {
675            panic!("expected Leaf");
676        };
677        assert!(matches!(segment, PathSegment::Key(k) if k == "c"));
678        assert!(matches!(kind, DiffKind::Changed { actual, expected }
679            if actual == &json!("foo") && expected == &json!("bar")
680        ));
681    }
682
683    #[test]
684    fn missing_key() {
685        let actual = json!({"a": 1});
686        let expected = json!({"a": 1, "b": 2});
687        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
688
689        assert_eq!(tree.roots.len(), 1);
690        let DiffNode::Leaf { segment, kind } = &tree.roots[0] else {
691            panic!("expected Leaf");
692        };
693        assert!(matches!(segment, PathSegment::Key(k) if k == "b"));
694        assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(2)));
695    }
696
697    #[test]
698    fn type_mismatch() {
699        let actual = json!({"a": 42});
700        let expected = json!({"a": "42"});
701        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
702
703        assert_eq!(tree.roots.len(), 1);
704        let DiffNode::Leaf { segment, kind } = &tree.roots[0] else {
705            panic!("expected Leaf");
706        };
707        assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
708        assert!(matches!(
709            kind,
710            DiffKind::TypeMismatch {
711                actual_type: "number",
712                expected_type: "string",
713                ..
714            }
715        ));
716    }
717
718    #[test]
719    fn omitted_count_reflects_extra_actual_keys() {
720        let actual = json!({"a": 1, "b": 2, "c": 3});
721        let expected = json!({"a": 99});
722        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
723
724        assert_eq!(tree.roots.len(), 1);
725        let DiffNode::Leaf { kind, .. } = &tree.roots[0] else {
726            panic!("expected Leaf for Changed");
727        };
728        assert!(matches!(kind, DiffKind::Changed { .. }));
729
730        // The root-level Children omitted_count should be 2 (b and c not in expected).
731        // But since roots are unwrapped from Children, we need to check via diff_values directly.
732        let result = diff_values(&actual, &expected, &default_config(), "")
733            .expect("diff_values with valid inputs");
734        assert!(matches!(
735            result,
736            DiffResult::Children {
737                omitted_count: 2,
738                ..
739            }
740        ));
741    }
742
743    #[test]
744    fn nested_missing_key() {
745        let actual = json!({"a": {"x": 1}});
746        let expected = json!({"a": {"x": 1, "y": 2}});
747        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
748
749        assert_eq!(tree.roots.len(), 1);
750        let DiffNode::Container {
751            segment,
752            children,
753            omitted_count,
754            ..
755        } = &tree.roots[0]
756        else {
757            panic!("expected Container");
758        };
759        assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
760        assert_eq!(*omitted_count, 0);
761
762        assert_eq!(children.len(), 1);
763        let DiffNode::Leaf { segment, kind } = &children[0] else {
764            panic!("expected Leaf");
765        };
766        assert!(matches!(segment, PathSegment::Key(k) if k == "y"));
767        assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(2)));
768    }
769
770    #[test]
771    fn index_based_array_equal() {
772        let actual = json!({"items": [1, 2, 3]});
773        let expected = json!({"items": [1, 2, 3]});
774        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
775        assert!(tree.is_empty());
776    }
777
778    #[test]
779    fn index_based_array_changed() {
780        let actual = json!({"items": [1, 2, 3]});
781        let expected = json!({"items": [1, 99, 3]});
782        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
783
784        // items -> Index(1): Changed(2 -> 99)
785        assert_eq!(tree.roots.len(), 1);
786        let DiffNode::Container { children, .. } = &tree.roots[0] else {
787            panic!("expected Container");
788        };
789        assert_eq!(children.len(), 1);
790        let DiffNode::Leaf { segment, kind } = &children[0] else {
791            panic!("expected Leaf");
792        };
793        assert!(matches!(segment, PathSegment::Index(1)));
794        assert!(matches!(kind, DiffKind::Changed { actual, expected }
795            if actual == &json!(2) && expected == &json!(99)
796        ));
797    }
798
799    #[test]
800    fn index_based_array_missing_element() {
801        let actual = json!({"items": [1]});
802        let expected = json!({"items": [1, 2, 3]});
803        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
804
805        let DiffNode::Container { children, .. } = &tree.roots[0] else {
806            panic!("expected Container");
807        };
808        assert_eq!(children.len(), 2);
809
810        // Index 1 is missing
811        let DiffNode::Leaf { segment, kind } = &children[0] else {
812            panic!("expected Leaf");
813        };
814        assert!(matches!(segment, PathSegment::Index(1)));
815        assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(2)));
816
817        // Index 2 is missing
818        let DiffNode::Leaf { segment, kind } = &children[1] else {
819            panic!("expected Leaf");
820        };
821        assert!(matches!(segment, PathSegment::Index(2)));
822        assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(3)));
823    }
824
825    #[test]
826    fn index_based_array_omitted_count() {
827        // actual has 5 elements, expected checks 2. Omitted count = 3.
828        let actual = json!({"items": [1, 2, 3, 4, 5]});
829        let expected = json!({"items": [1, 99]});
830        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
831
832        // Root: items Container (omitted_count=0 since both objects have 1 key)
833        let DiffNode::Container {
834            segment,
835            children,
836            omitted_count,
837            ..
838        } = &tree.roots[0]
839        else {
840            panic!("expected Container for items key");
841        };
842        assert!(matches!(segment, PathSegment::Key(k) if k == "items"));
843        assert_eq!(*omitted_count, 3);
844        // Only one child: Index(1) Changed(2 -> 99). Index(0) is equal.
845        assert_eq!(children.len(), 1);
846        assert!(matches!(
847            &children[0],
848            DiffNode::Leaf {
849                segment: PathSegment::Index(1),
850                kind: DiffKind::Changed { .. },
851            }
852        ));
853    }
854
855    fn config_with_key_at(path: &str, key: &str) -> DiffConfig {
856        use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
857        DiffConfig::new().with_match_config(MatchConfig::new().with_config_at(
858            path,
859            ArrayMatchConfig::new(ArrayMatchMode::Key(key.to_owned())),
860        ))
861    }
862
863    #[test]
864    fn key_based_array_equal() {
865        let config = config_with_key_at("items", "name");
866        let actual = json!({"items": [{"name": "a", "val": 1}, {"name": "b", "val": 2}]});
867        let expected = json!({"items": [{"name": "a", "val": 1}]});
868        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
869        assert!(tree.is_empty());
870    }
871
872    #[test]
873    fn key_based_array_changed() {
874        let config = config_with_key_at("items", "name");
875        let actual = json!({"items": [{"name": "FOO", "value": "bar"}]});
876        let expected = json!({"items": [{"name": "FOO", "value": "baz"}]});
877        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
878
879        // items -> NamedElement(name=FOO) -> value: Changed("bar" -> "baz")
880        assert_eq!(tree.roots.len(), 1);
881        let DiffNode::Container { children, .. } = &tree.roots[0] else {
882            panic!("expected Container for items");
883        };
884        assert_eq!(children.len(), 1);
885        let DiffNode::Container {
886            segment, children, ..
887        } = &children[0]
888        else {
889            panic!("expected Container for named element");
890        };
891        assert!(
892            matches!(segment, PathSegment::NamedElement { match_key, match_value }
893                if match_key == "name" && match_value == "FOO"
894            )
895        );
896        assert_eq!(children.len(), 1);
897        let DiffNode::Leaf { segment, kind } = &children[0] else {
898            panic!("expected Leaf");
899        };
900        assert!(matches!(segment, PathSegment::Key(k) if k == "value"));
901        assert!(matches!(kind, DiffKind::Changed { actual, expected }
902            if actual == &json!("bar") && expected == &json!("baz")
903        ));
904    }
905
906    #[test]
907    fn key_based_array_missing_element() {
908        let config = config_with_key_at("items", "name");
909        let actual = json!({"items": [{"name": "a"}]});
910        let expected = json!({"items": [{"name": "missing"}]});
911        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
912
913        let DiffNode::Container { children, .. } = &tree.roots[0] else {
914            panic!("expected Container for items");
915        };
916        assert_eq!(children.len(), 1);
917        let DiffNode::Leaf { segment, kind } = &children[0] else {
918            panic!("expected Leaf");
919        };
920        assert!(matches!(segment, PathSegment::Unmatched));
921        assert!(matches!(kind, DiffKind::Missing { .. }));
922    }
923
924    #[test]
925    fn key_based_array_omitted_count() {
926        let config = config_with_key_at("items", "name");
927        let actual = json!({"items": [{"name": "a"}, {"name": "b"}, {"name": "c"}]});
928        let expected = json!({"items": [{"name": "b"}]});
929        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
930
931        // No diffs (b matches), but omitted count should be 2 (a and c)
932        // Since there are no diffs, the tree should be empty... but omitted_count
933        // is only set when there ARE diffs. With all equal, we get Equal.
934        assert!(tree.is_empty());
935
936        // Check omitted count via diff_values directly with a diff present
937        let actual = json!({"items": [{"name": "a"}, {"name": "b", "x": 1}, {"name": "c"}]});
938        let expected = json!({"items": [{"name": "b", "x": 99}]});
939        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
940
941        let DiffNode::Container {
942            children: _children,
943            omitted_count,
944            ..
945        } = &tree.roots[0]
946        else {
947            panic!("expected Container for items");
948        };
949        // 3 actual elements, 1 matched = 2 omitted
950        assert_eq!(*omitted_count, 2);
951    }
952
953    fn config_with_contains_at(path: &str) -> DiffConfig {
954        use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
955        DiffConfig::new().with_match_config(
956            MatchConfig::new()
957                .with_config_at(path, ArrayMatchConfig::new(ArrayMatchMode::Contains)),
958        )
959    }
960
961    #[test]
962    fn contains_array_scalar_equal() {
963        let config = config_with_contains_at("items");
964        let actual = json!({"items": ["a", "b", "c"]});
965        let expected = json!({"items": ["b"]});
966        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
967        assert!(tree.is_empty());
968    }
969
970    #[test]
971    fn contains_array_object_subset_equal() {
972        let config = config_with_contains_at("items");
973        let actual = json!({"items": [{"a": 1, "b": 2}, {"c": 3}]});
974        let expected = json!({"items": [{"a": 1}]});
975        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
976        assert!(tree.is_empty());
977    }
978
979    #[test]
980    fn contains_array_missing_element() {
981        let config = config_with_contains_at("items");
982        let actual = json!({"items": ["a", "b"]});
983        let expected = json!({"items": ["x"]});
984        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
985
986        let DiffNode::Container { children, .. } = &tree.roots[0] else {
987            panic!("expected Container for items");
988        };
989        assert_eq!(children.len(), 1);
990        let DiffNode::Leaf { segment, kind } = &children[0] else {
991            panic!("expected Leaf");
992        };
993        assert!(matches!(segment, PathSegment::Unmatched));
994        assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!("x")));
995    }
996
997    #[test]
998    fn contains_array_match_not_at_first_position() {
999        let config = config_with_contains_at("items");
1000        // {b: 1} matches the second element (index 1), not the first.
1001        // Since all expected fields match, the result is equal.
1002        let actual = json!({"items": [{"a": 1}, {"b": 1}]});
1003        let expected = json!({"items": [{"b": 1}]});
1004        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
1005        assert!(tree.is_empty());
1006    }
1007
1008    #[test]
1009    fn contains_array_omitted_count() {
1010        let config = config_with_contains_at("items");
1011        let actual = json!({"items": ["a", "b", "c"]});
1012        let expected = json!({"items": ["x"]});
1013        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
1014
1015        let DiffNode::Container { omitted_count, .. } = &tree.roots[0] else {
1016            panic!("expected Container for items");
1017        };
1018        // 0 matched out of 3 actual = 3 omitted
1019        assert_eq!(*omitted_count, 3);
1020    }
1021
1022    fn config_with_key_and_strategy(
1023        path: &str,
1024        key: &str,
1025        strategy: AmbiguousMatchStrategy,
1026    ) -> DiffConfig {
1027        use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
1028        DiffConfig::new().with_match_config(
1029            MatchConfig::new().with_config_at(
1030                path,
1031                ArrayMatchConfig::new(ArrayMatchMode::Key(key.to_owned()))
1032                    .with_ambiguous_strategy(strategy),
1033            ),
1034        )
1035    }
1036
1037    fn config_with_contains_and_strategy(
1038        path: &str,
1039        strategy: AmbiguousMatchStrategy,
1040    ) -> DiffConfig {
1041        use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
1042        DiffConfig::new().with_match_config(MatchConfig::new().with_config_at(
1043            path,
1044            ArrayMatchConfig::new(ArrayMatchMode::Contains).with_ambiguous_strategy(strategy),
1045        ))
1046    }
1047
1048    #[test]
1049    fn ambiguous_key_best_match_picks_fewest_diffs() {
1050        // Two actual elements share name "FOO". One has value "bar" (1 diff),
1051        // the other has value "baz" (1 diff). Best match picks the one with
1052        // the closest value to expected.
1053        let config =
1054            config_with_key_and_strategy("items", "name", AmbiguousMatchStrategy::BestMatch);
1055        let actual = json!({"items": [
1056            {"name": "FOO", "value": "wrong"},
1057            {"name": "FOO", "value": "almost"}
1058        ]});
1059        let expected = json!({"items": [{"name": "FOO", "value": "almost"}]});
1060        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
1061
1062        // The second candidate is an exact match, so no diff
1063        assert!(tree.is_empty());
1064    }
1065
1066    #[test]
1067    fn ambiguous_key_best_match_with_diffs() {
1068        let config =
1069            config_with_key_and_strategy("items", "name", AmbiguousMatchStrategy::BestMatch);
1070        // Two candidates, both differ but second has fewer diffs
1071        let actual = json!({"items": [
1072            {"name": "FOO", "a": 1, "b": 2},
1073            {"name": "FOO", "a": 99, "b": 99}
1074        ]});
1075        let expected = json!({"items": [{"name": "FOO", "a": 1, "b": 99}]});
1076        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
1077
1078        // First candidate: b differs (1 diff). Second: a differs (1 diff).
1079        // Both have 1 diff, so first one seen wins. First has b: Changed.
1080        assert!(!tree.is_empty());
1081        let DiffNode::Container { children, .. } = &tree.roots[0] else {
1082            panic!("expected Container for items");
1083        };
1084        assert_eq!(children.len(), 1);
1085    }
1086
1087    #[test]
1088    fn ambiguous_contains_best_match() {
1089        // Two actual elements are both supersets of expected.
1090        // BestMatch should accept without error.
1091        let config = config_with_contains_and_strategy("items", AmbiguousMatchStrategy::BestMatch);
1092        let actual = json!({"items": [
1093            {"a": 1, "b": 2},
1094            {"a": 1, "c": 3}
1095        ]});
1096        let expected = json!({"items": [{"a": 1}]});
1097        let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
1098        assert!(tree.is_empty());
1099    }
1100
1101    // Null vs empty collection: YAML `foo:` (null) vs `foo: []` or `foo: {}`
1102    // are different JSON types and should produce TypeMismatch.
1103
1104    #[test]
1105    fn null_vs_empty_array() {
1106        let actual = json!({"foo": null});
1107        let expected = json!({"foo": []});
1108        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
1109
1110        assert_eq!(tree.roots.len(), 1);
1111        let DiffNode::Leaf { kind, .. } = &tree.roots[0] else {
1112            panic!("expected Leaf");
1113        };
1114        assert!(matches!(
1115            kind,
1116            DiffKind::TypeMismatch {
1117                actual_type: "null",
1118                expected_type: "array",
1119                ..
1120            }
1121        ));
1122    }
1123
1124    #[test]
1125    fn null_vs_empty_object() {
1126        let actual = json!({"bar": null});
1127        let expected = json!({"bar": {}});
1128        let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
1129
1130        assert_eq!(tree.roots.len(), 1);
1131        let DiffNode::Leaf { kind, .. } = &tree.roots[0] else {
1132            panic!("expected Leaf");
1133        };
1134        assert!(matches!(
1135            kind,
1136            DiffKind::TypeMismatch {
1137                actual_type: "null",
1138                expected_type: "object",
1139                ..
1140            }
1141        ));
1142    }
1143
1144    // Error cases
1145
1146    #[test]
1147    fn missing_key_field_returns_error() {
1148        let config = config_with_key_at("items", "name");
1149        let actual = json!({"items": [{"name": "a"}]});
1150        // Expected element is missing the "name" key field
1151        let expected = json!({"items": [{"value": "foo"}]});
1152        let result = diff(&actual, &expected, &config);
1153
1154        let Err(err) = result else {
1155            panic!("expected an error");
1156        };
1157        assert!(matches!(err, Error::MissingKeyField { .. }));
1158        assert!(err.to_string().contains("missing the key field `name`"));
1159    }
1160
1161    #[test]
1162    fn strict_ambiguous_key_match_returns_error() {
1163        let config = config_with_key_and_strategy("items", "name", AmbiguousMatchStrategy::Strict);
1164        let actual = json!({"items": [
1165            {"name": "FOO", "value": "a"},
1166            {"name": "FOO", "value": "b"}
1167        ]});
1168        let expected = json!({"items": [{"name": "FOO", "value": "a"}]});
1169        let result = diff(&actual, &expected, &config);
1170
1171        let Err(err) = result else {
1172            panic!("expected an error");
1173        };
1174        assert!(matches!(err, Error::AmbiguousMatch { count: 2, .. }));
1175    }
1176
1177    #[test]
1178    fn strict_ambiguous_contains_match_returns_error() {
1179        let config = config_with_contains_and_strategy("items", AmbiguousMatchStrategy::Strict);
1180        let actual = json!({"items": [
1181            {"a": 1, "b": 2},
1182            {"a": 1, "c": 3}
1183        ]});
1184        let expected = json!({"items": [{"a": 1}]});
1185        let result = diff(&actual, &expected, &config);
1186
1187        let Err(err) = result else {
1188            panic!("expected an error");
1189        };
1190        assert!(matches!(err, Error::AmbiguousMatch { count: 2, .. }));
1191    }
1192}