Skip to main content

agentstategraph_core/
diff.rs

1//! Structured diff engine — schema-aware, typed diffs between state trees.
2//!
3//! Unlike git's line-based text diffs, AgentStateGraph diffs operate on typed
4//! structured data. Each DiffOp describes a specific change (SetValue,
5//! AddKey, RemoveKey, etc.) at an exact path in the state tree.
6
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeSet;
9
10use crate::object::{Atom, Node, Object, ObjectId};
11use crate::path::StatePath;
12
13/// A single structured change operation within a diff.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(tag = "op")]
16pub enum DiffOp {
17    /// A value was changed at this path.
18    SetValue {
19        path: String,
20        old: DiffValue,
21        new: DiffValue,
22    },
23    /// A key was added to a map.
24    AddKey {
25        path: String,
26        key: String,
27        value: DiffValue,
28    },
29    /// A key was removed from a map.
30    RemoveKey {
31        path: String,
32        key: String,
33        old_value: DiffValue,
34    },
35    /// An element was added to a list.
36    AddElement {
37        path: String,
38        index: usize,
39        value: DiffValue,
40    },
41    /// An element was removed from a list.
42    RemoveElement {
43        path: String,
44        index: usize,
45        old_value: DiffValue,
46    },
47    /// An element was added to a set.
48    AddToSet { path: String, value: DiffValue },
49    /// An element was removed from a set.
50    RemoveFromSet { path: String, old_value: DiffValue },
51    /// The type of a node changed (e.g., map became a list).
52    ChangeType {
53        path: String,
54        old_type: String,
55        new_type: String,
56    },
57}
58
59/// A JSON-serializable representation of a value in a diff.
60/// We can't use Object directly because it contains ObjectIds
61/// which aren't meaningful to external consumers.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(untagged)]
64pub enum DiffValue {
65    Null,
66    Bool(bool),
67    Int(i64),
68    /// Above `i64::MAX`. Before `Float`: untagged, so a large integer read
69    /// back must reach this variant first.
70    UInt(u64),
71    Float(f64),
72    String(String),
73    Bytes(Vec<u8>),
74    /// For complex values (maps, lists), we store a summary
75    /// rather than the full deep value.
76    Complex(String),
77}
78
79impl DiffValue {
80    /// Create a DiffValue from an Atom.
81    pub fn from_atom(atom: &Atom) -> Self {
82        match atom {
83            Atom::Null => DiffValue::Null,
84            Atom::Bool(b) => DiffValue::Bool(*b),
85            Atom::Int(i) => DiffValue::Int(*i),
86            Atom::UInt(u) => DiffValue::UInt(*u),
87            Atom::Float(f) => DiffValue::Float(*f),
88            Atom::String(s) => DiffValue::String(s.clone()),
89            Atom::Bytes(b) => DiffValue::Bytes(b.clone()),
90        }
91    }
92
93    /// Create a summary DiffValue for a Node.
94    pub fn from_node(node: &Node) -> Self {
95        match node {
96            Node::Map(entries) => DiffValue::Complex(format!("{{map: {} keys}}", entries.len())),
97            Node::List(items) => DiffValue::Complex(format!("[list: {} items]", items.len())),
98            Node::Set(items) => DiffValue::Complex(format!("{{set: {} items}}", items.len())),
99        }
100    }
101
102    /// Create a DiffValue from an Object.
103    pub fn from_object(obj: &Object) -> Self {
104        match obj {
105            Object::Atom(atom) => Self::from_atom(atom),
106            Object::Node(node) => Self::from_node(node),
107        }
108    }
109}
110
111/// Trait for resolving ObjectIds to Objects during diff computation.
112/// This decouples the diff algorithm from the storage backend.
113pub trait ObjectResolver {
114    fn resolve(&self, id: &ObjectId) -> Option<Object>;
115}
116
117/// Compute a structured diff between two state trees.
118///
119/// Returns a list of DiffOps describing every change needed to transform
120/// the state at `old_root` into the state at `new_root`.
121pub fn diff(
122    resolver: &dyn ObjectResolver,
123    old_root: &ObjectId,
124    new_root: &ObjectId,
125) -> Vec<DiffOp> {
126    // If roots are identical, no changes
127    if old_root == new_root {
128        return Vec::new();
129    }
130
131    let old_obj = resolver.resolve(old_root);
132    let new_obj = resolver.resolve(new_root);
133
134    match (old_obj, new_obj) {
135        (Some(old), Some(new)) => diff_objects(resolver, &StatePath::root(), &old, &new),
136        (None, Some(_)) => {
137            // Old didn't exist — entire new tree is an addition
138            // This shouldn't normally happen at root level
139            Vec::new()
140        }
141        (Some(_), None) => {
142            // New doesn't exist — entire old tree was deleted
143            Vec::new()
144        }
145        (None, None) => Vec::new(),
146    }
147}
148
149/// Recursively diff two objects at the given path.
150fn diff_objects(
151    resolver: &dyn ObjectResolver,
152    path: &StatePath,
153    old: &Object,
154    new: &Object,
155) -> Vec<DiffOp> {
156    if old == new {
157        return Vec::new();
158    }
159
160    match (old, new) {
161        // Both atoms — value changed
162        (Object::Atom(old_atom), Object::Atom(new_atom)) => {
163            vec![DiffOp::SetValue {
164                path: path.to_string(),
165                old: DiffValue::from_atom(old_atom),
166                new: DiffValue::from_atom(new_atom),
167            }]
168        }
169
170        // Both maps — compare keys
171        (Object::Node(Node::Map(old_entries)), Object::Node(Node::Map(new_entries))) => {
172            diff_maps(resolver, path, old_entries, new_entries)
173        }
174
175        // Both lists — compare elements
176        (Object::Node(Node::List(old_items)), Object::Node(Node::List(new_items))) => {
177            diff_lists(resolver, path, old_items, new_items)
178        }
179
180        // Both sets — compare membership
181        (Object::Node(Node::Set(old_items)), Object::Node(Node::Set(new_items))) => {
182            diff_sets(resolver, path, old_items, new_items)
183        }
184
185        // Type changed
186        _ => {
187            vec![DiffOp::ChangeType {
188                path: path.to_string(),
189                old_type: type_name(old),
190                new_type: type_name(new),
191            }]
192        }
193    }
194}
195
196fn diff_maps(
197    resolver: &dyn ObjectResolver,
198    path: &StatePath,
199    old_entries: &std::collections::BTreeMap<String, ObjectId>,
200    new_entries: &std::collections::BTreeMap<String, ObjectId>,
201) -> Vec<DiffOp> {
202    let mut ops = Vec::new();
203
204    let old_keys: BTreeSet<&String> = old_entries.keys().collect();
205    let new_keys: BTreeSet<&String> = new_entries.keys().collect();
206
207    // Removed keys
208    for key in old_keys.difference(&new_keys) {
209        let old_id = &old_entries[*key];
210        let old_obj = resolver.resolve(old_id);
211        ops.push(DiffOp::RemoveKey {
212            path: path.to_string(),
213            key: (*key).clone(),
214            old_value: old_obj
215                .map(|o| DiffValue::from_object(&o))
216                .unwrap_or(DiffValue::Null),
217        });
218    }
219
220    // Added keys
221    for key in new_keys.difference(&old_keys) {
222        let new_id = &new_entries[*key];
223        let new_obj = resolver.resolve(new_id);
224        ops.push(DiffOp::AddKey {
225            path: path.to_string(),
226            key: (*key).clone(),
227            value: new_obj
228                .map(|o| DiffValue::from_object(&o))
229                .unwrap_or(DiffValue::Null),
230        });
231    }
232
233    // Modified keys (same key, different ObjectId)
234    for key in old_keys.intersection(&new_keys) {
235        let old_id = &old_entries[*key];
236        let new_id = &new_entries[*key];
237
238        if old_id != new_id {
239            let child_path = path.push_key(*key);
240            let old_obj = resolver.resolve(old_id);
241            let new_obj = resolver.resolve(new_id);
242
243            match (old_obj, new_obj) {
244                (Some(old_child), Some(new_child)) => {
245                    ops.extend(diff_objects(resolver, &child_path, &old_child, &new_child));
246                }
247                _ => {
248                    // Can't resolve — report as a value change
249                    ops.push(DiffOp::SetValue {
250                        path: child_path.to_string(),
251                        old: DiffValue::Null,
252                        new: DiffValue::Null,
253                    });
254                }
255            }
256        }
257    }
258
259    ops
260}
261
262fn diff_lists(
263    resolver: &dyn ObjectResolver,
264    path: &StatePath,
265    old_items: &[ObjectId],
266    new_items: &[ObjectId],
267) -> Vec<DiffOp> {
268    let mut ops = Vec::new();
269    let max_len = old_items.len().max(new_items.len());
270
271    for i in 0..max_len {
272        match (old_items.get(i), new_items.get(i)) {
273            (Some(old_id), Some(new_id)) => {
274                if old_id != new_id {
275                    let child_path = path.push_index(i);
276                    let old_obj = resolver.resolve(old_id);
277                    let new_obj = resolver.resolve(new_id);
278
279                    match (old_obj, new_obj) {
280                        (Some(old_child), Some(new_child)) => {
281                            ops.extend(diff_objects(resolver, &child_path, &old_child, &new_child));
282                        }
283                        _ => {
284                            ops.push(DiffOp::SetValue {
285                                path: child_path.to_string(),
286                                old: DiffValue::Null,
287                                new: DiffValue::Null,
288                            });
289                        }
290                    }
291                }
292            }
293            (None, Some(new_id)) => {
294                // Element added
295                let new_obj = resolver.resolve(new_id);
296                ops.push(DiffOp::AddElement {
297                    path: path.to_string(),
298                    index: i,
299                    value: new_obj
300                        .map(|o| DiffValue::from_object(&o))
301                        .unwrap_or(DiffValue::Null),
302                });
303            }
304            (Some(old_id), None) => {
305                // Element removed
306                let old_obj = resolver.resolve(old_id);
307                ops.push(DiffOp::RemoveElement {
308                    path: path.to_string(),
309                    index: i,
310                    old_value: old_obj
311                        .map(|o| DiffValue::from_object(&o))
312                        .unwrap_or(DiffValue::Null),
313                });
314            }
315            (None, None) => unreachable!(),
316        }
317    }
318
319    ops
320}
321
322fn diff_sets(
323    resolver: &dyn ObjectResolver,
324    path: &StatePath,
325    old_items: &[ObjectId],
326    new_items: &[ObjectId],
327) -> Vec<DiffOp> {
328    let mut ops = Vec::new();
329
330    let old_set: BTreeSet<&ObjectId> = old_items.iter().collect();
331    let new_set: BTreeSet<&ObjectId> = new_items.iter().collect();
332
333    // Removed from set
334    for id in old_set.difference(&new_set) {
335        let obj = resolver.resolve(id);
336        ops.push(DiffOp::RemoveFromSet {
337            path: path.to_string(),
338            old_value: obj
339                .map(|o| DiffValue::from_object(&o))
340                .unwrap_or(DiffValue::Null),
341        });
342    }
343
344    // Added to set
345    for id in new_set.difference(&old_set) {
346        let obj = resolver.resolve(id);
347        ops.push(DiffOp::AddToSet {
348            path: path.to_string(),
349            value: obj
350                .map(|o| DiffValue::from_object(&o))
351                .unwrap_or(DiffValue::Null),
352        });
353    }
354
355    ops
356}
357
358fn type_name(obj: &Object) -> String {
359    match obj {
360        Object::Atom(Atom::Null) => "null".to_string(),
361        Object::Atom(Atom::Bool(_)) => "bool".to_string(),
362        Object::Atom(Atom::Int(_)) | Object::Atom(Atom::UInt(_)) => "int".to_string(),
363        Object::Atom(Atom::Float(_)) => "float".to_string(),
364        Object::Atom(Atom::String(_)) => "string".to_string(),
365        Object::Atom(Atom::Bytes(_)) => "bytes".to_string(),
366        Object::Node(Node::Map(_)) => "map".to_string(),
367        Object::Node(Node::List(_)) => "list".to_string(),
368        Object::Node(Node::Set(_)) => "set".to_string(),
369    }
370}
371
372#[cfg(test)]
373mod tests {
374
375    #[test]
376    fn a_large_integer_reads_back_as_itself() {
377        // Untagged: UInt must come before Float to be reachable.
378        let v = DiffValue::from_atom(&Atom::UInt(u64::MAX));
379        let back: DiffValue = serde_json::from_value(serde_json::to_value(&v).unwrap()).unwrap();
380        assert_eq!(back, DiffValue::UInt(u64::MAX));
381        let c = crate::merge::ConflictValue::from_object(&Object::uint(u64::MAX));
382        let back: crate::merge::ConflictValue =
383            serde_json::from_value(serde_json::to_value(&c).unwrap()).unwrap();
384        assert_eq!(back, crate::merge::ConflictValue::UInt(u64::MAX));
385    }
386
387    use super::*;
388    use std::collections::{BTreeMap, HashMap};
389
390    /// Simple in-memory resolver for testing.
391    struct TestResolver {
392        objects: HashMap<ObjectId, Object>,
393    }
394
395    impl TestResolver {
396        fn new() -> Self {
397            Self {
398                objects: HashMap::new(),
399            }
400        }
401
402        fn store(&mut self, obj: &Object) -> ObjectId {
403            let id = obj.id();
404            self.objects.insert(id, obj.clone());
405            id
406        }
407
408        fn store_json(&mut self, value: &serde_json::Value) -> ObjectId {
409            self.store_json_inner(value)
410        }
411
412        fn store_json_inner(&mut self, value: &serde_json::Value) -> ObjectId {
413            let obj = match value {
414                serde_json::Value::Null => Object::null(),
415                serde_json::Value::Bool(b) => Object::bool(*b),
416                serde_json::Value::Number(n) => {
417                    if let Some(i) = n.as_i64() {
418                        Object::int(i)
419                    } else {
420                        Object::float(n.as_f64().unwrap())
421                    }
422                }
423                serde_json::Value::String(s) => Object::string(s.clone()),
424                serde_json::Value::Array(arr) => {
425                    let ids: Vec<ObjectId> = arr.iter().map(|v| self.store_json_inner(v)).collect();
426                    Object::list(ids)
427                }
428                serde_json::Value::Object(map) => {
429                    let mut entries = BTreeMap::new();
430                    for (k, v) in map {
431                        let id = self.store_json_inner(v);
432                        entries.insert(k.clone(), id);
433                    }
434                    Object::map(entries)
435                }
436            };
437            self.store(&obj)
438        }
439    }
440
441    impl ObjectResolver for TestResolver {
442        fn resolve(&self, id: &ObjectId) -> Option<Object> {
443            self.objects.get(id).cloned()
444        }
445    }
446
447    #[test]
448    fn test_identical_trees_no_diff() {
449        let mut r = TestResolver::new();
450        let root = r.store_json(&serde_json::json!({"a": 1, "b": 2}));
451        let ops = diff(&r, &root, &root);
452        assert!(ops.is_empty());
453    }
454
455    #[test]
456    fn test_simple_value_change() {
457        let mut r = TestResolver::new();
458        let old = r.store_json(&serde_json::json!({"status": "healthy"}));
459        let new = r.store_json(&serde_json::json!({"status": "unhealthy"}));
460
461        let ops = diff(&r, &old, &new);
462        assert_eq!(ops.len(), 1);
463        match &ops[0] {
464            DiffOp::SetValue { path, old, new } => {
465                assert_eq!(path, "/status");
466                assert_eq!(*old, DiffValue::String("healthy".to_string()));
467                assert_eq!(*new, DiffValue::String("unhealthy".to_string()));
468            }
469            _ => panic!("expected SetValue, got {:?}", ops[0]),
470        }
471    }
472
473    #[test]
474    fn test_add_key() {
475        let mut r = TestResolver::new();
476        let old = r.store_json(&serde_json::json!({"a": 1}));
477        let new = r.store_json(&serde_json::json!({"a": 1, "b": 2}));
478
479        let ops = diff(&r, &old, &new);
480        assert_eq!(ops.len(), 1);
481        match &ops[0] {
482            DiffOp::AddKey { path, key, value } => {
483                assert_eq!(path, "/");
484                assert_eq!(key, "b");
485                assert_eq!(*value, DiffValue::Int(2));
486            }
487            _ => panic!("expected AddKey, got {:?}", ops[0]),
488        }
489    }
490
491    #[test]
492    fn test_remove_key() {
493        let mut r = TestResolver::new();
494        let old = r.store_json(&serde_json::json!({"a": 1, "b": 2}));
495        let new = r.store_json(&serde_json::json!({"a": 1}));
496
497        let ops = diff(&r, &old, &new);
498        assert_eq!(ops.len(), 1);
499        match &ops[0] {
500            DiffOp::RemoveKey {
501                path,
502                key,
503                old_value,
504            } => {
505                assert_eq!(path, "/");
506                assert_eq!(key, "b");
507                assert_eq!(*old_value, DiffValue::Int(2));
508            }
509            _ => panic!("expected RemoveKey, got {:?}", ops[0]),
510        }
511    }
512
513    #[test]
514    fn test_nested_changes() {
515        let mut r = TestResolver::new();
516        let old = r.store_json(&serde_json::json!({
517            "config": {
518                "network": { "subnet": "10.0.0.0/24" },
519                "dns": "8.8.8.8"
520            }
521        }));
522        let new = r.store_json(&serde_json::json!({
523            "config": {
524                "network": { "subnet": "192.168.0.0/16" },
525                "dns": "8.8.8.8"
526            }
527        }));
528
529        let ops = diff(&r, &old, &new);
530        assert_eq!(ops.len(), 1);
531        match &ops[0] {
532            DiffOp::SetValue { path, .. } => {
533                assert_eq!(path, "/config/network/subnet");
534            }
535            _ => panic!("expected SetValue at nested path"),
536        }
537    }
538
539    #[test]
540    fn test_multiple_changes() {
541        let mut r = TestResolver::new();
542        let old = r.store_json(&serde_json::json!({
543            "a": 1,
544            "b": 2,
545            "c": 3
546        }));
547        let new = r.store_json(&serde_json::json!({
548            "a": 10,
549            "b": 2,
550            "d": 4
551        }));
552
553        let ops = diff(&r, &old, &new);
554        // a changed, c removed, d added = 3 ops
555        assert_eq!(ops.len(), 3);
556
557        let op_types: Vec<String> = ops
558            .iter()
559            .map(|op| match op {
560                DiffOp::SetValue { path, .. } => format!("set:{}", path),
561                DiffOp::AddKey { key, .. } => format!("add:{}", key),
562                DiffOp::RemoveKey { key, .. } => format!("remove:{}", key),
563                _ => "other".to_string(),
564            })
565            .collect();
566
567        assert!(op_types.contains(&"set:/a".to_string()));
568        assert!(op_types.contains(&"remove:c".to_string()));
569        assert!(op_types.contains(&"add:d".to_string()));
570    }
571
572    #[test]
573    fn test_list_element_changed() {
574        let mut r = TestResolver::new();
575        let old = r.store_json(&serde_json::json!({"items": [1, 2, 3]}));
576        let new = r.store_json(&serde_json::json!({"items": [1, 99, 3]}));
577
578        let ops = diff(&r, &old, &new);
579        assert_eq!(ops.len(), 1);
580        match &ops[0] {
581            DiffOp::SetValue { path, old, new } => {
582                assert_eq!(path, "/items/1");
583                assert_eq!(*old, DiffValue::Int(2));
584                assert_eq!(*new, DiffValue::Int(99));
585            }
586            _ => panic!("expected SetValue"),
587        }
588    }
589
590    #[test]
591    fn test_list_element_added() {
592        let mut r = TestResolver::new();
593        let old = r.store_json(&serde_json::json!({"items": [1, 2]}));
594        let new = r.store_json(&serde_json::json!({"items": [1, 2, 3]}));
595
596        let ops = diff(&r, &old, &new);
597        assert_eq!(ops.len(), 1);
598        match &ops[0] {
599            DiffOp::AddElement { path, index, value } => {
600                assert_eq!(path, "/items");
601                assert_eq!(*index, 2);
602                assert_eq!(*value, DiffValue::Int(3));
603            }
604            _ => panic!("expected AddElement"),
605        }
606    }
607
608    #[test]
609    fn test_list_element_removed() {
610        let mut r = TestResolver::new();
611        let old = r.store_json(&serde_json::json!({"items": [1, 2, 3]}));
612        let new = r.store_json(&serde_json::json!({"items": [1, 2]}));
613
614        let ops = diff(&r, &old, &new);
615        assert_eq!(ops.len(), 1);
616        match &ops[0] {
617            DiffOp::RemoveElement {
618                path,
619                index,
620                old_value,
621            } => {
622                assert_eq!(path, "/items");
623                assert_eq!(*index, 2);
624                assert_eq!(*old_value, DiffValue::Int(3));
625            }
626            _ => panic!("expected RemoveElement"),
627        }
628    }
629
630    #[test]
631    fn test_type_change() {
632        let mut r = TestResolver::new();
633        let old = r.store_json(&serde_json::json!({"value": "string"}));
634        let new = r.store_json(&serde_json::json!({"value": 42}));
635
636        let ops = diff(&r, &old, &new);
637        assert_eq!(ops.len(), 1);
638        match &ops[0] {
639            DiffOp::SetValue { path, .. } => {
640                assert_eq!(path, "/value");
641            }
642            _ => panic!("expected SetValue for atom type change"),
643        }
644    }
645
646    #[test]
647    fn test_structural_type_change() {
648        let mut r = TestResolver::new();
649        let _old = r.store_json(&serde_json::json!({"value": "was a string"}));
650        let new_list = Object::list(vec![r.store(&Object::int(1))]);
651        let new_id = r.store(&new_list);
652
653        let mut entries = BTreeMap::new();
654        entries.insert("value".to_string(), new_id);
655        let new_root = r.store(&Object::map(entries));
656
657        let old = r.store_json(&serde_json::json!({"value": "was a string"}));
658        let ops = diff(&r, &old, &new_root);
659
660        assert_eq!(ops.len(), 1);
661        match &ops[0] {
662            DiffOp::ChangeType {
663                path,
664                old_type,
665                new_type,
666            } => {
667                assert_eq!(path, "/value");
668                assert_eq!(old_type, "string");
669                assert_eq!(new_type, "list");
670            }
671            _ => panic!("expected ChangeType"),
672        }
673    }
674
675    #[test]
676    fn test_diff_serializable() {
677        let mut r = TestResolver::new();
678        let old = r.store_json(&serde_json::json!({"a": 1}));
679        let new = r.store_json(&serde_json::json!({"a": 2, "b": 3}));
680
681        let ops = diff(&r, &old, &new);
682        let json = serde_json::to_string_pretty(&ops).unwrap();
683        assert!(json.contains("SetValue"));
684        assert!(json.contains("AddKey"));
685
686        // Deserialize roundtrip
687        let parsed: Vec<DiffOp> = serde_json::from_str(&json).unwrap();
688        assert_eq!(parsed, ops);
689    }
690}