Skip to main content

iac_forge/
sexpr_diff.rs

1//! Semantic diff over sexpr trees.
2//!
3//! Given two [`SExpr`] values, produce a list of [`Edit`]s describing
4//! how to transform the first into the second. The diff is structural:
5//! it walks the tree, compares by position and by struct-form keyword
6//! labels, and reports adds/removes/changes at the finest granularity
7//! it can while still being deterministic.
8//!
9//! # Why this instead of line diff?
10//!
11//! - **Survives field reordering**: struct-form `(:field val)` pairs
12//!   are compared by keyword, so swapping two fields in a struct
13//!   produces zero edits (semantically identical).
14//! - **Human-readable paths**: each edit carries a dotted path like
15//!   `resource.attributes[2].required` so review isn't a character
16//!   hunt.
17//! - **Feeds remediation**: an edit list over IR is the same shape
18//!   the transform script consumes — you can generate a remediation
19//!   script from a diff.
20//!
21//! # Limitations
22//!
23//! - List diffing is positional (no LCS). Inserting an element at
24//!   index 0 of a list of 5 produces 5 "changed" edits. Good enough
25//!   for IR diffs where lists are usually small and stable-ordered.
26//!   A full LCS can slot in later behind the same public API.
27
28use crate::sexpr::SExpr;
29
30/// A single edit in a semantic diff.
31#[derive(Debug, Clone, PartialEq)]
32pub enum Edit {
33    /// A value appeared in the new tree at `path` that was absent in the old.
34    Added { path: String, value: SExpr },
35    /// A value was present in the old tree at `path` but is absent in the new.
36    Removed { path: String, value: SExpr },
37    /// A leaf value at `path` changed from `old` to `new`.
38    Changed {
39        path: String,
40        old: SExpr,
41        new: SExpr,
42    },
43}
44
45impl Edit {
46    /// Dotted path of the edit location.
47    #[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/// Compute edits that transform `old` into `new`.
58#[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    // Short-circuit: equal values emit nothing.
67    if old == new {
68        return;
69    }
70
71    // If either side is a struct-form list `(name (:field val) ...)`
72    // and both sides are struct-forms with the same head, do a
73    // field-keyed merge so reordering produces zero edits.
74    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 both sides are (list ...) forms, walk positionally.
84    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 both sides are tuple-tag lists with the same head (e.g.
90    // `(list <T>)` as an IacType), walk children positionally.
91    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                // Same tag, compare tails.
95                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    // Fallback: leaf-level change.
112    out.push(Edit::Changed {
113        path,
114        old: old.clone(),
115        new: new.clone(),
116    });
117}
118
119/// If `s` is `(name (:field val) ...)` and every tail item is a keyword
120/// pair, return the head symbol and the field map. Otherwise None.
121fn 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    // The tail must be non-empty AND every item must be a (:keyword value)
132    // pair for this to count as a struct-form.
133    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
154/// If `s` is `(list item1 item2 ...)`, return the items slice.
155fn 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    // Heuristic: a struct-form-internal pair starts with `:`.
169    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    // Collect keys from both sides, deterministic by BTreeMap iteration.
179    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    // Surplus in new → Added
214    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    // Surplus in old → Removed
226    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        // Same fields, different order → zero edits (struct-form merges
268        // by keyword).
269        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        // Different heads → can't merge; emits a single Changed at root.
307        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        // IacType::List(inner) is (list <inner>) — a tuple-tag form,
346        // not a list-form (head is "list" symbol, but it has one child
347        // and no "(list ...)" list wrapper). This test uses a different
348        // head to avoid confusion with list-form.
349        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        // (list string) vs (list integer) → tuple-tag diff at [0]
384        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(); // not required
395        let edits = diff(&a.to_sexpr(), &b.to_sexpr());
396        // The only differing field must be `required`. (TestAttributeBuilder
397        // treats `required` and `optional` as independent flags; we assert
398        // precisely what changed.)
399        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}