1use crate::sexpr::SExpr;
29
30#[derive(Debug, Clone, PartialEq)]
32pub enum Edit {
33 Added { path: String, value: SExpr },
35 Removed { path: String, value: SExpr },
37 Changed {
39 path: String,
40 old: SExpr,
41 new: SExpr,
42 },
43}
44
45impl Edit {
46 #[must_use]
48 pub fn path(&self) -> &str {
49 match self {
50 Self::Added { path, .. } | Self::Removed { path, .. } | Self::Changed { path, .. } => {
51 path
52 }
53 }
54 }
55}
56
57#[must_use]
59pub fn diff(old: &SExpr, new: &SExpr) -> Vec<Edit> {
60 let mut edits = Vec::new();
61 diff_into("".to_string(), old, new, &mut edits);
62 edits
63}
64
65fn diff_into(path: String, old: &SExpr, new: &SExpr, out: &mut Vec<Edit>) {
66 if old == new {
68 return;
69 }
70
71 if let (Some((old_name, old_fields)), Some((new_name, new_fields))) =
75 (struct_form(old), struct_form(new))
76 {
77 if old_name == new_name {
78 diff_struct(&path, old_fields, new_fields, out);
79 return;
80 }
81 }
82
83 if let (Some(old_items), Some(new_items)) = (list_form(old), list_form(new)) {
85 diff_list(&path, old_items, new_items, out);
86 return;
87 }
88
89 if let (SExpr::List(a), SExpr::List(b)) = (old, new) {
92 if let (Some(head_a), Some(head_b)) = (a.first(), b.first()) {
93 if head_a == head_b && !is_keyword_pair(a) && !is_keyword_pair(b) {
94 let (a_rest, b_rest) = (&a[1..], &b[1..]);
96 if a_rest.len() == b_rest.len() {
97 for (i, (ai, bi)) in a_rest.iter().zip(b_rest).enumerate() {
98 let child_path = if path.is_empty() {
99 format!("[{i}]")
100 } else {
101 format!("{path}[{i}]")
102 };
103 diff_into(child_path, ai, bi, out);
104 }
105 return;
106 }
107 }
108 }
109 }
110
111 out.push(Edit::Changed {
113 path,
114 old: old.clone(),
115 new: new.clone(),
116 });
117}
118
119fn struct_form(s: &SExpr) -> Option<(&str, std::collections::BTreeMap<&str, &SExpr>)> {
122 let items = match s {
123 SExpr::List(items) => items,
124 _ => return None,
125 };
126 let (head, rest) = items.split_first()?;
127 let name = match head {
128 SExpr::Symbol(s) => s.as_str(),
129 _ => return None,
130 };
131 if rest.is_empty() {
134 return None;
135 }
136 let mut fields = std::collections::BTreeMap::new();
137 for item in rest {
138 let pair = match item {
139 SExpr::List(pair) => pair,
140 _ => return None,
141 };
142 if pair.len() != 2 {
143 return None;
144 }
145 let key = match &pair[0] {
146 SExpr::Symbol(k) if k.starts_with(':') => &k[1..],
147 _ => return None,
148 };
149 fields.insert(key, &pair[1]);
150 }
151 Some((name, fields))
152}
153
154fn list_form(s: &SExpr) -> Option<&[SExpr]> {
156 let items = match s {
157 SExpr::List(items) => items,
158 _ => return None,
159 };
160 let (head, rest) = items.split_first()?;
161 match head {
162 SExpr::Symbol(s) if s == "list" => Some(rest),
163 _ => None,
164 }
165}
166
167fn is_keyword_pair(items: &[SExpr]) -> bool {
168 items.len() == 2 && matches!(&items[0], SExpr::Symbol(k) if k.starts_with(':'))
170}
171
172fn diff_struct(
173 base: &str,
174 old: std::collections::BTreeMap<&str, &SExpr>,
175 new: std::collections::BTreeMap<&str, &SExpr>,
176 out: &mut Vec<Edit>,
177) {
178 let mut keys: Vec<&str> = old.keys().chain(new.keys()).copied().collect();
180 keys.sort_unstable();
181 keys.dedup();
182 for k in keys {
183 let child = if base.is_empty() {
184 k.to_string()
185 } else {
186 format!("{base}.{k}")
187 };
188 match (old.get(k), new.get(k)) {
189 (Some(a), Some(b)) => diff_into(child, a, b, out),
190 (None, Some(b)) => out.push(Edit::Added {
191 path: child,
192 value: (*b).clone(),
193 }),
194 (Some(a), None) => out.push(Edit::Removed {
195 path: child,
196 value: (*a).clone(),
197 }),
198 (None, None) => {}
199 }
200 }
201}
202
203fn diff_list(base: &str, old: &[SExpr], new: &[SExpr], out: &mut Vec<Edit>) {
204 let common = old.len().min(new.len());
205 for (i, (a, b)) in old.iter().zip(new).enumerate() {
206 let child = if base.is_empty() {
207 format!("[{i}]")
208 } else {
209 format!("{base}[{i}]")
210 };
211 diff_into(child, a, b, out);
212 }
213 for (i, b) in new.iter().enumerate().skip(common) {
215 let child = if base.is_empty() {
216 format!("[{i}]")
217 } else {
218 format!("{base}[{i}]")
219 };
220 out.push(Edit::Added {
221 path: child,
222 value: b.clone(),
223 });
224 }
225 for (i, a) in old.iter().enumerate().skip(common) {
227 let child = if base.is_empty() {
228 format!("[{i}]")
229 } else {
230 format!("{base}[{i}]")
231 };
232 out.push(Edit::Removed {
233 path: child,
234 value: a.clone(),
235 });
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::sexpr::ToSExpr;
243
244 fn sym(s: &str) -> SExpr {
245 SExpr::Symbol(s.into())
246 }
247
248 #[test]
249 fn equal_values_yield_no_edits() {
250 let a = SExpr::Integer(42);
251 let b = SExpr::Integer(42);
252 assert!(diff(&a, &b).is_empty());
253 }
254
255 #[test]
256 fn different_leaves_yield_single_changed() {
257 let a = SExpr::Integer(1);
258 let b = SExpr::Integer(2);
259 let edits = diff(&a, &b);
260 assert_eq!(edits.len(), 1);
261 assert!(matches!(edits[0], Edit::Changed { .. }));
262 assert_eq!(edits[0].path(), "");
263 }
264
265 #[test]
266 fn struct_form_reordering_is_invisible() {
267 let a = SExpr::parse("(x (:a 1) (:b 2))").unwrap();
270 let b = SExpr::parse("(x (:b 2) (:a 1))").unwrap();
271 assert!(diff(&a, &b).is_empty());
272 }
273
274 #[test]
275 fn struct_field_change_reports_field_path() {
276 let a = SExpr::parse("(x (:a 1) (:b 2))").unwrap();
277 let b = SExpr::parse("(x (:a 1) (:b 3))").unwrap();
278 let edits = diff(&a, &b);
279 assert_eq!(edits.len(), 1);
280 assert_eq!(edits[0].path(), "b");
281 assert!(matches!(edits[0], Edit::Changed { .. }));
282 }
283
284 #[test]
285 fn struct_field_added() {
286 let a = SExpr::parse("(x (:a 1))").unwrap();
287 let b = SExpr::parse("(x (:a 1) (:b 2))").unwrap();
288 let edits = diff(&a, &b);
289 assert_eq!(edits.len(), 1);
290 assert!(matches!(edits[0], Edit::Added { .. }));
291 assert_eq!(edits[0].path(), "b");
292 }
293
294 #[test]
295 fn struct_field_removed() {
296 let a = SExpr::parse("(x (:a 1) (:b 2))").unwrap();
297 let b = SExpr::parse("(x (:a 1))").unwrap();
298 let edits = diff(&a, &b);
299 assert_eq!(edits.len(), 1);
300 assert!(matches!(edits[0], Edit::Removed { .. }));
301 assert_eq!(edits[0].path(), "b");
302 }
303
304 #[test]
305 fn different_struct_names_full_replacement() {
306 let a = SExpr::parse("(x (:a 1))").unwrap();
308 let b = SExpr::parse("(y (:a 1))").unwrap();
309 let edits = diff(&a, &b);
310 assert_eq!(edits.len(), 1);
311 assert!(matches!(edits[0], Edit::Changed { .. }));
312 }
313
314 #[test]
315 fn list_form_positional_diff() {
316 let a = SExpr::parse("(list 1 2 3)").unwrap();
317 let b = SExpr::parse("(list 1 5 3)").unwrap();
318 let edits = diff(&a, &b);
319 assert_eq!(edits.len(), 1);
320 assert_eq!(edits[0].path(), "[1]");
321 }
322
323 #[test]
324 fn list_form_appended_element() {
325 let a = SExpr::parse("(list 1 2)").unwrap();
326 let b = SExpr::parse("(list 1 2 3)").unwrap();
327 let edits = diff(&a, &b);
328 assert_eq!(edits.len(), 1);
329 assert!(matches!(edits[0], Edit::Added { .. }));
330 assert_eq!(edits[0].path(), "[2]");
331 }
332
333 #[test]
334 fn list_form_removed_element() {
335 let a = SExpr::parse("(list 1 2 3)").unwrap();
336 let b = SExpr::parse("(list 1 2)").unwrap();
337 let edits = diff(&a, &b);
338 assert_eq!(edits.len(), 1);
339 assert!(matches!(edits[0], Edit::Removed { .. }));
340 assert_eq!(edits[0].path(), "[2]");
341 }
342
343 #[test]
344 fn tuple_tag_list_same_head_diffs_children() {
345 let a = SExpr::List(vec![sym("array"), SExpr::Integer(1)]);
350 let b = SExpr::List(vec![sym("array"), SExpr::Integer(2)]);
351 let edits = diff(&a, &b);
352 assert_eq!(edits.len(), 1);
353 assert_eq!(edits[0].path(), "[0]");
354 }
355
356 #[test]
357 fn nested_struct_dotted_paths() {
358 let a = SExpr::parse("(res (:name \"a\") (:meta (info (:v 1))))").unwrap();
359 let b = SExpr::parse("(res (:name \"a\") (:meta (info (:v 2))))").unwrap();
360 let edits = diff(&a, &b);
361 assert_eq!(edits.len(), 1);
362 assert_eq!(edits[0].path(), "meta.v");
363 }
364
365 #[test]
366 fn multiple_simultaneous_changes() {
367 let a = SExpr::parse("(res (:a 1) (:b 2) (:c 3))").unwrap();
368 let b = SExpr::parse("(res (:a 1) (:b 99) (:c 100))").unwrap();
369 let mut edits = diff(&a, &b);
370 edits.sort_by(|x, y| x.path().cmp(y.path()));
371 assert_eq!(edits.len(), 2);
372 assert_eq!(edits[0].path(), "b");
373 assert_eq!(edits[1].path(), "c");
374 }
375
376 #[test]
377 fn iac_type_diff_field_level() {
378 use crate::ir::IacType;
379 let a = IacType::List(Box::new(IacType::String)).to_sexpr();
380 let b = IacType::List(Box::new(IacType::Integer)).to_sexpr();
381 let edits = diff(&a, &b);
382 assert_eq!(edits.len(), 1);
383 assert_eq!(edits[0].path(), "[0]");
385 }
386
387 #[test]
388 fn iac_attribute_diff_single_field() {
389 use crate::ir::IacType;
390 use crate::testing::TestAttributeBuilder;
391 let a = TestAttributeBuilder::new("x", IacType::String)
392 .required()
393 .build();
394 let b = TestAttributeBuilder::new("x", IacType::String).build(); let edits = diff(&a.to_sexpr(), &b.to_sexpr());
396 let paths: Vec<&str> = edits.iter().map(|e| e.path()).collect();
400 assert!(
401 paths.contains(&"required"),
402 "expected 'required' in edits, got {paths:?}",
403 );
404 assert!(!paths.iter().any(|p| p.contains("name")));
405 assert!(!paths.iter().any(|p| p.contains("iac-type")));
406 }
407
408 #[test]
409 fn diff_is_deterministic() {
410 let a = SExpr::parse("(res (:a 1) (:b 2))").unwrap();
411 let b = SExpr::parse("(res (:a 10) (:b 20))").unwrap();
412 let x = diff(&a, &b);
413 let y = diff(&a, &b);
414 assert_eq!(x, y);
415 }
416
417 #[test]
418 fn empty_sexpr_comparison() {
419 let a = SExpr::List(vec![]);
420 let b = SExpr::List(vec![]);
421 assert!(diff(&a, &b).is_empty());
422 }
423}