use serde_json::Value;
use crate::utils::decode_json_pointer_token;
#[derive(Debug, Clone, PartialEq)]
pub struct ValueChange {
pub pointer: String,
pub kind: ValueChangeKind,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ValueChangeKind {
Added(Value),
Removed(Value),
Changed {
old: Value,
new: Value,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Location {
Type,
RequiredElement,
EnumElement,
Property,
AdditionalProperties,
Nullable,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Node {
Schema,
PropertyMap,
SchemaList,
Set(Location),
Opaque,
}
impl Node {
fn child(self, segment: &str) -> Node {
match self {
Node::Schema => match segment {
"properties" | "patternProperties" => Node::PropertyMap,
"allOf" | "oneOf" | "anyOf" => Node::SchemaList,
"items" | "additionalProperties" | "not" => Node::Schema,
"required" => Node::Set(Location::RequiredElement),
"enum" => Node::Set(Location::EnumElement),
_ => Node::Opaque,
},
Node::PropertyMap | Node::SchemaList => Node::Schema,
Node::Set(_) | Node::Opaque => Node::Opaque,
}
}
fn locate(self, segment: &str) -> Location {
match self {
Node::Schema => match segment {
"type" => Location::Type,
"nullable" => Location::Nullable,
"additionalProperties" => Location::AdditionalProperties,
_ => Location::Other,
},
Node::PropertyMap => Location::Property,
Node::Set(element) => element,
Node::SchemaList | Node::Opaque => Location::Other,
}
}
}
pub(crate) fn locate(pointer: &str) -> Location {
let mut node = Node::Schema;
let mut location = Location::Other;
for segment in pointer.split('/').skip(1) {
let segment = decode_json_pointer_token(segment);
location = node.locate(&segment);
node = node.child(&segment);
}
location
}
fn empty_counterpart(child: Node, present: &Value) -> Option<Value> {
match child {
Node::Set(_) if present.is_array() => Some(Value::Array(Vec::new())),
Node::PropertyMap if present.is_object() => Some(Value::Object(serde_json::Map::new())),
_ => None,
}
}
fn encode_json_pointer_token(token: &str) -> String {
token.replace('~', "~0").replace('/', "~1")
}
fn set_element_segment(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
other => other.to_string(),
}
}
fn push_segment(path: &mut String, segment: &str) -> usize {
let mark = path.len();
path.push('/');
path.push_str(&encode_json_pointer_token(segment));
mark
}
pub(crate) fn diff_values(old: &Value, new: &Value, path: &mut String, out: &mut Vec<ValueChange>) {
diff_nodes(old, new, Node::Schema, path, out);
}
fn diff_nodes(old: &Value, new: &Value, node: Node, path: &mut String, out: &mut Vec<ValueChange>) {
match (old, new) {
(Value::Object(old_map), Value::Object(new_map)) => {
for (key, old_child) in old_map {
let child = node.child(key);
let mark = push_segment(path, key);
match (new_map.get(key), empty_counterpart(child, old_child)) {
(Some(new_child), _) => diff_nodes(old_child, new_child, child, path, out),
(None, Some(empty)) => diff_nodes(old_child, &empty, child, path, out),
(None, None) => out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Removed(old_child.clone()),
}),
}
path.truncate(mark);
}
for (key, new_child) in new_map {
if old_map.contains_key(key) {
continue;
}
let child = node.child(key);
let mark = push_segment(path, key);
match empty_counterpart(child, new_child) {
Some(empty) => diff_nodes(&empty, new_child, child, path, out),
None => out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Added(new_child.clone()),
}),
}
path.truncate(mark);
}
}
(Value::Array(_), Value::Array(_)) if matches!(node, Node::Set(_)) => {
diff_sets(old, new, path, out)
}
(Value::Array(old_items), Value::Array(new_items)) => {
let common = old_items.len().min(new_items.len());
for index in 0..common {
let segment = index.to_string();
let child = node.child(&segment);
let mark = push_segment(path, &segment);
diff_nodes(&old_items[index], &new_items[index], child, path, out);
path.truncate(mark);
}
for (index, item) in old_items.iter().enumerate().skip(common) {
let mark = push_segment(path, &index.to_string());
out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Removed(item.clone()),
});
path.truncate(mark);
}
for (index, item) in new_items.iter().enumerate().skip(common) {
let mark = push_segment(path, &index.to_string());
out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Added(item.clone()),
});
path.truncate(mark);
}
}
_ if old == new => {}
_ => out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Changed {
old: old.clone(),
new: new.clone(),
},
}),
}
}
fn diff_sets(old: &Value, new: &Value, path: &mut String, out: &mut Vec<ValueChange>) {
let old_items = old.as_array().map(Vec::as_slice).unwrap_or_default();
let new_items = new.as_array().map(Vec::as_slice).unwrap_or_default();
for item in old_items.iter().filter(|item| !new_items.contains(item)) {
let mark = push_segment(path, &set_element_segment(item));
out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Removed(item.clone()),
});
path.truncate(mark);
}
for item in new_items.iter().filter(|item| !old_items.contains(item)) {
let mark = push_segment(path, &set_element_segment(item));
out.push(ValueChange {
pointer: path.clone(),
kind: ValueChangeKind::Added(item.clone()),
});
path.truncate(mark);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::test_support::{added, changed, removed};
use crate::diff::value_changes;
use serde_json::json;
#[test]
fn diff_values_reports_object_keys_added_removed_changed() {
let changes = value_changes(
&json!({"a": 1, "b": {"c": "x"}, "d": true}),
&json!({"a": 1, "b": {"c": "y"}, "e": null}),
);
assert_eq!(
changes,
vec![
changed("/b/c", json!("x"), json!("y")),
removed("/d", json!(true)),
added("/e", Value::Null),
]
);
}
#[test]
fn diff_values_compares_generic_arrays_by_index() {
let changes = value_changes(
&json!({"allOf": [{"type": "object"}, {"type": "string"}]}),
&json!({"allOf": [{"type": "object"}, {"type": "integer"}, {"format": "x"}]}),
);
assert_eq!(
changes,
vec![
changed("/allOf/1/type", json!("string"), json!("integer")),
added("/allOf/2", json!({"format": "x"})),
]
);
let shorter = value_changes(&json!({"oneOf": [1, 2]}), &json!({"oneOf": [1]}));
assert_eq!(shorter, vec![removed("/oneOf/1", json!(2))]);
}
#[test]
fn diff_values_compares_required_as_a_set() {
let changes = value_changes(
&json!({"required": ["a", "b"]}),
&json!({"required": ["b", "c"]}),
);
assert_eq!(
changes,
vec![
removed("/required/a", json!("a")),
added("/required/c", json!("c")),
]
);
}
#[test]
fn diff_values_treats_missing_required_as_empty_set() {
assert_eq!(
value_changes(
&json!({"type": "object"}),
&json!({"type": "object", "required": ["a"]})
),
vec![added("/required/a", json!("a"))]
);
assert_eq!(
value_changes(&json!({"required": ["a"]}), &json!({})),
vec![removed("/required/a", json!("a"))]
);
}
#[test]
fn diff_values_treats_missing_properties_as_empty_map() {
assert_eq!(
value_changes(
&json!({"type": "object"}),
&json!({"type": "object", "properties": {"name": {"type": "string"}}})
),
vec![added("/properties/name", json!({"type": "string"}))]
);
assert_eq!(
value_changes(
&json!({"properties": {"a": {}, "b": {}}}),
&json!({"type": "object"})
),
vec![
removed("/properties/a", json!({})),
removed("/properties/b", json!({})),
added("/type", json!("object")),
]
);
}
#[test]
fn diff_values_compares_enum_as_a_set_with_non_string_members() {
let changes = value_changes(
&json!({"properties": {"n": {"enum": [1, 2]}}}),
&json!({"properties": {"n": {"enum": [2, 3]}}}),
);
assert_eq!(
changes,
vec![
removed("/properties/n/enum/1", json!(1)),
added("/properties/n/enum/3", json!(3)),
]
);
}
#[test]
fn diff_values_escapes_pointer_tokens() {
let changes = value_changes(
&json!({"properties": {"a/b": {"type": "string"}}}),
&json!({"properties": {}}),
);
assert_eq!(
changes,
vec![removed("/properties/a~1b", json!({"type": "string"}))]
);
}
#[test]
fn diff_values_reports_nothing_for_equal_values() {
let value =
json!({"type": "object", "required": ["a"], "properties": {"a": {"type": "x"}}});
assert!(value_changes(&value, &value).is_empty());
}
#[test]
fn diff_values_keyword_semantics_resume_inside_a_field_named_like_a_keyword() {
let changes = value_changes(
&json!({"properties": {"required": {"type": "object", "required": ["a"]}}}),
&json!({"properties": {"required": {"type": "object", "required": ["a", "b"]}}}),
);
assert_eq!(
changes,
vec![added("/properties/required/required/b", json!("b"))]
);
assert_eq!(locate(&changes[0].pointer), Location::RequiredElement);
}
#[test]
fn diff_values_does_not_apply_set_semantics_outside_schema_positions() {
let changes = value_changes(
&json!({"default": {"required": ["a", "b"]}}),
&json!({"default": {"required": ["b", "a"]}}),
);
assert_eq!(
changes,
vec![
changed("/default/required/0", json!("a"), json!("b")),
changed("/default/required/1", json!("b"), json!("a")),
]
);
}
#[test]
fn locate_distinguishes_keywords_from_property_names() {
assert_eq!(locate("/type"), Location::Type);
assert_eq!(locate("/properties/type"), Location::Property);
assert_eq!(locate("/properties/type/type"), Location::Type);
assert_eq!(locate("/items/type"), Location::Type);
assert_eq!(locate("/allOf/0/type"), Location::Type);
assert_eq!(locate("/allOf/0"), Location::Other);
assert_eq!(locate("/required/x"), Location::RequiredElement);
assert_eq!(locate("/properties/required"), Location::Property);
assert_eq!(locate("/properties/s/enum/a"), Location::EnumElement);
assert_eq!(
locate("/additionalProperties"),
Location::AdditionalProperties
);
assert_eq!(locate("/additionalProperties/type"), Location::Type);
assert_eq!(locate("/nullable"), Location::Nullable);
assert_eq!(locate("/format"), Location::Other);
assert_eq!(locate("/default/type"), Location::Other);
assert_eq!(locate(""), Location::Other);
}
}