use serde_json::Value;
use snafu::Snafu;
use crate::config::{AmbiguousMatchStrategy, ArrayMatchMode, DiffConfig};
use crate::model::{ChildKind, DiffKind, DiffNode, DiffTree, PathSegment};
type Result<T, E = Error> = std::result::Result<T, E>;
const NO_DIFFERENCES: Vec<DiffNode> = vec![];
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display(
"expected array element at path `{path}` is missing the key field `{key_field}` required for matching"
))]
MissingKeyField {
path: String,
key_field: String,
},
#[snafu(display("ambiguous match at path `{path}`: {count} candidates matched"))]
AmbiguousMatch {
path: String,
count: u16,
},
}
pub fn diff(actual: &Value, expected: &Value, config: &DiffConfig) -> Result<DiffTree> {
let path = "";
let roots = match diff_values(actual, expected, config, path)? {
DiffResult::Equal => NO_DIFFERENCES,
DiffResult::Leaf(_kind) => NO_DIFFERENCES,
DiffResult::Children { nodes, .. } => nodes,
};
Ok(DiffTree { roots })
}
enum DiffResult {
Equal,
Leaf(DiffKind),
Children {
child_kind: ChildKind,
nodes: Vec<DiffNode>,
omitted_count: u16,
},
}
fn diff_values(
actual: &Value,
expected: &Value,
config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
if std::mem::discriminant(actual) != std::mem::discriminant(expected) {
return Ok(DiffResult::Leaf(DiffKind::type_mismatch(
actual.clone(),
value_type_name(actual),
expected.clone(),
value_type_name(expected),
)));
}
match (actual, expected) {
(Value::Null, Value::Null) => Ok(DiffResult::Equal),
(Value::Bool(a), Value::Bool(e)) if a == e => Ok(DiffResult::Equal),
(Value::Number(a), Value::Number(e)) if a == e => Ok(DiffResult::Equal),
(Value::String(a), Value::String(e)) if a == e => Ok(DiffResult::Equal),
(Value::Bool(_), Value::Bool(_))
| (Value::Number(_), Value::Number(_))
| (Value::String(_), Value::String(_)) => Ok(DiffResult::Leaf(DiffKind::changed(
actual.clone(),
expected.clone(),
))),
(Value::Object(actual_map), Value::Object(expected_map)) => {
diff_objects(actual_map, expected_map, config, path)
}
(Value::Array(actual_arr), Value::Array(expected_arr)) => {
diff_arrays(actual_arr, expected_arr, config, path)
}
_ => unreachable!("discriminant check above ensures matching types"),
}
}
fn diff_objects(
actual_map: &serde_json::Map<String, Value>,
expected_map: &serde_json::Map<String, Value>,
config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
let mut children = Vec::new();
for (key, expected_val) in expected_map {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
let segment = PathSegment::Key(key.clone());
match actual_map.get(key) {
None => {
let kind = DiffKind::missing(expected_val.clone());
children.push(DiffNode::leaf(segment, kind));
}
Some(actual_val) => {
match diff_values(actual_val, expected_val, config, &child_path)? {
DiffResult::Equal => {}
DiffResult::Leaf(kind) => {
children.push(DiffNode::leaf(segment, kind));
}
DiffResult::Children {
child_kind,
nodes,
omitted_count,
} => {
children.push(DiffNode::container(
segment,
child_kind,
omitted_count,
nodes,
));
}
}
}
}
}
if children.is_empty() {
return Ok(DiffResult::Equal);
}
let omitted_count = actual_map.len().saturating_sub(expected_map.len()) as u16;
Ok(DiffResult::Children {
child_kind: ChildKind::Fields,
nodes: children,
omitted_count,
})
}
fn diff_arrays(
actual_arr: &[Value],
expected_arr: &[Value],
config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
let path_config = config.match_config().config_at(path);
let mode = path_config
.map(|c| c.mode())
.unwrap_or(config.default_array_mode());
let ambiguous_strategy = path_config
.and_then(|c| c.ambiguous_strategy())
.unwrap_or(config.default_ambiguous_strategy());
match mode {
ArrayMatchMode::Index => diff_arrays_by_index(actual_arr, expected_arr, config, path),
ArrayMatchMode::Key(key_field) => diff_arrays_by_key(
actual_arr,
expected_arr,
key_field,
ambiguous_strategy,
config,
path,
),
ArrayMatchMode::Contains => {
diff_arrays_by_contains(actual_arr, expected_arr, ambiguous_strategy, config, path)
}
}
}
fn diff_arrays_by_index(
actual_arr: &[Value],
expected_arr: &[Value],
config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
let mut children = Vec::new();
for (i, expected_elem) in expected_arr.iter().enumerate() {
let segment = PathSegment::Index(i as u16);
match actual_arr.get(i) {
None => {
let kind = DiffKind::missing(expected_elem.clone());
children.push(DiffNode::leaf(segment, kind));
}
Some(actual_elem) => {
match diff_values(actual_elem, expected_elem, config, path)? {
DiffResult::Equal => {}
DiffResult::Leaf(kind) => {
children.push(DiffNode::leaf(segment, kind));
}
DiffResult::Children {
child_kind,
nodes,
omitted_count,
} => {
children.push(DiffNode::container(
segment,
child_kind,
omitted_count,
nodes,
));
}
}
}
}
}
if children.is_empty() {
return Ok(DiffResult::Equal);
}
let omitted_count = actual_arr.len().saturating_sub(expected_arr.len()) as u16;
Ok(DiffResult::Children {
child_kind: ChildKind::Items,
nodes: children,
omitted_count,
})
}
fn diff_arrays_by_key(
actual_arr: &[Value],
expected_arr: &[Value],
key_field: &str,
ambiguous_strategy: &AmbiguousMatchStrategy,
config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
let mut children = Vec::new();
let mut matched_count: u16 = 0;
for expected_elem in expected_arr {
let expected_key_val = expected_elem
.get(key_field)
.and_then(|v| v.as_str())
.ok_or_else(|| Error::MissingKeyField {
path: path.to_owned(),
key_field: key_field.to_owned(),
})?;
let candidates: Vec<&Value> = actual_arr
.iter()
.filter(|elem| elem.get(key_field).and_then(|v| v.as_str()) == Some(expected_key_val))
.collect();
match candidates.len() {
0 => {
let kind = DiffKind::missing(expected_elem.clone());
children.push(DiffNode::leaf(PathSegment::Unmatched, kind));
}
1 => {
matched_count += 1;
let segment = PathSegment::NamedElement {
match_key: key_field.to_owned(),
match_value: expected_key_val.to_owned(),
};
match diff_values(candidates[0], expected_elem, config, path)? {
DiffResult::Equal => {}
DiffResult::Leaf(kind) => {
children.push(DiffNode::leaf(segment, kind));
}
DiffResult::Children {
child_kind,
nodes,
omitted_count,
} => {
children.push(DiffNode::container(
segment,
child_kind,
omitted_count,
nodes,
));
}
}
}
_ => match ambiguous_strategy {
AmbiguousMatchStrategy::Strict => {
return Err(Error::AmbiguousMatch {
path: path.to_owned(),
count: candidates.len() as u16,
});
}
AmbiguousMatchStrategy::BestMatch | AmbiguousMatchStrategy::Silent => {
matched_count += 1;
let segment = PathSegment::NamedElement {
match_key: key_field.to_owned(),
match_value: expected_key_val.to_owned(),
};
let best =
pick_best_match(candidates.iter().copied(), expected_elem, config, path)?;
push_diff_result(&mut children, segment, best);
}
},
}
}
if children.is_empty() {
return Ok(DiffResult::Equal);
}
let omitted_count = (actual_arr.len() as u16).saturating_sub(matched_count);
Ok(DiffResult::Children {
child_kind: ChildKind::Items,
nodes: children,
omitted_count,
})
}
fn diff_arrays_by_contains(
actual_arr: &[Value],
expected_arr: &[Value],
ambiguous_strategy: &AmbiguousMatchStrategy,
_config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
let mut children = Vec::new();
let mut matched_count: u16 = 0;
for expected_elem in expected_arr {
let candidates: Vec<(usize, &Value)> = actual_arr
.iter()
.enumerate()
.filter(|(_, actual_elem)| value_contains(actual_elem, expected_elem))
.collect();
match candidates.len() {
0 => {
let kind = DiffKind::missing(expected_elem.clone());
children.push(DiffNode::leaf(PathSegment::Unmatched, kind));
}
1 => {
matched_count += 1;
}
_ => match ambiguous_strategy {
AmbiguousMatchStrategy::Strict => {
return Err(Error::AmbiguousMatch {
path: path.to_owned(),
count: candidates.len() as u16,
});
}
AmbiguousMatchStrategy::BestMatch | AmbiguousMatchStrategy::Silent => {
matched_count += 1;
}
},
}
}
if children.is_empty() {
return Ok(DiffResult::Equal);
}
let omitted_count = (actual_arr.len() as u16).saturating_sub(matched_count);
Ok(DiffResult::Children {
child_kind: ChildKind::Items,
nodes: children,
omitted_count,
})
}
fn value_contains(actual: &Value, expected: &Value) -> bool {
match (actual, expected) {
_ if std::mem::discriminant(actual) != std::mem::discriminant(expected) => false,
(Value::Null, Value::Null) => true,
(Value::Bool(a), Value::Bool(e)) => a == e,
(Value::Number(a), Value::Number(e)) => a == e,
(Value::String(a), Value::String(e)) => a == e,
(Value::Object(actual_map), Value::Object(expected_map)) => {
expected_map.iter().all(|(key, expected_val)| {
actual_map
.get(key)
.is_some_and(|actual_val| value_contains(actual_val, expected_val))
})
}
(Value::Array(a), Value::Array(e)) => a == e,
_ => unreachable!("discriminant check above ensures matching types"),
}
}
fn pick_best_match<'a>(
candidates: impl Iterator<Item = &'a Value>,
expected: &Value,
config: &DiffConfig,
path: &str,
) -> Result<DiffResult> {
let mut best: Option<DiffResult> = None;
let mut best_count: Option<usize> = None;
for candidate in candidates {
let result = diff_values(candidate, expected, config, path)?;
if matches!(result, DiffResult::Equal) {
return Ok(result);
}
let count = match &result {
DiffResult::Children { nodes, .. } => nodes.len(),
DiffResult::Leaf(_) => 1,
DiffResult::Equal => unreachable!("handled above"),
};
if best_count.is_none() || count < best_count.expect("guarded by is_none check") {
best = Some(result);
best_count = Some(count);
}
}
Ok(best.unwrap_or(DiffResult::Equal))
}
fn push_diff_result(children: &mut Vec<DiffNode>, segment: PathSegment, result: DiffResult) {
match result {
DiffResult::Equal => {}
DiffResult::Leaf(kind) => {
children.push(DiffNode::leaf(segment, kind));
}
DiffResult::Children {
child_kind,
nodes,
omitted_count,
} => {
children.push(DiffNode::container(segment, child_kind, omitted_count, nodes));
}
}
}
fn value_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn default_config() -> DiffConfig {
DiffConfig::default()
}
#[test]
fn object_key_order_does_not_affect_equality() {
let actual = json!({"z": 1, "a": 2, "m": 3});
let expected = json!({"m": 3, "z": 1, "a": 2});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn object_key_order_does_not_affect_diffs() {
let actual = json!({"z": 1, "a": 2});
let expected = json!({"a": 99, "z": 1});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Leaf { segment, kind } = &tree.roots[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
assert!(matches!(kind, DiffKind::Changed { .. }));
}
#[test]
fn equal_objects_produce_empty_diff() {
let actual = json!({"a": 1, "b": "hello"});
let expected = json!({"a": 1, "b": "hello"});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn scalar_changed() {
let actual = json!({"a": {"b": {"c": "foo"}}});
let expected = json!({"a": {"b": {"c": "bar"}}});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Container {
segment, children, ..
} = &tree.roots[0]
else {
panic!("expected Container");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
let DiffNode::Container {
segment, children, ..
} = &children[0]
else {
panic!("expected Container");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "b"));
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "c"));
assert!(matches!(kind, DiffKind::Changed { actual, expected }
if actual == &json!("foo") && expected == &json!("bar")
));
}
#[test]
fn missing_key() {
let actual = json!({"a": 1});
let expected = json!({"a": 1, "b": 2});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Leaf { segment, kind } = &tree.roots[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "b"));
assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(2)));
}
#[test]
fn type_mismatch() {
let actual = json!({"a": 42});
let expected = json!({"a": "42"});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Leaf { segment, kind } = &tree.roots[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
assert!(matches!(
kind,
DiffKind::TypeMismatch {
actual_type: "number",
expected_type: "string",
..
}
));
}
#[test]
fn omitted_count_reflects_extra_actual_keys() {
let actual = json!({"a": 1, "b": 2, "c": 3});
let expected = json!({"a": 99});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Leaf { kind, .. } = &tree.roots[0] else {
panic!("expected Leaf for Changed");
};
assert!(matches!(kind, DiffKind::Changed { .. }));
let result = diff_values(&actual, &expected, &default_config(), "")
.expect("diff_values with valid inputs");
assert!(matches!(
result,
DiffResult::Children {
omitted_count: 2,
..
}
));
}
#[test]
fn nested_missing_key() {
let actual = json!({"a": {"x": 1}});
let expected = json!({"a": {"x": 1, "y": 2}});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Container {
segment,
children,
omitted_count,
..
} = &tree.roots[0]
else {
panic!("expected Container");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "a"));
assert_eq!(*omitted_count, 0);
assert_eq!(children.len(), 1);
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "y"));
assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(2)));
}
#[test]
fn index_based_array_equal() {
let actual = json!({"items": [1, 2, 3]});
let expected = json!({"items": [1, 2, 3]});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn index_based_array_changed() {
let actual = json!({"items": [1, 2, 3]});
let expected = json!({"items": [1, 99, 3]});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Container { children, .. } = &tree.roots[0] else {
panic!("expected Container");
};
assert_eq!(children.len(), 1);
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Index(1)));
assert!(matches!(kind, DiffKind::Changed { actual, expected }
if actual == &json!(2) && expected == &json!(99)
));
}
#[test]
fn index_based_array_missing_element() {
let actual = json!({"items": [1]});
let expected = json!({"items": [1, 2, 3]});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
let DiffNode::Container { children, .. } = &tree.roots[0] else {
panic!("expected Container");
};
assert_eq!(children.len(), 2);
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Index(1)));
assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(2)));
let DiffNode::Leaf { segment, kind } = &children[1] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Index(2)));
assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!(3)));
}
#[test]
fn index_based_array_omitted_count() {
let actual = json!({"items": [1, 2, 3, 4, 5]});
let expected = json!({"items": [1, 99]});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
let DiffNode::Container {
segment,
children,
omitted_count,
..
} = &tree.roots[0]
else {
panic!("expected Container for items key");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "items"));
assert_eq!(*omitted_count, 3);
assert_eq!(children.len(), 1);
assert!(matches!(
&children[0],
DiffNode::Leaf {
segment: PathSegment::Index(1),
kind: DiffKind::Changed { .. },
}
));
}
fn config_with_key_at(path: &str, key: &str) -> DiffConfig {
use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
DiffConfig::new().with_match_config(MatchConfig::new().with_config_at(
path,
ArrayMatchConfig::new(ArrayMatchMode::Key(key.to_owned())),
))
}
#[test]
fn key_based_array_equal() {
let config = config_with_key_at("items", "name");
let actual = json!({"items": [{"name": "a", "val": 1}, {"name": "b", "val": 2}]});
let expected = json!({"items": [{"name": "a", "val": 1}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn key_based_array_changed() {
let config = config_with_key_at("items", "name");
let actual = json!({"items": [{"name": "FOO", "value": "bar"}]});
let expected = json!({"items": [{"name": "FOO", "value": "baz"}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Container { children, .. } = &tree.roots[0] else {
panic!("expected Container for items");
};
assert_eq!(children.len(), 1);
let DiffNode::Container {
segment, children, ..
} = &children[0]
else {
panic!("expected Container for named element");
};
assert!(
matches!(segment, PathSegment::NamedElement { match_key, match_value }
if match_key == "name" && match_value == "FOO"
)
);
assert_eq!(children.len(), 1);
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Key(k) if k == "value"));
assert!(matches!(kind, DiffKind::Changed { actual, expected }
if actual == &json!("bar") && expected == &json!("baz")
));
}
#[test]
fn key_based_array_missing_element() {
let config = config_with_key_at("items", "name");
let actual = json!({"items": [{"name": "a"}]});
let expected = json!({"items": [{"name": "missing"}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
let DiffNode::Container { children, .. } = &tree.roots[0] else {
panic!("expected Container for items");
};
assert_eq!(children.len(), 1);
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Unmatched));
assert!(matches!(kind, DiffKind::Missing { .. }));
}
#[test]
fn key_based_array_omitted_count() {
let config = config_with_key_at("items", "name");
let actual = json!({"items": [{"name": "a"}, {"name": "b"}, {"name": "c"}]});
let expected = json!({"items": [{"name": "b"}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
let actual = json!({"items": [{"name": "a"}, {"name": "b", "x": 1}, {"name": "c"}]});
let expected = json!({"items": [{"name": "b", "x": 99}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
let DiffNode::Container {
children: _children,
omitted_count,
..
} = &tree.roots[0]
else {
panic!("expected Container for items");
};
assert_eq!(*omitted_count, 2);
}
fn config_with_contains_at(path: &str) -> DiffConfig {
use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
DiffConfig::new().with_match_config(
MatchConfig::new()
.with_config_at(path, ArrayMatchConfig::new(ArrayMatchMode::Contains)),
)
}
#[test]
fn contains_array_scalar_equal() {
let config = config_with_contains_at("items");
let actual = json!({"items": ["a", "b", "c"]});
let expected = json!({"items": ["b"]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn contains_array_object_subset_equal() {
let config = config_with_contains_at("items");
let actual = json!({"items": [{"a": 1, "b": 2}, {"c": 3}]});
let expected = json!({"items": [{"a": 1}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn contains_array_missing_element() {
let config = config_with_contains_at("items");
let actual = json!({"items": ["a", "b"]});
let expected = json!({"items": ["x"]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
let DiffNode::Container { children, .. } = &tree.roots[0] else {
panic!("expected Container for items");
};
assert_eq!(children.len(), 1);
let DiffNode::Leaf { segment, kind } = &children[0] else {
panic!("expected Leaf");
};
assert!(matches!(segment, PathSegment::Unmatched));
assert!(matches!(kind, DiffKind::Missing { expected } if expected == &json!("x")));
}
#[test]
fn contains_array_match_not_at_first_position() {
let config = config_with_contains_at("items");
let actual = json!({"items": [{"a": 1}, {"b": 1}]});
let expected = json!({"items": [{"b": 1}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn contains_array_omitted_count() {
let config = config_with_contains_at("items");
let actual = json!({"items": ["a", "b", "c"]});
let expected = json!({"items": ["x"]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
let DiffNode::Container { omitted_count, .. } = &tree.roots[0] else {
panic!("expected Container for items");
};
assert_eq!(*omitted_count, 3);
}
fn config_with_key_and_strategy(
path: &str,
key: &str,
strategy: AmbiguousMatchStrategy,
) -> DiffConfig {
use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
DiffConfig::new().with_match_config(
MatchConfig::new().with_config_at(
path,
ArrayMatchConfig::new(ArrayMatchMode::Key(key.to_owned()))
.with_ambiguous_strategy(strategy),
),
)
}
fn config_with_contains_and_strategy(
path: &str,
strategy: AmbiguousMatchStrategy,
) -> DiffConfig {
use crate::config::{ArrayMatchConfig, ArrayMatchMode, MatchConfig};
DiffConfig::new().with_match_config(MatchConfig::new().with_config_at(
path,
ArrayMatchConfig::new(ArrayMatchMode::Contains).with_ambiguous_strategy(strategy),
))
}
#[test]
fn ambiguous_key_best_match_picks_fewest_diffs() {
let config =
config_with_key_and_strategy("items", "name", AmbiguousMatchStrategy::BestMatch);
let actual = json!({"items": [
{"name": "FOO", "value": "wrong"},
{"name": "FOO", "value": "almost"}
]});
let expected = json!({"items": [{"name": "FOO", "value": "almost"}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn ambiguous_key_best_match_with_diffs() {
let config =
config_with_key_and_strategy("items", "name", AmbiguousMatchStrategy::BestMatch);
let actual = json!({"items": [
{"name": "FOO", "a": 1, "b": 2},
{"name": "FOO", "a": 99, "b": 99}
]});
let expected = json!({"items": [{"name": "FOO", "a": 1, "b": 99}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(!tree.is_empty());
let DiffNode::Container { children, .. } = &tree.roots[0] else {
panic!("expected Container for items");
};
assert_eq!(children.len(), 1);
}
#[test]
fn ambiguous_contains_best_match() {
let config = config_with_contains_and_strategy("items", AmbiguousMatchStrategy::BestMatch);
let actual = json!({"items": [
{"a": 1, "b": 2},
{"a": 1, "c": 3}
]});
let expected = json!({"items": [{"a": 1}]});
let tree = diff(&actual, &expected, &config).expect("diff with valid inputs");
assert!(tree.is_empty());
}
#[test]
fn null_vs_empty_array() {
let actual = json!({"foo": null});
let expected = json!({"foo": []});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Leaf { kind, .. } = &tree.roots[0] else {
panic!("expected Leaf");
};
assert!(matches!(
kind,
DiffKind::TypeMismatch {
actual_type: "null",
expected_type: "array",
..
}
));
}
#[test]
fn null_vs_empty_object() {
let actual = json!({"bar": null});
let expected = json!({"bar": {}});
let tree = diff(&actual, &expected, &default_config()).expect("diff with valid inputs");
assert_eq!(tree.roots.len(), 1);
let DiffNode::Leaf { kind, .. } = &tree.roots[0] else {
panic!("expected Leaf");
};
assert!(matches!(
kind,
DiffKind::TypeMismatch {
actual_type: "null",
expected_type: "object",
..
}
));
}
#[test]
fn missing_key_field_returns_error() {
let config = config_with_key_at("items", "name");
let actual = json!({"items": [{"name": "a"}]});
let expected = json!({"items": [{"value": "foo"}]});
let result = diff(&actual, &expected, &config);
let Err(err) = result else {
panic!("expected an error");
};
assert!(matches!(err, Error::MissingKeyField { .. }));
assert!(err.to_string().contains("missing the key field `name`"));
}
#[test]
fn strict_ambiguous_key_match_returns_error() {
let config = config_with_key_and_strategy("items", "name", AmbiguousMatchStrategy::Strict);
let actual = json!({"items": [
{"name": "FOO", "value": "a"},
{"name": "FOO", "value": "b"}
]});
let expected = json!({"items": [{"name": "FOO", "value": "a"}]});
let result = diff(&actual, &expected, &config);
let Err(err) = result else {
panic!("expected an error");
};
assert!(matches!(err, Error::AmbiguousMatch { count: 2, .. }));
}
#[test]
fn strict_ambiguous_contains_match_returns_error() {
let config = config_with_contains_and_strategy("items", AmbiguousMatchStrategy::Strict);
let actual = json!({"items": [
{"a": 1, "b": 2},
{"a": 1, "c": 3}
]});
let expected = json!({"items": [{"a": 1}]});
let result = diff(&actual, &expected, &config);
let Err(err) = result else {
panic!("expected an error");
};
assert!(matches!(err, Error::AmbiguousMatch { count: 2, .. }));
}
}