Skip to main content

tauri_plugin_hasgard/
diff.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5pub struct SnapshotElement {
6    #[serde(rename = "ref")]
7    pub ref_id: String,
8    pub role: String,
9    pub depth: u64,
10    #[serde(skip_serializing_if = "Option::is_none")]
11    pub name: Option<String>,
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub value: Option<String>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub checked: Option<bool>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub disabled: Option<bool>,
18}
19
20#[derive(Debug, Clone, Serialize)]
21pub struct ChangedEntry {
22    pub old: SnapshotElement,
23    pub new: SnapshotElement,
24    pub changes: Vec<String>,
25}
26
27#[derive(Debug, Clone, Serialize)]
28pub struct DiffResult {
29    pub added: Vec<SnapshotElement>,
30    pub removed: Vec<SnapshotElement>,
31    pub changed: Vec<ChangedEntry>,
32}
33
34/// Identity key for stable matching between snapshots.
35/// Uses (role, name, depth) since refs reset each snapshot.
36#[derive(Debug, Clone, PartialEq, Eq, Hash)]
37struct ElementKey {
38    role: String,
39    name: Option<String>,
40    depth: u64,
41}
42
43impl ElementKey {
44    fn from(el: &SnapshotElement) -> Self {
45        Self { role: el.role.clone(), name: el.name.clone(), depth: el.depth }
46    }
47}
48
49/// Compare two snapshot element lists and return added, removed, and changed entries.
50///
51/// Match elements by (role, name, depth). For duplicate keys, match by position order
52/// within the group. Unmatched old → removed, unmatched new → added.
53#[must_use]
54pub fn compute_diff(old: &[SnapshotElement], new: &[SnapshotElement]) -> DiffResult {
55    let mut old_groups: HashMap<ElementKey, Vec<usize>> = HashMap::new();
56    for (i, el) in old.iter().enumerate() {
57        old_groups.entry(ElementKey::from(el)).or_default().push(i);
58    }
59
60    let mut new_groups: HashMap<ElementKey, Vec<usize>> = HashMap::new();
61    for (i, el) in new.iter().enumerate() {
62        new_groups.entry(ElementKey::from(el)).or_default().push(i);
63    }
64
65    let mut matched_old: Vec<bool> = vec![false; old.len()];
66    let mut matched_new: Vec<bool> = vec![false; new.len()];
67    let mut changed: Vec<ChangedEntry> = Vec::new();
68
69    // Match by key and position within group
70    for (key, old_indices) in &old_groups {
71        if let Some(new_indices) = new_groups.get(key) {
72            let pair_count = old_indices.len().min(new_indices.len());
73            for i in 0..pair_count {
74                let old_idx = old_indices[i];
75                let new_idx = new_indices[i];
76                matched_old[old_idx] = true;
77                matched_new[new_idx] = true;
78
79                let old_el = &old[old_idx];
80                let new_el = &new[new_idx];
81                let mut field_changes: Vec<String> = Vec::new();
82
83                if old_el.value != new_el.value {
84                    field_changes.push("value".to_owned());
85                }
86                if old_el.checked != new_el.checked {
87                    field_changes.push("checked".to_owned());
88                }
89                if old_el.disabled != new_el.disabled {
90                    field_changes.push("disabled".to_owned());
91                }
92
93                if !field_changes.is_empty() {
94                    changed.push(ChangedEntry { old: old_el.clone(), new: new_el.clone(), changes: field_changes });
95                }
96            }
97        }
98    }
99
100    let mut removed: Vec<SnapshotElement> =
101        old.iter().enumerate().filter(|(i, _)| !matched_old[*i]).map(|(_, el)| el.clone()).collect();
102
103    let mut added: Vec<SnapshotElement> =
104        new.iter().enumerate().filter(|(i, _)| !matched_new[*i]).map(|(_, el)| el.clone()).collect();
105
106    // Sort all result arrays for deterministic output (HashMap iteration is unordered)
107    let sort_key = |a: &SnapshotElement, b: &SnapshotElement| {
108        a.depth.cmp(&b.depth).then_with(|| a.role.cmp(&b.role)).then_with(|| a.name.cmp(&b.name))
109    };
110    added.sort_by(sort_key);
111    removed.sort_by(sort_key);
112    changed.sort_by(|a, b| sort_key(&a.new, &b.new));
113
114    DiffResult { added, removed, changed }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    fn el(ref_id: &str, role: &str, depth: u64) -> SnapshotElement {
122        SnapshotElement {
123            ref_id: ref_id.to_owned(),
124            role: role.to_owned(),
125            depth,
126            name: None,
127            value: None,
128            checked: None,
129            disabled: None,
130        }
131    }
132
133    fn el_named(ref_id: &str, role: &str, depth: u64, name: &str) -> SnapshotElement {
134        SnapshotElement {
135            ref_id: ref_id.to_owned(),
136            role: role.to_owned(),
137            depth,
138            name: Some(name.to_owned()),
139            value: None,
140            checked: None,
141            disabled: None,
142        }
143    }
144
145    #[test]
146    fn test_snapshot_element_deserializes_string_value() {
147        // Regression for #120: the bridge emits `value` as a string ("0" for a
148        // bare <li>), and the reference-snapshot parse in `diff` must accept it.
149        let json = r#"{"ref":"e31","role":"listitem","depth":3,"name":"single hyphen value","value":"0"}"#;
150        let el: SnapshotElement = serde_json::from_str(json).expect("string value should deserialize");
151        assert_eq!(el.value, Some("0".to_owned()));
152    }
153
154    #[test]
155    fn test_diff_identical_snapshots() {
156        let snapshot = vec![el("e1", "button", 1), el("e2", "input", 2)];
157        let result = compute_diff(&snapshot, &snapshot);
158        assert!(result.added.is_empty());
159        assert!(result.removed.is_empty());
160        assert!(result.changed.is_empty());
161    }
162
163    #[test]
164    fn test_diff_added_elements() {
165        let old = vec![el("e1", "button", 1)];
166        let new = vec![el("e1", "button", 1), el("e2", "input", 2)];
167        let result = compute_diff(&old, &new);
168        assert_eq!(result.added.len(), 1);
169        assert_eq!(result.added[0].role, "input");
170        assert!(result.removed.is_empty());
171        assert!(result.changed.is_empty());
172    }
173
174    #[test]
175    fn test_diff_removed_elements() {
176        let old = vec![el("e1", "button", 1), el("e2", "input", 2)];
177        let new = vec![el("e1", "button", 1)];
178        let result = compute_diff(&old, &new);
179        assert!(result.added.is_empty());
180        assert_eq!(result.removed.len(), 1);
181        assert_eq!(result.removed[0].role, "input");
182        assert!(result.changed.is_empty());
183    }
184
185    #[test]
186    fn test_diff_changed_value() {
187        let old = vec![SnapshotElement {
188            ref_id: "e1".to_owned(),
189            role: "input".to_owned(),
190            depth: 1,
191            name: Some("username".to_owned()),
192            value: Some("old".to_owned()),
193            checked: None,
194            disabled: None,
195        }];
196        let new = vec![SnapshotElement {
197            ref_id: "e2".to_owned(),
198            role: "input".to_owned(),
199            depth: 1,
200            name: Some("username".to_owned()),
201            value: Some("new".to_owned()),
202            checked: None,
203            disabled: None,
204        }];
205        let result = compute_diff(&old, &new);
206        assert!(result.added.is_empty());
207        assert!(result.removed.is_empty());
208        assert_eq!(result.changed.len(), 1);
209        assert_eq!(result.changed[0].changes, vec!["value"]);
210    }
211
212    #[test]
213    fn test_diff_changed_multiple_fields() {
214        let old = vec![SnapshotElement {
215            ref_id: "e1".to_owned(),
216            role: "checkbox".to_owned(),
217            depth: 2,
218            name: Some("agree".to_owned()),
219            value: Some("on".to_owned()),
220            checked: Some(false),
221            disabled: Some(false),
222        }];
223        let new = vec![SnapshotElement {
224            ref_id: "e2".to_owned(),
225            role: "checkbox".to_owned(),
226            depth: 2,
227            name: Some("agree".to_owned()),
228            value: Some("off".to_owned()),
229            checked: Some(true),
230            disabled: Some(true),
231        }];
232        let result = compute_diff(&old, &new);
233        assert!(result.added.is_empty());
234        assert!(result.removed.is_empty());
235        assert_eq!(result.changed.len(), 1);
236        assert!(result.changed[0].changes.contains(&"value".to_owned()));
237        assert!(result.changed[0].changes.contains(&"checked".to_owned()));
238        assert!(result.changed[0].changes.contains(&"disabled".to_owned()));
239    }
240
241    #[test]
242    fn test_diff_mixed_changes() {
243        let old = vec![
244            el_named("e1", "button", 1, "submit"),
245            SnapshotElement {
246                ref_id: "e2".to_owned(),
247                role: "input".to_owned(),
248                depth: 2,
249                name: Some("email".to_owned()),
250                value: Some("old@example.com".to_owned()),
251                checked: None,
252                disabled: None,
253            },
254            el_named("e3", "link", 3, "home"),
255        ];
256        let new = vec![
257            el_named("e1", "button", 1, "submit"),
258            SnapshotElement {
259                ref_id: "e4".to_owned(),
260                role: "input".to_owned(),
261                depth: 2,
262                name: Some("email".to_owned()),
263                value: Some("new@example.com".to_owned()),
264                checked: None,
265                disabled: None,
266            },
267            el_named("e5", "paragraph", 4, "info"),
268        ];
269        let result = compute_diff(&old, &new);
270        assert_eq!(result.added.len(), 1);
271        assert_eq!(result.added[0].role, "paragraph");
272        assert_eq!(result.removed.len(), 1);
273        assert_eq!(result.removed[0].role, "link");
274        assert_eq!(result.changed.len(), 1);
275        assert_eq!(result.changed[0].changes, vec!["value"]);
276    }
277
278    #[test]
279    fn test_diff_empty_old() {
280        let old: Vec<SnapshotElement> = vec![];
281        let new = vec![el("e1", "button", 1), el("e2", "input", 2)];
282        let result = compute_diff(&old, &new);
283        assert_eq!(result.added.len(), 2);
284        assert!(result.removed.is_empty());
285        assert!(result.changed.is_empty());
286    }
287
288    #[test]
289    fn test_diff_duplicate_roles() {
290        // Two buttons at same depth — matched by position order within group
291        let old = vec![
292            SnapshotElement {
293                ref_id: "e1".to_owned(),
294                role: "button".to_owned(),
295                depth: 1,
296                name: None,
297                value: Some("save".to_owned()),
298                checked: None,
299                disabled: None,
300            },
301            SnapshotElement {
302                ref_id: "e2".to_owned(),
303                role: "button".to_owned(),
304                depth: 1,
305                name: None,
306                value: Some("cancel".to_owned()),
307                checked: None,
308                disabled: None,
309            },
310        ];
311        let new = vec![
312            SnapshotElement {
313                ref_id: "e3".to_owned(),
314                role: "button".to_owned(),
315                depth: 1,
316                name: None,
317                value: Some("save".to_owned()),
318                checked: None,
319                disabled: None,
320            },
321            SnapshotElement {
322                ref_id: "e4".to_owned(),
323                role: "button".to_owned(),
324                depth: 1,
325                name: None,
326                value: Some("cancel".to_owned()),
327                checked: None,
328                disabled: None,
329            },
330        ];
331        let result = compute_diff(&old, &new);
332        assert!(result.added.is_empty());
333        assert!(result.removed.is_empty());
334        assert!(result.changed.is_empty());
335    }
336}