1use serde::Serialize;
11use serde_json::Value;
12
13use crate::merge::normalization::normalize;
14use crate::merge::path::build_path_map;
15use crate::merge::sdl::SdlDocument;
16
17#[derive(Debug, Clone, Serialize)]
19pub struct DiffResult {
20 pub changes: Vec<Change>,
21}
22
23impl DiffResult {
24 pub fn is_empty(&self) -> bool {
26 self.changes.is_empty()
27 }
28
29 pub fn len(&self) -> usize {
31 self.changes.len()
32 }
33
34 pub fn by_operation(&self) -> (Vec<&Change>, Vec<&Change>, Vec<&Change>) {
36 let mut added = Vec::new();
37 let mut removed = Vec::new();
38 let mut modified = Vec::new();
39 for c in &self.changes {
40 match c.op {
41 ChangeOp::Added => added.push(c),
42 ChangeOp::Removed => removed.push(c),
43 ChangeOp::Modified => modified.push(c),
44 }
45 }
46 (added, removed, modified)
47 }
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub struct Change {
53 pub path: String,
56
57 pub op: ChangeOp,
59
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub old_value: Option<Value>,
63
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub new_value: Option<Value>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ChangeOp {
73 Added,
75 Removed,
77 Modified,
79}
80
81pub fn diff(base: &Value, candidate: &Value, schema: &SdlDocument) -> DiffResult {
102 let base_paths = build_path_map(base, schema);
104 let candidate_paths = build_path_map(candidate, schema);
105
106 let mut changes = Vec::new();
107
108 for (path, base_val) in &base_paths {
110 match candidate_paths.get(path) {
111 None | Some(Value::Null) => {
112 let sub_path = format!("root.{path}");
114 changes.push(Change {
115 path: sub_path,
116 op: ChangeOp::Removed,
117 old_value: Some(base_val.clone()),
118 new_value: None,
119 });
120 }
121 Some(candidate_val) if candidate_val != base_val => {
122 let sub_path = format!("root.{path}");
123 changes.push(Change {
124 path: sub_path,
125 op: ChangeOp::Modified,
126 old_value: Some(base_val.clone()),
127 new_value: Some(candidate_val.clone()),
128 });
129 }
130 _ => {
131 }
133 }
134 }
135
136 for (path, candidate_val) in &candidate_paths {
138 if !base_paths.contains_key(path) && !candidate_val.is_null() {
139 let sub_path = format!("root.{path}");
140 changes.push(Change {
141 path: sub_path,
142 op: ChangeOp::Added,
143 old_value: None,
144 new_value: Some(candidate_val.clone()),
145 });
146 }
147 }
148
149 changes.sort_by(|a, b| a.path.cmp(&b.path));
151
152 DiffResult { changes }
153}
154
155pub fn diff_normalized(base: &Value, candidate: &Value, schema: &SdlDocument) -> DiffResult {
163 let normalized = normalize(base, candidate);
164 diff(base, &normalized, schema)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use crate::merge::sdl::SdlDocument;
171 use serde_json::json;
172
173 fn test_sdl() -> SdlDocument {
174 SdlDocument::from_yaml(
175 r#"
176schema:
177 version: "1.0"
178 required: []
179 properties:
180 name:
181 type: string
182 merge:
183 type: replace
184 tags:
185 type: array
186 merge:
187 type: ordered_unique
188 identity:
189 mode: primitive_value
190 characters:
191 type: array
192 merge:
193 type: ordered_unique
194 identity:
195 mode: key
196 key: id
197"#,
198 )
199 .unwrap()
200 }
201
202 #[test]
203 fn test_diff_modified() {
204 let base = json!({"name": "Luke"});
205 let candidate = json!({"name": "Luke Skywalker"});
206
207 let result = diff(&base, &candidate, &test_sdl());
208 assert_eq!(result.len(), 1);
209 assert_eq!(result.changes[0].op, ChangeOp::Modified);
210 assert_eq!(result.changes[0].path, "root.name");
211 assert_eq!(result.changes[0].old_value, Some(json!("Luke")));
212 assert_eq!(result.changes[0].new_value, Some(json!("Luke Skywalker")));
213 }
214
215 #[test]
216 fn test_diff_added() {
217 let base = json!({"name": "Luke"});
218 let candidate = json!({"name": "Luke", "homeworld": "Tatooine"});
219
220 let result = diff(&base, &candidate, &test_sdl());
221 assert_eq!(result.len(), 1);
222 assert_eq!(result.changes[0].op, ChangeOp::Added);
223 assert_eq!(result.changes[0].path, "root.homeworld");
224 assert_eq!(result.changes[0].old_value, None);
225 assert_eq!(result.changes[0].new_value, Some(json!("Tatooine")));
226 }
227
228 #[test]
229 fn test_diff_removed() {
230 let base = json!({"name": "Luke", "homeworld": "Tatooine"});
231 let candidate = json!({"name": "Luke", "homeworld": null});
232
233 let result = diff(&base, &candidate, &test_sdl());
234 assert_eq!(result.len(), 1);
235 assert_eq!(result.changes[0].op, ChangeOp::Removed);
236 assert_eq!(result.changes[0].path, "root.homeworld");
237 }
238
239 #[test]
240 fn test_diff_no_changes() {
241 let base = json!({"name": "Luke"});
242 let candidate = json!({"name": "Luke"});
243
244 let result = diff(&base, &candidate, &test_sdl());
245 assert!(result.is_empty());
246 }
247
248 #[test]
249 fn test_diff_by_operation() {
250 let base = json!({"a": 1, "b": 2});
251 let candidate = json!({"a": 10, "c": 3});
252
253 let result = diff(&base, &candidate, &test_sdl());
254 let (added, removed, modified) = result.by_operation();
255 assert_eq!(added.len(), 1); assert_eq!(removed.len(), 1); assert_eq!(modified.len(), 1); }
259
260 #[test]
261 fn test_diff_deterministic() {
262 let base = json!({"b": 2, "a": 1});
263 let candidate = json!({"a": 10, "c": 3});
264
265 let result1 = diff(&base, &candidate, &test_sdl());
266 let result2 = diff(&base, &candidate, &test_sdl());
267 assert_eq!(result1.changes.len(), result2.changes.len());
268 for (c1, c2) in result1.changes.iter().zip(result2.changes.iter()) {
270 assert_eq!(c1.path, c2.path);
271 }
272 }
273}