Skip to main content

shape_runtime/
state_diff.rs

1//! Content-addressed state diffing for distributed Shape.
2//!
3//! Provides `diff(old, new)` and `patch(base, delta)` operations that
4//! compare values using content-hash trees. Only changed subtrees are
5//! included in the delta, enabling efficient state synchronization.
6
7use crate::hashing::HashDigest;
8use crate::type_schema::TypeSchemaRegistry;
9use sha2::{Digest, Sha256};
10use shape_value::NanTag;
11use shape_value::ValueWord;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15// ---------------------------------------------------------------------------
16// Delta representation
17// ---------------------------------------------------------------------------
18
19/// A delta between two values, keyed by content path.
20///
21/// Paths use dot-separated notation:
22/// - `"field_name"` for top-level fields of a TypedObject
23/// - `"field_name.nested"` for nested fields
24/// - `"[0]"`, `"[1]"` for array indices
25/// - `"frames.[0].locals.[2]"` for deeply nested paths
26#[derive(Debug, Clone)]
27pub struct Delta {
28    /// Fields/paths that changed, mapped to their new values.
29    pub changed: HashMap<String, ValueWord>,
30    /// Paths that were removed (present in old, absent in new).
31    pub removed: Vec<String>,
32}
33
34impl Delta {
35    /// Create an empty delta (no changes).
36    pub fn empty() -> Self {
37        Self {
38            changed: HashMap::new(),
39            removed: Vec::new(),
40        }
41    }
42
43    /// True if this delta represents no change.
44    pub fn is_empty(&self) -> bool {
45        self.changed.is_empty() && self.removed.is_empty()
46    }
47
48    /// Number of changes (additions + modifications + removals).
49    pub fn change_count(&self) -> usize {
50        self.changed.len() + self.removed.len()
51    }
52
53    /// Apply this delta to a base value, producing the updated value.
54    ///
55    /// This is a convenience wrapper around [`patch_value`] that validates
56    /// delta paths before applying. Invalid paths (empty segments, leading
57    /// or trailing dots) are silently skipped.
58    ///
59    /// # Path validation
60    ///
61    /// Each path in `changed` and `removed` is checked for basic structural
62    /// validity:
63    /// - Must not be empty (except the root sentinel `"."`).
64    /// - Must not contain empty segments (e.g. `"a..b"`).
65    /// - Must not start or end with `"."` (except the root sentinel).
66    ///
67    /// Paths that fail validation are excluded from the applied delta and
68    /// collected into the returned `Vec` of rejected path strings.
69    pub fn patch(
70        &self,
71        base: &ValueWord,
72        schemas: &TypeSchemaRegistry,
73    ) -> (ValueWord, Vec<String>) {
74        let mut rejected = Vec::new();
75        let validated = self.validated_delta(&mut rejected);
76        let result = patch_value(base, &validated, schemas);
77        (result, rejected)
78    }
79
80    /// Build a new `Delta` containing only paths that pass validation,
81    /// collecting rejected paths into `rejected`.
82    fn validated_delta(&self, rejected: &mut Vec<String>) -> Delta {
83        let mut valid = Delta::empty();
84
85        for (path, value) in &self.changed {
86            if is_valid_delta_path(path) {
87                valid.changed.insert(path.clone(), value.clone());
88            } else {
89                rejected.push(path.clone());
90            }
91        }
92
93        for path in &self.removed {
94            if is_valid_delta_path(path) {
95                valid.removed.push(path.clone());
96            } else {
97                rejected.push(path.clone());
98            }
99        }
100
101        valid
102    }
103}
104
105/// Check whether a delta path is structurally valid.
106///
107/// The root sentinel `"."` is always valid. All other paths must be
108/// non-empty, must not contain empty segments (consecutive dots), and
109/// must not start or end with a dot.
110fn is_valid_delta_path(path: &str) -> bool {
111    // Root sentinel is always valid
112    if path == "." {
113        return true;
114    }
115
116    if path.is_empty() {
117        return false;
118    }
119
120    // Array index paths like "[0]" are valid
121    if path.starts_with('[') {
122        return true;
123    }
124
125    // Must not start or end with a dot
126    if path.starts_with('.') || path.ends_with('.') {
127        return false;
128    }
129
130    // Must not contain empty segments (consecutive dots)
131    if path.contains("..") {
132        return false;
133    }
134
135    true
136}
137
138// ---------------------------------------------------------------------------
139// Value hashing
140// ---------------------------------------------------------------------------
141
142/// Compute a content hash for a ValueWord value.
143///
144/// Provides structural hashing that is deterministic across runs.
145/// For TypedObjects, fields are hashed in slot order. For arrays, each
146/// element is hashed. Primitives are hashed by their binary representation.
147pub fn content_hash_value(value: &ValueWord, schemas: &TypeSchemaRegistry) -> HashDigest {
148    let mut hasher = Sha256::new();
149    hash_value_into(&mut hasher, value, schemas);
150    let result = hasher.finalize();
151    let hex_str = result.iter().fold(String::with_capacity(64), |mut acc, b| {
152        use std::fmt::Write;
153        let _ = write!(acc, "{:02x}", b);
154        acc
155    });
156    HashDigest::from_hex(&hex_str)
157}
158
159fn hash_value_into(hasher: &mut Sha256, value: &ValueWord, schemas: &TypeSchemaRegistry) {
160    match value.tag() {
161        NanTag::F64 => {
162            hasher.update(b"f64:");
163            if let Some(f) = value.as_f64() {
164                hasher.update(f.to_le_bytes());
165            }
166        }
167        NanTag::I48 => {
168            hasher.update(b"i48:");
169            if let Some(i) = value.as_i64() {
170                hasher.update(i.to_le_bytes());
171            }
172        }
173        NanTag::Bool => {
174            hasher.update(b"bool:");
175            if let Some(b) = value.as_bool() {
176                hasher.update(if b { &[1u8] } else { &[0u8] });
177            }
178        }
179        NanTag::None => {
180            hasher.update(b"none");
181        }
182        NanTag::Unit => {
183            hasher.update(b"unit");
184        }
185        NanTag::Function => {
186            hasher.update(b"fn:");
187            hasher.update(value.raw_bits().to_le_bytes());
188        }
189        NanTag::ModuleFunction => {
190            hasher.update(b"modfn:");
191            hasher.update(value.raw_bits().to_le_bytes());
192        }
193        NanTag::Ref => {
194            hasher.update(b"ref:");
195            hasher.update(value.raw_bits().to_le_bytes());
196        }
197        NanTag::Heap => {
198            // Heap values: differentiate by content
199            if let Some(s) = value.as_str() {
200                hasher.update(b"str:");
201                hasher.update((s.len() as u64).to_le_bytes());
202                hasher.update(s.as_bytes());
203            } else if let Some(view) = value.as_any_array() {
204                hasher.update(b"arr:");
205                hasher.update((view.len() as u64).to_le_bytes());
206                let arr = view.to_generic();
207                for elem in arr.iter() {
208                    hash_value_into(hasher, elem, schemas);
209                }
210            } else if let Some((schema_id, slots, heap_mask)) = value.as_typed_object() {
211                hasher.update(b"obj:");
212                hasher.update(schema_id.to_le_bytes());
213                for (i, slot) in slots.iter().enumerate() {
214                    let is_heap = (heap_mask >> i) & 1 == 1;
215                    if is_heap {
216                        let nb = slot.as_heap_nb();
217                        hash_value_into(hasher, &nb, schemas);
218                    } else {
219                        hasher.update(b"slot:");
220                        hasher.update(slot.raw().to_le_bytes());
221                    }
222                }
223            } else {
224                // Other heap types (BigInt, Decimal, Closure, etc.)
225                hasher.update(b"heap:");
226                hasher.update(value.raw_bits().to_le_bytes());
227            }
228        }
229    }
230}
231
232// ---------------------------------------------------------------------------
233// Diffing
234// ---------------------------------------------------------------------------
235
236/// Compute the delta between two values.
237///
238/// For TypedObjects of the same schema, produces per-field diffs.
239/// For arrays of the same length, produces per-element diffs.
240/// For all other cases, treats the entire value as changed if different.
241pub fn diff_values(old: &ValueWord, new: &ValueWord, schemas: &TypeSchemaRegistry) -> Delta {
242    let mut delta = Delta::empty();
243    diff_recursive(old, new, "", schemas, &mut delta);
244    delta
245}
246
247fn make_path(prefix: &str, suffix: &str) -> String {
248    if prefix.is_empty() {
249        suffix.to_string()
250    } else {
251        format!("{}.{}", prefix, suffix)
252    }
253}
254
255fn root_path(prefix: &str) -> String {
256    if prefix.is_empty() {
257        ".".to_string()
258    } else {
259        prefix.to_string()
260    }
261}
262
263fn diff_recursive(
264    old: &ValueWord,
265    new: &ValueWord,
266    prefix: &str,
267    schemas: &TypeSchemaRegistry,
268    delta: &mut Delta,
269) {
270    // Fast path: identical raw bits means identical value
271    if old.raw_bits() == new.raw_bits() {
272        return;
273    }
274
275    // If tags differ, the whole subtree changed
276    if old.tag() != new.tag() {
277        delta.changed.insert(root_path(prefix), new.clone());
278        return;
279    }
280
281    match old.tag() {
282        NanTag::Heap => {
283            // Try typed object diff
284            if let (Some((old_sid, old_slots, old_hm)), Some((new_sid, new_slots, new_hm))) =
285                (old.as_typed_object(), new.as_typed_object())
286            {
287                if old_sid == new_sid {
288                    let schema = schemas.get_by_id(old_sid as u32);
289                    let min_len = old_slots.len().min(new_slots.len());
290
291                    for i in 0..min_len {
292                        let field_name = schema
293                            .and_then(|s| s.fields.get(i).map(|f| f.name.as_str()))
294                            .unwrap_or("?");
295                        let field_path = make_path(prefix, field_name);
296
297                        let old_is_heap = (old_hm >> i) & 1 == 1;
298                        let new_is_heap = (new_hm >> i) & 1 == 1;
299
300                        if old_is_heap && new_is_heap {
301                            let old_nb = old_slots[i].as_heap_nb();
302                            let new_nb = new_slots[i].as_heap_nb();
303                            diff_recursive(&old_nb, &new_nb, &field_path, schemas, delta);
304                        } else if old_slots[i].raw() != new_slots[i].raw()
305                            || old_is_heap != new_is_heap
306                        {
307                            // Slot raw bits differ or heap-ness changed
308                            if new_is_heap {
309                                delta.changed.insert(field_path, new_slots[i].as_heap_nb());
310                            } else {
311                                delta.changed.insert(field_path, unsafe {
312                                    ValueWord::clone_from_bits(new_slots[i].raw())
313                                });
314                            }
315                        }
316                    }
317
318                    // Extra new slots
319                    for i in old_slots.len()..new_slots.len() {
320                        let field_name = schema
321                            .and_then(|s| s.fields.get(i).map(|f| f.name.as_str()))
322                            .unwrap_or("?");
323                        let field_path = make_path(prefix, field_name);
324                        let is_heap = (new_hm >> i) & 1 == 1;
325                        if is_heap {
326                            delta.changed.insert(field_path, new_slots[i].as_heap_nb());
327                        } else {
328                            delta.changed.insert(field_path, unsafe {
329                                ValueWord::clone_from_bits(new_slots[i].raw())
330                            });
331                        }
332                    }
333
334                    // Removed slots
335                    for i in new_slots.len()..old_slots.len() {
336                        let field_name = schema
337                            .and_then(|s| s.fields.get(i).map(|f| f.name.as_str()))
338                            .unwrap_or("?");
339                        delta.removed.push(make_path(prefix, field_name));
340                    }
341                    return;
342                }
343                // Different schemas: whole value changed
344                delta.changed.insert(root_path(prefix), new.clone());
345                return;
346            }
347
348            // Try array diff
349            if let (Some(old_view), Some(new_view)) = (old.as_any_array(), new.as_any_array()) {
350                let old_arr = old_view.to_generic();
351                let new_arr = new_view.to_generic();
352                let min_len = old_arr.len().min(new_arr.len());
353
354                for i in 0..min_len {
355                    let idx_path = if prefix.is_empty() {
356                        format!("[{}]", i)
357                    } else {
358                        format!("{}.[{}]", prefix, i)
359                    };
360                    diff_recursive(&old_arr[i], &new_arr[i], &idx_path, schemas, delta);
361                }
362
363                for i in min_len..new_arr.len() {
364                    let idx_path = if prefix.is_empty() {
365                        format!("[{}]", i)
366                    } else {
367                        format!("{}.[{}]", prefix, i)
368                    };
369                    delta.changed.insert(idx_path, new_arr[i].clone());
370                }
371
372                for i in min_len..old_arr.len() {
373                    let idx_path = if prefix.is_empty() {
374                        format!("[{}]", i)
375                    } else {
376                        format!("{}.[{}]", prefix, i)
377                    };
378                    delta.removed.push(idx_path);
379                }
380                return;
381            }
382
383            // Try HashMap diff
384            if let (Some(old_data), Some(new_data)) =
385                (old.as_hashmap_data(), new.as_hashmap_data())
386            {
387                diff_hashmap(old_data, new_data, prefix, schemas, delta);
388                return;
389            }
390
391            // Try string diff
392            if let (Some(old_s), Some(new_s)) = (old.as_str(), new.as_str()) {
393                if old_s != new_s {
394                    delta.changed.insert(root_path(prefix), new.clone());
395                }
396                return;
397            }
398
399            // Different heap subtypes: whole value changed
400            delta.changed.insert(root_path(prefix), new.clone());
401        }
402
403        _ => {
404            // Primitive types: already checked raw bits above, so they differ
405            delta.changed.insert(root_path(prefix), new.clone());
406        }
407    }
408}
409
410/// Diff two HashMap values by comparing keys and values.
411///
412/// Detects:
413/// - Keys present in `new` but not in `old` (added entries)
414/// - Keys present in `old` but not in `new` (removed entries)
415/// - Keys present in both but with different values (changed entries)
416///
417/// For changed entries whose values are themselves compound types (arrays,
418/// objects, hashmaps), diffs recursively instead of treating as atomic.
419fn diff_hashmap(
420    old_data: &shape_value::HashMapData,
421    new_data: &shape_value::HashMapData,
422    prefix: &str,
423    schemas: &TypeSchemaRegistry,
424    delta: &mut Delta,
425) {
426    // Build a lookup from old keys for efficient comparison.
427    // For each key in the new map, check if it exists in the old map.
428    for (new_idx, new_key) in new_data.keys.iter().enumerate() {
429        let key_label = format_map_key(new_key);
430        let key_path = make_path(prefix, &key_label);
431
432        match old_data.find_key(new_key) {
433            Some(old_idx) => {
434                // Key exists in both — diff the values recursively
435                diff_recursive(
436                    &old_data.values[old_idx],
437                    &new_data.values[new_idx],
438                    &key_path,
439                    schemas,
440                    delta,
441                );
442            }
443            None => {
444                // Key added in new
445                delta
446                    .changed
447                    .insert(key_path, new_data.values[new_idx].clone());
448            }
449        }
450    }
451
452    // Find keys removed from old (present in old, absent in new)
453    for old_key in &old_data.keys {
454        if new_data.find_key(old_key).is_none() {
455            let key_label = format_map_key(old_key);
456            let key_path = make_path(prefix, &key_label);
457            delta.removed.push(key_path);
458        }
459    }
460}
461
462/// Format a HashMap key as a path component for delta paths.
463///
464/// String keys use their value directly (e.g. `"name"`).
465/// Integer keys use bracket notation (e.g. `{42}`).
466/// Other types use a debug-style representation.
467fn format_map_key(key: &ValueWord) -> String {
468    if let Some(s) = key.as_str() {
469        s.to_string()
470    } else if let Some(i) = key.as_i64() {
471        format!("{{{}}}", i)
472    } else if let Some(f) = key.as_f64() {
473        format!("{{{}}}", f)
474    } else if let Some(b) = key.as_bool() {
475        format!("{{{}}}", b)
476    } else {
477        format!("{{0x{:x}}}", key.raw_bits())
478    }
479}
480
481// ---------------------------------------------------------------------------
482// Patching
483// ---------------------------------------------------------------------------
484
485/// Apply a delta to a base value, producing the updated value.
486///
487/// For TypedObjects, patches individual fields by path.
488/// For arrays, patches individual elements by index.
489/// For root-level changes (path "."), replaces the entire value.
490pub fn patch_value(base: &ValueWord, delta: &Delta, schemas: &TypeSchemaRegistry) -> ValueWord {
491    if delta.is_empty() {
492        return base.clone();
493    }
494
495    // Root-level replacement
496    if let Some(root_val) = delta.changed.get(".") {
497        return root_val.clone();
498    }
499
500    // Try to patch TypedObject fields
501    if let Some((schema_id, slots, heap_mask)) = base.as_typed_object() {
502        let schema = schemas.get_by_id(schema_id as u32);
503        if let Some(schema) = schema {
504            // Partition changed entries into direct and nested
505            let mut direct_changes: HashMap<String, ValueWord> = HashMap::new();
506            let mut nested_changes: HashMap<String, Delta> = HashMap::new();
507
508            for (path, value) in &delta.changed {
509                if let Some(dot_pos) = path.find('.') {
510                    let top = &path[..dot_pos];
511                    let rest = &path[dot_pos + 1..];
512                    nested_changes
513                        .entry(top.to_string())
514                        .or_insert_with(Delta::empty)
515                        .changed
516                        .insert(rest.to_string(), value.clone());
517                } else {
518                    direct_changes.insert(path.clone(), value.clone());
519                }
520            }
521
522            // Similarly partition removed entries into direct and nested.
523            // Note: direct removals for TypedObject fields are not currently
524            // applied (fields can't be removed from a fixed schema), but we
525            // still partition so nested removals are forwarded recursively.
526            let mut _direct_removals: Vec<String> = Vec::new();
527            let mut nested_removals: HashMap<String, Delta> = HashMap::new();
528
529            for path in &delta.removed {
530                if let Some(dot_pos) = path.find('.') {
531                    let top = &path[..dot_pos];
532                    let rest = &path[dot_pos + 1..];
533                    nested_removals
534                        .entry(top.to_string())
535                        .or_insert_with(Delta::empty)
536                        .removed
537                        .push(rest.to_string());
538                } else {
539                    _direct_removals.push(path.clone());
540                }
541            }
542
543            // Merge nested removals into nested_changes map
544            for (top, mut removal_delta) in nested_removals {
545                let entry = nested_changes.entry(top).or_insert_with(Delta::empty);
546                entry.removed.append(&mut removal_delta.removed);
547            }
548
549            // Clone all slots carefully
550            let mut new_slots: Vec<shape_value::ValueSlot> = Vec::with_capacity(slots.len());
551            for (i, slot) in slots.iter().enumerate() {
552                let is_heap = (heap_mask >> i) & 1 == 1;
553                if is_heap {
554                    new_slots.push(unsafe { slot.clone_heap() });
555                } else {
556                    new_slots.push(shape_value::ValueSlot::from_raw(slot.raw()));
557                }
558            }
559            let mut new_heap_mask = heap_mask;
560
561            // Apply direct field changes (paths with no '.' separator)
562            for (path, new_val) in &direct_changes {
563                if let Some(field_idx_u16) = schema.field_index(path) {
564                    let field_idx = field_idx_u16 as usize;
565                    if field_idx < new_slots.len() {
566                        // Drop old heap slot if needed
567                        if (new_heap_mask >> field_idx) & 1 == 1 {
568                            unsafe {
569                                new_slots[field_idx].drop_heap();
570                            }
571                        }
572
573                        if new_val.is_heap() {
574                            if let Some(hv) = new_val.as_heap_ref() {
575                                new_slots[field_idx] =
576                                    shape_value::ValueSlot::from_heap(hv.clone());
577                                new_heap_mask |= 1u64 << field_idx;
578                            }
579                        } else if let Some(f) = new_val.as_f64() {
580                            new_slots[field_idx] = shape_value::ValueSlot::from_number(f);
581                            new_heap_mask &= !(1u64 << field_idx);
582                        } else if let Some(i) = new_val.as_i64() {
583                            new_slots[field_idx] = shape_value::ValueSlot::from_int(i);
584                            new_heap_mask &= !(1u64 << field_idx);
585                        } else if let Some(b) = new_val.as_bool() {
586                            new_slots[field_idx] = shape_value::ValueSlot::from_bool(b);
587                            new_heap_mask &= !(1u64 << field_idx);
588                        }
589                    }
590                }
591            }
592
593            // Apply nested field changes (dotted paths like "inner.field")
594            for (top_field, sub_delta) in &nested_changes {
595                if let Some(field_idx_u16) = schema.field_index(top_field) {
596                    let field_idx = field_idx_u16 as usize;
597                    if field_idx < new_slots.len() {
598                        // Extract the current value from the slot
599                        let is_heap = (new_heap_mask >> field_idx) & 1 == 1;
600                        if is_heap {
601                            let current_val = new_slots[field_idx].as_heap_nb();
602                            // Recursively patch the nested value
603                            let patched = patch_value(&current_val, sub_delta, schemas);
604
605                            // Drop the old heap slot
606                            unsafe {
607                                new_slots[field_idx].drop_heap();
608                            }
609
610                            // Write back the patched value
611                            if patched.is_heap() {
612                                if let Some(hv) = patched.as_heap_ref() {
613                                    new_slots[field_idx] =
614                                        shape_value::ValueSlot::from_heap(hv.clone());
615                                    new_heap_mask |= 1u64 << field_idx;
616                                }
617                            } else if let Some(f) = patched.as_f64() {
618                                new_slots[field_idx] = shape_value::ValueSlot::from_number(f);
619                                new_heap_mask &= !(1u64 << field_idx);
620                            } else if let Some(i) = patched.as_i64() {
621                                new_slots[field_idx] = shape_value::ValueSlot::from_int(i);
622                                new_heap_mask &= !(1u64 << field_idx);
623                            } else if let Some(b) = patched.as_bool() {
624                                new_slots[field_idx] = shape_value::ValueSlot::from_bool(b);
625                                new_heap_mask &= !(1u64 << field_idx);
626                            }
627                        }
628                    }
629                }
630            }
631
632            use shape_value::HeapValue;
633            return ValueWord::from_heap_value(HeapValue::TypedObject {
634                schema_id,
635                slots: new_slots.into_boxed_slice(),
636                heap_mask: new_heap_mask,
637            });
638        }
639    }
640
641    // Try to patch Array elements
642    if let Some(view) = base.as_any_array() {
643        let arr = view.to_generic();
644        let mut new_arr: Vec<ValueWord> = arr.to_vec();
645
646        // Process removals first (high to low to preserve indices)
647        let mut removal_indices: Vec<usize> = delta
648            .removed
649            .iter()
650            .filter_map(|path| parse_array_index(path))
651            .collect();
652        removal_indices.sort_unstable();
653        removal_indices.reverse();
654        for idx in removal_indices {
655            if idx < new_arr.len() {
656                new_arr.remove(idx);
657            }
658        }
659
660        // Process changes
661        for (path, new_val) in &delta.changed {
662            if let Some(idx) = parse_array_index(path) {
663                if idx < new_arr.len() {
664                    new_arr[idx] = new_val.clone();
665                } else {
666                    while new_arr.len() < idx {
667                        new_arr.push(ValueWord::none());
668                    }
669                    new_arr.push(new_val.clone());
670                }
671            }
672        }
673
674        return ValueWord::from_array(Arc::new(new_arr));
675    }
676
677    // Try to patch HashMap entries
678    if let Some(data) = base.as_hashmap_data() {
679        let mut new_keys = data.keys.clone();
680        let mut new_values = data.values.clone();
681
682        // Process removals
683        for path in &delta.removed {
684            // Find the key in the map and remove it
685            let remove_idx = new_keys
686                .iter()
687                .position(|k| format_map_key(k) == *path);
688            if let Some(idx) = remove_idx {
689                new_keys.remove(idx);
690                new_values.remove(idx);
691            }
692        }
693
694        // Process changes (add or update)
695        for (path, new_val) in &delta.changed {
696            // Check if this path has nested sub-paths (contains '.')
697            // For simplicity, direct key changes are applied here.
698            let existing_idx = new_keys
699                .iter()
700                .position(|k| format_map_key(k) == *path);
701            if let Some(idx) = existing_idx {
702                new_values[idx] = new_val.clone();
703            } else {
704                // New key — use a string key matching the path label
705                new_keys.push(ValueWord::from_string(Arc::new(path.clone())));
706                new_values.push(new_val.clone());
707            }
708        }
709
710        return ValueWord::from_hashmap_pairs(new_keys, new_values);
711    }
712
713    // Cannot patch — return base unchanged
714    base.clone()
715}
716
717/// Parse an array index from a path like "[3]" or "prefix.[3]".
718fn parse_array_index(path: &str) -> Option<usize> {
719    let part = path.rsplit('.').next().unwrap_or(path);
720    if part.starts_with('[') && part.ends_with(']') {
721        part[1..part.len() - 1].parse().ok()
722    } else {
723        None
724    }
725}
726
727// ---------------------------------------------------------------------------
728// Tests
729// ---------------------------------------------------------------------------
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn test_empty_delta() {
737        let delta = Delta::empty();
738        assert!(delta.is_empty());
739        assert_eq!(delta.change_count(), 0);
740    }
741
742    #[test]
743    fn test_diff_identical_primitives() {
744        let schemas = TypeSchemaRegistry::new();
745        let a = ValueWord::from_f64(42.0);
746        let b = ValueWord::from_f64(42.0);
747        let delta = diff_values(&a, &b, &schemas);
748        assert!(delta.is_empty());
749    }
750
751    #[test]
752    fn test_diff_different_primitives() {
753        let schemas = TypeSchemaRegistry::new();
754        let a = ValueWord::from_f64(42.0);
755        let b = ValueWord::from_f64(99.0);
756        let delta = diff_values(&a, &b, &schemas);
757        assert!(!delta.is_empty());
758        assert_eq!(delta.change_count(), 1);
759        assert!(delta.changed.contains_key("."));
760    }
761
762    #[test]
763    fn test_diff_arrays_same() {
764        let schemas = TypeSchemaRegistry::new();
765        let a = ValueWord::from_array(Arc::new(vec![
766            ValueWord::from_f64(1.0),
767            ValueWord::from_f64(2.0),
768        ]));
769        let b = ValueWord::from_array(Arc::new(vec![
770            ValueWord::from_f64(1.0),
771            ValueWord::from_f64(2.0),
772        ]));
773        let delta = diff_values(&a, &b, &schemas);
774        // Different Arc pointers so raw bits differ, but elements match
775        assert!(delta.is_empty());
776    }
777
778    #[test]
779    fn test_diff_arrays_element_changed() {
780        let schemas = TypeSchemaRegistry::new();
781        let a = ValueWord::from_array(Arc::new(vec![
782            ValueWord::from_f64(1.0),
783            ValueWord::from_f64(2.0),
784        ]));
785        let b = ValueWord::from_array(Arc::new(vec![
786            ValueWord::from_f64(1.0),
787            ValueWord::from_f64(99.0),
788        ]));
789        let delta = diff_values(&a, &b, &schemas);
790        assert_eq!(delta.change_count(), 1);
791        assert!(delta.changed.contains_key("[1]"));
792    }
793
794    #[test]
795    fn test_diff_arrays_element_added() {
796        let schemas = TypeSchemaRegistry::new();
797        let a = ValueWord::from_array(Arc::new(vec![ValueWord::from_f64(1.0)]));
798        let b = ValueWord::from_array(Arc::new(vec![
799            ValueWord::from_f64(1.0),
800            ValueWord::from_f64(2.0),
801        ]));
802        let delta = diff_values(&a, &b, &schemas);
803        assert_eq!(delta.changed.len(), 1);
804        assert!(delta.changed.contains_key("[1]"));
805    }
806
807    #[test]
808    fn test_diff_arrays_element_removed() {
809        let schemas = TypeSchemaRegistry::new();
810        let a = ValueWord::from_array(Arc::new(vec![
811            ValueWord::from_f64(1.0),
812            ValueWord::from_f64(2.0),
813        ]));
814        let b = ValueWord::from_array(Arc::new(vec![ValueWord::from_f64(1.0)]));
815        let delta = diff_values(&a, &b, &schemas);
816        assert_eq!(delta.removed.len(), 1);
817        assert!(delta.removed.contains(&"[1]".to_string()));
818    }
819
820    #[test]
821    fn test_patch_root_replacement() {
822        let schemas = TypeSchemaRegistry::new();
823        let base = ValueWord::from_f64(42.0);
824        let mut delta = Delta::empty();
825        delta
826            .changed
827            .insert(".".to_string(), ValueWord::from_f64(99.0));
828
829        let result = patch_value(&base, &delta, &schemas);
830        assert_eq!(result.as_f64(), Some(99.0));
831    }
832
833    #[test]
834    fn test_patch_array_element() {
835        let schemas = TypeSchemaRegistry::new();
836        let base = ValueWord::from_array(Arc::new(vec![
837            ValueWord::from_f64(1.0),
838            ValueWord::from_f64(2.0),
839        ]));
840        let mut delta = Delta::empty();
841        delta
842            .changed
843            .insert("[1]".to_string(), ValueWord::from_f64(99.0));
844
845        let result = patch_value(&base, &delta, &schemas);
846        let arr = result.as_any_array().unwrap().to_generic();
847        assert_eq!(arr[0].as_f64(), Some(1.0));
848        assert_eq!(arr[1].as_f64(), Some(99.0));
849    }
850
851    #[test]
852    fn test_parse_array_index() {
853        assert_eq!(parse_array_index("[0]"), Some(0));
854        assert_eq!(parse_array_index("[42]"), Some(42));
855        assert_eq!(parse_array_index("prefix.[3]"), Some(3));
856        assert_eq!(parse_array_index("notindex"), None);
857    }
858
859    #[test]
860    fn test_content_hash_deterministic() {
861        let schemas = TypeSchemaRegistry::new();
862        let v1 = ValueWord::from_f64(42.0);
863        let v2 = ValueWord::from_f64(42.0);
864        assert_eq!(
865            content_hash_value(&v1, &schemas),
866            content_hash_value(&v2, &schemas)
867        );
868    }
869
870    #[test]
871    fn test_content_hash_different() {
872        let schemas = TypeSchemaRegistry::new();
873        let v1 = ValueWord::from_f64(42.0);
874        let v2 = ValueWord::from_f64(99.0);
875        assert_ne!(
876            content_hash_value(&v1, &schemas),
877            content_hash_value(&v2, &schemas)
878        );
879    }
880
881    #[test]
882    fn test_nested_typed_object_diff_and_patch() {
883        use crate::type_schema::TypeSchemaBuilder;
884        use shape_value::{HeapValue, ValueSlot};
885
886        let mut schemas = TypeSchemaRegistry::new();
887
888        // Register inner type: Inner { x: f64, y: f64 }
889        let inner_id = TypeSchemaBuilder::new("Inner")
890            .f64_field("x")
891            .f64_field("y")
892            .register(&mut schemas);
893
894        // Register outer type: Outer { name: string, inner: Inner, score: f64 }
895        let outer_id = TypeSchemaBuilder::new("Outer")
896            .string_field("name")
897            .object_field("inner", "Inner")
898            .f64_field("score")
899            .register(&mut schemas);
900
901        // Build inner objects
902        let inner_old = ValueWord::from_heap_value(HeapValue::TypedObject {
903            schema_id: inner_id as u64,
904            slots: vec![
905                ValueSlot::from_number(1.0), // x = 1.0
906                ValueSlot::from_number(2.0), // y = 2.0
907            ]
908            .into_boxed_slice(),
909            heap_mask: 0,
910        });
911
912        let inner_new = ValueWord::from_heap_value(HeapValue::TypedObject {
913            schema_id: inner_id as u64,
914            slots: vec![
915                ValueSlot::from_number(1.0),  // x = 1.0 (unchanged)
916                ValueSlot::from_number(99.0), // y = 99.0 (changed)
917            ]
918            .into_boxed_slice(),
919            heap_mask: 0,
920        });
921
922        // Build outer objects
923        let name_val = Arc::new("test".to_string());
924        let old_outer = ValueWord::from_heap_value(HeapValue::TypedObject {
925            schema_id: outer_id as u64,
926            slots: vec![
927                ValueSlot::from_heap(HeapValue::String(name_val.clone())), // name
928                ValueSlot::from_heap(inner_old.as_heap_ref().unwrap().clone()), // inner
929                ValueSlot::from_number(10.0),                              // score
930            ]
931            .into_boxed_slice(),
932            heap_mask: 0b011, // slots 0 and 1 are heap
933        });
934
935        let new_outer = ValueWord::from_heap_value(HeapValue::TypedObject {
936            schema_id: outer_id as u64,
937            slots: vec![
938                ValueSlot::from_heap(HeapValue::String(name_val.clone())), // name (same)
939                ValueSlot::from_heap(inner_new.as_heap_ref().unwrap().clone()), // inner (y changed)
940                ValueSlot::from_number(10.0),                              // score (same)
941            ]
942            .into_boxed_slice(),
943            heap_mask: 0b011,
944        });
945
946        // Diff should produce a dotted path "inner.y"
947        let delta = diff_values(&old_outer, &new_outer, &schemas);
948        assert!(!delta.is_empty(), "delta should not be empty");
949        assert!(
950            delta.changed.contains_key("inner.y"),
951            "delta should contain 'inner.y', got keys: {:?}",
952            delta.changed.keys().collect::<Vec<_>>()
953        );
954        assert_eq!(delta.change_count(), 1, "only inner.y should have changed");
955
956        // Patch should correctly apply the nested change
957        let patched = patch_value(&old_outer, &delta, &schemas);
958
959        // Verify the patched outer object
960        let (patched_sid, patched_slots, patched_hm) = patched
961            .as_typed_object()
962            .expect("patched should be a TypedObject");
963        assert_eq!(patched_sid, outer_id as u64);
964
965        // name should be unchanged
966        assert_eq!(patched_hm & 1, 1, "slot 0 should be heap");
967        let patched_name = patched_slots[0].as_heap_nb();
968        assert_eq!(patched_name.as_str().unwrap(), "test");
969
970        // score should be unchanged
971        assert_eq!(
972            f64::from_bits(patched_slots[2].raw()),
973            10.0,
974            "score should be 10.0"
975        );
976
977        // inner should have y=99.0 and x=1.0
978        assert_eq!((patched_hm >> 1) & 1, 1, "slot 1 should be heap");
979        let patched_inner = patched_slots[1].as_heap_nb();
980        let (inner_sid, inner_slots, _inner_hm) = patched_inner
981            .as_typed_object()
982            .expect("inner should be a TypedObject");
983        assert_eq!(inner_sid, inner_id as u64);
984        assert_eq!(
985            f64::from_bits(inner_slots[0].raw()),
986            1.0,
987            "inner.x should be 1.0"
988        );
989        assert_eq!(
990            f64::from_bits(inner_slots[1].raw()),
991            99.0,
992            "inner.y should be 99.0"
993        );
994    }
995
996    #[test]
997    fn test_patch_direct_fields_still_work() {
998        use crate::type_schema::TypeSchemaBuilder;
999        use shape_value::{HeapValue, ValueSlot};
1000
1001        let mut schemas = TypeSchemaRegistry::new();
1002
1003        let schema_id = TypeSchemaBuilder::new("Simple")
1004            .f64_field("a")
1005            .f64_field("b")
1006            .register(&mut schemas);
1007
1008        let base = ValueWord::from_heap_value(HeapValue::TypedObject {
1009            schema_id: schema_id as u64,
1010            slots: vec![ValueSlot::from_number(1.0), ValueSlot::from_number(2.0)]
1011                .into_boxed_slice(),
1012            heap_mask: 0,
1013        });
1014
1015        // Direct field patch (no dots)
1016        let mut delta = Delta::empty();
1017        delta
1018            .changed
1019            .insert("b".to_string(), ValueWord::from_f64(42.0));
1020
1021        let patched = patch_value(&base, &delta, &schemas);
1022        let (_sid, slots, _hm) = patched.as_typed_object().unwrap();
1023        assert_eq!(f64::from_bits(slots[0].raw()), 1.0, "a unchanged");
1024        assert_eq!(f64::from_bits(slots[1].raw()), 42.0, "b patched to 42.0");
1025    }
1026
1027    #[test]
1028    fn test_nested_patch_mixed_direct_and_dotted() {
1029        use crate::type_schema::TypeSchemaBuilder;
1030        use shape_value::{HeapValue, ValueSlot};
1031
1032        let mut schemas = TypeSchemaRegistry::new();
1033
1034        let inner_id = TypeSchemaBuilder::new("MixedInner")
1035            .f64_field("val")
1036            .register(&mut schemas);
1037
1038        let outer_id = TypeSchemaBuilder::new("MixedOuter")
1039            .f64_field("score")
1040            .object_field("nested", "MixedInner")
1041            .register(&mut schemas);
1042
1043        let inner_obj = ValueWord::from_heap_value(HeapValue::TypedObject {
1044            schema_id: inner_id as u64,
1045            slots: vec![ValueSlot::from_number(5.0)].into_boxed_slice(),
1046            heap_mask: 0,
1047        });
1048
1049        let base = ValueWord::from_heap_value(HeapValue::TypedObject {
1050            schema_id: outer_id as u64,
1051            slots: vec![
1052                ValueSlot::from_number(100.0),
1053                ValueSlot::from_heap(inner_obj.as_heap_ref().unwrap().clone()),
1054            ]
1055            .into_boxed_slice(),
1056            heap_mask: 0b10, // slot 1 is heap
1057        });
1058
1059        // Delta with both a direct change and a nested dotted change
1060        let mut delta = Delta::empty();
1061        delta
1062            .changed
1063            .insert("score".to_string(), ValueWord::from_f64(200.0));
1064        delta
1065            .changed
1066            .insert("nested.val".to_string(), ValueWord::from_f64(77.0));
1067
1068        let patched = patch_value(&base, &delta, &schemas);
1069        let (_sid, slots, hm) = patched.as_typed_object().unwrap();
1070
1071        // Direct field should be patched
1072        assert_eq!(
1073            f64::from_bits(slots[0].raw()),
1074            200.0,
1075            "score should be 200.0"
1076        );
1077
1078        // Nested field should be patched
1079        assert_eq!((hm >> 1) & 1, 1, "slot 1 should be heap");
1080        let patched_inner = slots[1].as_heap_nb();
1081        let (_inner_sid, inner_slots, _) = patched_inner.as_typed_object().unwrap();
1082        assert_eq!(
1083            f64::from_bits(inner_slots[0].raw()),
1084            77.0,
1085            "nested.val should be 77.0"
1086        );
1087    }
1088
1089    // ---- HashMap diffing tests ----
1090
1091    #[test]
1092    fn test_diff_hashmaps_identical() {
1093        let schemas = TypeSchemaRegistry::new();
1094        let a = ValueWord::from_hashmap_pairs(
1095            vec![
1096                ValueWord::from_string(Arc::new("x".to_string())),
1097                ValueWord::from_string(Arc::new("y".to_string())),
1098            ],
1099            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(2.0)],
1100        );
1101        let b = ValueWord::from_hashmap_pairs(
1102            vec![
1103                ValueWord::from_string(Arc::new("x".to_string())),
1104                ValueWord::from_string(Arc::new("y".to_string())),
1105            ],
1106            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(2.0)],
1107        );
1108        let delta = diff_values(&a, &b, &schemas);
1109        assert!(delta.is_empty(), "identical hashmaps should produce empty delta");
1110    }
1111
1112    #[test]
1113    fn test_diff_hashmaps_value_changed() {
1114        let schemas = TypeSchemaRegistry::new();
1115        let a = ValueWord::from_hashmap_pairs(
1116            vec![
1117                ValueWord::from_string(Arc::new("x".to_string())),
1118                ValueWord::from_string(Arc::new("y".to_string())),
1119            ],
1120            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(2.0)],
1121        );
1122        let b = ValueWord::from_hashmap_pairs(
1123            vec![
1124                ValueWord::from_string(Arc::new("x".to_string())),
1125                ValueWord::from_string(Arc::new("y".to_string())),
1126            ],
1127            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(99.0)],
1128        );
1129        let delta = diff_values(&a, &b, &schemas);
1130        assert_eq!(delta.change_count(), 1);
1131        assert!(delta.changed.contains_key("y"));
1132    }
1133
1134    #[test]
1135    fn test_diff_hashmaps_key_added() {
1136        let schemas = TypeSchemaRegistry::new();
1137        let a = ValueWord::from_hashmap_pairs(
1138            vec![ValueWord::from_string(Arc::new("x".to_string()))],
1139            vec![ValueWord::from_f64(1.0)],
1140        );
1141        let b = ValueWord::from_hashmap_pairs(
1142            vec![
1143                ValueWord::from_string(Arc::new("x".to_string())),
1144                ValueWord::from_string(Arc::new("y".to_string())),
1145            ],
1146            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(2.0)],
1147        );
1148        let delta = diff_values(&a, &b, &schemas);
1149        assert_eq!(delta.changed.len(), 1);
1150        assert!(delta.changed.contains_key("y"));
1151        assert!(delta.removed.is_empty());
1152    }
1153
1154    #[test]
1155    fn test_diff_hashmaps_key_removed() {
1156        let schemas = TypeSchemaRegistry::new();
1157        let a = ValueWord::from_hashmap_pairs(
1158            vec![
1159                ValueWord::from_string(Arc::new("x".to_string())),
1160                ValueWord::from_string(Arc::new("y".to_string())),
1161            ],
1162            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(2.0)],
1163        );
1164        let b = ValueWord::from_hashmap_pairs(
1165            vec![ValueWord::from_string(Arc::new("x".to_string()))],
1166            vec![ValueWord::from_f64(1.0)],
1167        );
1168        let delta = diff_values(&a, &b, &schemas);
1169        assert!(delta.changed.is_empty());
1170        assert_eq!(delta.removed.len(), 1);
1171        assert!(delta.removed.contains(&"y".to_string()));
1172    }
1173
1174    #[test]
1175    fn test_diff_hashmaps_symmetric_difference() {
1176        // Tests set-like diffing: keys present in one but not the other
1177        let schemas = TypeSchemaRegistry::new();
1178        let a = ValueWord::from_hashmap_pairs(
1179            vec![
1180                ValueWord::from_string(Arc::new("a".to_string())),
1181                ValueWord::from_string(Arc::new("b".to_string())),
1182                ValueWord::from_string(Arc::new("c".to_string())),
1183            ],
1184            vec![
1185                ValueWord::from_f64(1.0),
1186                ValueWord::from_f64(2.0),
1187                ValueWord::from_f64(3.0),
1188            ],
1189        );
1190        let b = ValueWord::from_hashmap_pairs(
1191            vec![
1192                ValueWord::from_string(Arc::new("b".to_string())),
1193                ValueWord::from_string(Arc::new("c".to_string())),
1194                ValueWord::from_string(Arc::new("d".to_string())),
1195            ],
1196            vec![
1197                ValueWord::from_f64(2.0),
1198                ValueWord::from_f64(3.0),
1199                ValueWord::from_f64(4.0),
1200            ],
1201        );
1202        let delta = diff_values(&a, &b, &schemas);
1203        // "a" removed, "d" added, "b" and "c" unchanged
1204        assert_eq!(delta.removed.len(), 1);
1205        assert!(delta.removed.contains(&"a".to_string()));
1206        assert_eq!(delta.changed.len(), 1);
1207        assert!(delta.changed.contains_key("d"));
1208    }
1209
1210    #[test]
1211    fn test_diff_hashmap_with_integer_keys() {
1212        let schemas = TypeSchemaRegistry::new();
1213        let a = ValueWord::from_hashmap_pairs(
1214            vec![ValueWord::from_i64(1), ValueWord::from_i64(2)],
1215            vec![
1216                ValueWord::from_string(Arc::new("one".to_string())),
1217                ValueWord::from_string(Arc::new("two".to_string())),
1218            ],
1219        );
1220        let b = ValueWord::from_hashmap_pairs(
1221            vec![ValueWord::from_i64(1), ValueWord::from_i64(2)],
1222            vec![
1223                ValueWord::from_string(Arc::new("one".to_string())),
1224                ValueWord::from_string(Arc::new("TWO".to_string())),
1225            ],
1226        );
1227        let delta = diff_values(&a, &b, &schemas);
1228        assert_eq!(delta.change_count(), 1);
1229        // Integer key 2 should be formatted as {2}
1230        assert!(delta.changed.contains_key("{2}"));
1231    }
1232
1233    #[test]
1234    fn test_patch_hashmap_add_entry() {
1235        let schemas = TypeSchemaRegistry::new();
1236        let base = ValueWord::from_hashmap_pairs(
1237            vec![ValueWord::from_string(Arc::new("x".to_string()))],
1238            vec![ValueWord::from_f64(1.0)],
1239        );
1240        let mut delta = Delta::empty();
1241        delta
1242            .changed
1243            .insert("y".to_string(), ValueWord::from_f64(2.0));
1244
1245        let patched = patch_value(&base, &delta, &schemas);
1246        let data = patched.as_hashmap_data().expect("should be hashmap");
1247        assert_eq!(data.keys.len(), 2);
1248    }
1249
1250    #[test]
1251    fn test_patch_hashmap_remove_entry() {
1252        let schemas = TypeSchemaRegistry::new();
1253        let base = ValueWord::from_hashmap_pairs(
1254            vec![
1255                ValueWord::from_string(Arc::new("x".to_string())),
1256                ValueWord::from_string(Arc::new("y".to_string())),
1257            ],
1258            vec![ValueWord::from_f64(1.0), ValueWord::from_f64(2.0)],
1259        );
1260        let mut delta = Delta::empty();
1261        delta.removed.push("y".to_string());
1262
1263        let patched = patch_value(&base, &delta, &schemas);
1264        let data = patched.as_hashmap_data().expect("should be hashmap");
1265        assert_eq!(data.keys.len(), 1);
1266        assert!(data.find_key(&ValueWord::from_string(Arc::new("x".to_string()))).is_some());
1267    }
1268
1269    // ---- Nested array diffing tests ----
1270
1271    #[test]
1272    fn test_diff_nested_arrays_recursive() {
1273        let schemas = TypeSchemaRegistry::new();
1274        // Array of arrays: [[1, 2], [3, 4]]
1275        let inner1_old = ValueWord::from_array(Arc::new(vec![
1276            ValueWord::from_f64(1.0),
1277            ValueWord::from_f64(2.0),
1278        ]));
1279        let inner2 = ValueWord::from_array(Arc::new(vec![
1280            ValueWord::from_f64(3.0),
1281            ValueWord::from_f64(4.0),
1282        ]));
1283        let a = ValueWord::from_array(Arc::new(vec![inner1_old, inner2.clone()]));
1284
1285        // Change inner array [0][1] from 2.0 to 99.0
1286        let inner1_new = ValueWord::from_array(Arc::new(vec![
1287            ValueWord::from_f64(1.0),
1288            ValueWord::from_f64(99.0),
1289        ]));
1290        let b = ValueWord::from_array(Arc::new(vec![inner1_new, inner2]));
1291
1292        let delta = diff_values(&a, &b, &schemas);
1293        // Should recursively diff and produce [0].[1] as changed
1294        assert_eq!(delta.change_count(), 1, "only one element changed");
1295        assert!(
1296            delta.changed.contains_key("[0].[1]"),
1297            "should have path [0].[1], got keys: {:?}",
1298            delta.changed.keys().collect::<Vec<_>>()
1299        );
1300    }
1301
1302    #[test]
1303    fn test_diff_nested_array_with_object_elements() {
1304        use crate::type_schema::TypeSchemaBuilder;
1305        use shape_value::{HeapValue, ValueSlot};
1306
1307        let mut schemas = TypeSchemaRegistry::new();
1308        let point_id = TypeSchemaBuilder::new("Point")
1309            .f64_field("x")
1310            .f64_field("y")
1311            .register(&mut schemas);
1312
1313        let mk_point = |x: f64, y: f64| {
1314            ValueWord::from_heap_value(HeapValue::TypedObject {
1315                schema_id: point_id as u64,
1316                slots: vec![ValueSlot::from_number(x), ValueSlot::from_number(y)]
1317                    .into_boxed_slice(),
1318                heap_mask: 0,
1319            })
1320        };
1321
1322        let a = ValueWord::from_array(Arc::new(vec![mk_point(1.0, 2.0), mk_point(3.0, 4.0)]));
1323        let b = ValueWord::from_array(Arc::new(vec![mk_point(1.0, 2.0), mk_point(3.0, 99.0)]));
1324
1325        let delta = diff_values(&a, &b, &schemas);
1326        // Should recursively diff: [1].y changed
1327        assert_eq!(delta.change_count(), 1);
1328        assert!(
1329            delta.changed.contains_key("[1].y"),
1330            "should have path [1].y, got keys: {:?}",
1331            delta.changed.keys().collect::<Vec<_>>()
1332        );
1333    }
1334
1335    #[test]
1336    fn test_diff_hashmap_nested_value_recursive() {
1337        // HashMap with array values — changes within the array should be
1338        // detected recursively.
1339        let schemas = TypeSchemaRegistry::new();
1340
1341        let old_arr = ValueWord::from_array(Arc::new(vec![
1342            ValueWord::from_f64(1.0),
1343            ValueWord::from_f64(2.0),
1344        ]));
1345        let new_arr = ValueWord::from_array(Arc::new(vec![
1346            ValueWord::from_f64(1.0),
1347            ValueWord::from_f64(99.0),
1348        ]));
1349
1350        let a = ValueWord::from_hashmap_pairs(
1351            vec![ValueWord::from_string(Arc::new("data".to_string()))],
1352            vec![old_arr],
1353        );
1354        let b = ValueWord::from_hashmap_pairs(
1355            vec![ValueWord::from_string(Arc::new("data".to_string()))],
1356            vec![new_arr],
1357        );
1358        let delta = diff_values(&a, &b, &schemas);
1359        // Should recursively diff: data.[1] changed
1360        assert_eq!(delta.change_count(), 1);
1361        assert!(
1362            delta.changed.contains_key("data.[1]"),
1363            "should have path data.[1], got keys: {:?}",
1364            delta.changed.keys().collect::<Vec<_>>()
1365        );
1366    }
1367
1368    // ---- Path validation tests ----
1369
1370    #[test]
1371    fn test_is_valid_delta_path_root() {
1372        assert!(super::is_valid_delta_path("."));
1373    }
1374
1375    #[test]
1376    fn test_is_valid_delta_path_simple_field() {
1377        assert!(super::is_valid_delta_path("name"));
1378        assert!(super::is_valid_delta_path("field_name"));
1379    }
1380
1381    #[test]
1382    fn test_is_valid_delta_path_dotted() {
1383        assert!(super::is_valid_delta_path("a.b.c"));
1384        assert!(super::is_valid_delta_path("inner.field"));
1385    }
1386
1387    #[test]
1388    fn test_is_valid_delta_path_array_index() {
1389        assert!(super::is_valid_delta_path("[0]"));
1390        assert!(super::is_valid_delta_path("[42]"));
1391    }
1392
1393    #[test]
1394    fn test_is_valid_delta_path_rejects_empty() {
1395        assert!(!super::is_valid_delta_path(""));
1396    }
1397
1398    #[test]
1399    fn test_is_valid_delta_path_rejects_leading_dot() {
1400        assert!(!super::is_valid_delta_path(".field"));
1401    }
1402
1403    #[test]
1404    fn test_is_valid_delta_path_rejects_trailing_dot() {
1405        assert!(!super::is_valid_delta_path("field."));
1406    }
1407
1408    #[test]
1409    fn test_is_valid_delta_path_rejects_empty_segment() {
1410        assert!(!super::is_valid_delta_path("a..b"));
1411    }
1412
1413    // ---- Delta::patch() tests ----
1414
1415    #[test]
1416    fn test_delta_patch_valid_paths() {
1417        let schemas = TypeSchemaRegistry::new();
1418        let base = ValueWord::from_f64(42.0);
1419        let mut delta = Delta::empty();
1420        delta
1421            .changed
1422            .insert(".".to_string(), ValueWord::from_f64(99.0));
1423
1424        let (result, rejected) = delta.patch(&base, &schemas);
1425        assert!(rejected.is_empty());
1426        assert_eq!(result.as_f64(), Some(99.0));
1427    }
1428
1429    #[test]
1430    fn test_delta_patch_rejects_invalid_paths() {
1431        let schemas = TypeSchemaRegistry::new();
1432        let base = ValueWord::from_f64(42.0);
1433        let mut delta = Delta::empty();
1434        // Valid path
1435        delta
1436            .changed
1437            .insert(".".to_string(), ValueWord::from_f64(99.0));
1438        // Invalid paths
1439        delta
1440            .changed
1441            .insert("".to_string(), ValueWord::from_f64(1.0));
1442        delta
1443            .changed
1444            .insert("a..b".to_string(), ValueWord::from_f64(2.0));
1445        delta.removed.push(".trailing.".to_string());
1446
1447        let (result, rejected) = delta.patch(&base, &schemas);
1448        assert_eq!(rejected.len(), 3);
1449        assert!(rejected.contains(&"".to_string()));
1450        assert!(rejected.contains(&"a..b".to_string()));
1451        assert!(rejected.contains(&".trailing.".to_string()));
1452        // The valid root replacement should still apply
1453        assert_eq!(result.as_f64(), Some(99.0));
1454    }
1455}