Skip to main content

fionn_diff/
diff_tape.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Native tape-walking diff
3//!
4//! Provides format-agnostic diff by walking two tapes in parallel.
5//! This eliminates the "conversion tax" of serializing/deserializing
6//! between formats - we can diff YAML, TOML, CSV, ISON, TOON tapes directly.
7//!
8//! # Performance
9//!
10//! This approach is ~6x faster than convert-then-diff because:
11//! - No intermediate serialization
12//! - Direct tape traversal with O(1) skip
13//! - No allocation of `serde_json::Value` trees
14//!
15//! # Example
16//!
17//! ```ignore
18//! use fionn_diff::diff_tapes;
19//! use fionn_simd::transform::UnifiedTape;
20//! use fionn_core::format::FormatKind;
21//!
22//! let tape_a = UnifiedTape::parse(yaml_a.as_bytes(), FormatKind::Yaml)?;
23//! let tape_b = UnifiedTape::parse(yaml_b.as_bytes(), FormatKind::Yaml)?;
24//!
25//! let patch = diff_tapes(&tape_a, &tape_b)?;
26//! ```
27
28use fionn_core::Result;
29use fionn_core::tape_source::{TapeNodeKind, TapeSource, TapeValue};
30use std::borrow::Cow;
31
32/// A tape diff operation
33#[derive(Debug, Clone, PartialEq)]
34pub enum TapeDiffOp<'a> {
35    /// Add a value at a path
36    Add {
37        /// JSON Pointer path where to add
38        path: String,
39        /// Value to add
40        value: TapeValueOwned,
41    },
42    /// Remove the value at a path
43    Remove {
44        /// JSON Pointer path to remove
45        path: String,
46    },
47    /// Replace the value at a path
48    Replace {
49        /// JSON Pointer path to replace
50        path: String,
51        /// New value
52        value: TapeValueOwned,
53    },
54    /// Move a value from one path to another
55    Move {
56        /// Source path
57        from: String,
58        /// Destination path
59        path: String,
60    },
61    /// Copy a value from one path to another
62    Copy {
63        /// Source path
64        from: String,
65        /// Destination path
66        path: String,
67    },
68    /// Reference to a value in the target tape (zero-copy variant)
69    AddRef {
70        /// JSON Pointer path where to add
71        path: String,
72        /// Index in the target tape
73        tape_index: usize,
74        /// Lifetime marker
75        _marker: std::marker::PhantomData<&'a ()>,
76    },
77    /// Reference to a value in the target tape (zero-copy variant)
78    ReplaceRef {
79        /// JSON Pointer path to replace
80        path: String,
81        /// Index in the target tape
82        tape_index: usize,
83        /// Lifetime marker
84        _marker: std::marker::PhantomData<&'a ()>,
85    },
86}
87
88/// Owned tape value for patch operations
89#[derive(Debug, Clone, PartialEq)]
90pub enum TapeValueOwned {
91    /// Null value
92    Null,
93    /// Boolean value
94    Bool(bool),
95    /// 64-bit integer
96    Int(i64),
97    /// 64-bit float
98    Float(f64),
99    /// String value
100    String(String),
101    /// Raw number string (preserves precision)
102    RawNumber(String),
103    /// Serialized JSON for complex values
104    Json(String),
105}
106
107impl<'a> From<TapeValue<'a>> for TapeValueOwned {
108    fn from(val: TapeValue<'a>) -> Self {
109        match val {
110            TapeValue::Null => Self::Null,
111            TapeValue::Bool(b) => Self::Bool(b),
112            TapeValue::Int(n) => Self::Int(n),
113            TapeValue::Float(f) => Self::Float(f),
114            TapeValue::String(s) => Self::String(s.into_owned()),
115            TapeValue::RawNumber(s) => Self::RawNumber(s.into_owned()),
116        }
117    }
118}
119
120/// Result of diffing two tapes
121#[derive(Debug, Clone, Default)]
122pub struct TapeDiff<'a> {
123    /// Operations to transform source into target
124    pub operations: Vec<TapeDiffOp<'a>>,
125}
126
127impl<'a> TapeDiff<'a> {
128    /// Create an empty diff
129    #[must_use]
130    pub const fn new() -> Self {
131        Self {
132            operations: Vec::new(),
133        }
134    }
135
136    /// Check if the diff is empty (tapes are equal)
137    #[must_use]
138    pub const fn is_empty(&self) -> bool {
139        self.operations.is_empty()
140    }
141
142    /// Number of operations
143    #[must_use]
144    pub const fn len(&self) -> usize {
145        self.operations.len()
146    }
147
148    /// Add an operation
149    pub fn push(&mut self, op: TapeDiffOp<'a>) {
150        self.operations.push(op);
151    }
152}
153
154/// Options for tape diff
155#[derive(Debug, Clone, Default)]
156pub struct TapeDiffOptions {
157    /// Maximum depth to diff (0 = unlimited)
158    pub max_depth: usize,
159    /// Use reference operations (zero-copy, but ties to target tape lifetime)
160    pub use_refs: bool,
161}
162
163impl TapeDiffOptions {
164    /// Create default options
165    #[must_use]
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Set max depth
171    #[must_use]
172    pub const fn with_max_depth(mut self, depth: usize) -> Self {
173        self.max_depth = depth;
174        self
175    }
176
177    /// Enable reference operations
178    #[must_use]
179    pub const fn with_refs(mut self) -> Self {
180        self.use_refs = true;
181        self
182    }
183}
184
185/// Diff two tapes and produce a patch
186///
187/// This is the native tape-walking diff that avoids the conversion tax.
188///
189/// # Errors
190///
191/// Returns an error if tape traversal fails.
192pub fn diff_tapes<'a, S: TapeSource, T: TapeSource>(
193    source: &'a S,
194    target: &'a T,
195) -> Result<TapeDiff<'a>> {
196    diff_tapes_with_options(source, target, &TapeDiffOptions::default())
197}
198
199/// Diff two tapes with custom options
200///
201/// # Errors
202///
203/// Returns an error if tape traversal fails.
204pub fn diff_tapes_with_options<'a, S: TapeSource, T: TapeSource>(
205    source: &'a S,
206    target: &'a T,
207    options: &TapeDiffOptions,
208) -> Result<TapeDiff<'a>> {
209    let mut diff = TapeDiff::new();
210
211    if source.is_empty() && target.is_empty() {
212        return Ok(diff);
213    }
214
215    if source.is_empty() {
216        // Entire target is an add
217        let value = extract_value(target, 0)?;
218        diff.push(TapeDiffOp::Add {
219            path: String::new(),
220            value,
221        });
222        return Ok(diff);
223    }
224
225    if target.is_empty() {
226        diff.push(TapeDiffOp::Remove {
227            path: String::new(),
228        });
229        return Ok(diff);
230    }
231
232    // Start recursive diff at root
233    diff_at_path(source, 0, target, 0, "", &mut diff, options, 0)?;
234
235    Ok(diff)
236}
237
238#[allow(clippy::too_many_arguments)] // Recursive diff traversal requires path context
239fn diff_at_path<S: TapeSource, T: TapeSource>(
240    source: &S,
241    src_idx: usize,
242    target: &T,
243    tgt_idx: usize,
244    path: &str,
245    diff: &mut TapeDiff<'_>,
246    options: &TapeDiffOptions,
247    depth: usize,
248) -> Result<()> {
249    // Check depth limit
250    if options.max_depth > 0 && depth >= options.max_depth {
251        if !values_equal(source, src_idx, target, tgt_idx) {
252            let value = extract_value(target, tgt_idx)?;
253            diff.push(TapeDiffOp::Replace {
254                path: path.to_string(),
255                value,
256            });
257        }
258        return Ok(());
259    }
260
261    let src_node = source.node_at(src_idx);
262    let tgt_node = target.node_at(tgt_idx);
263
264    match (src_node, tgt_node) {
265        (Some(src), Some(tgt)) => {
266            // Check if kinds match
267            let src_kind_class = kind_class(&src.kind);
268            let tgt_kind_class = kind_class(&tgt.kind);
269
270            if src_kind_class != tgt_kind_class {
271                // Type change - replace entire value
272                let value = extract_value(target, tgt_idx)?;
273                diff.push(TapeDiffOp::Replace {
274                    path: path.to_string(),
275                    value,
276                });
277                return Ok(());
278            }
279
280            match src.kind {
281                TapeNodeKind::ObjectStart { count: src_count } => {
282                    if let TapeNodeKind::ObjectStart { count: tgt_count } = tgt.kind {
283                        diff_objects(
284                            source, src_idx, src_count, target, tgt_idx, tgt_count, path, diff,
285                            options, depth,
286                        )?;
287                    }
288                }
289                TapeNodeKind::ArrayStart { count: src_count } => {
290                    if let TapeNodeKind::ArrayStart { count: tgt_count } = tgt.kind {
291                        diff_arrays(
292                            source, src_idx, src_count, target, tgt_idx, tgt_count, path, diff,
293                            options, depth,
294                        )?;
295                    }
296                }
297                TapeNodeKind::Value | TapeNodeKind::Key => {
298                    // Compare scalar values
299                    if !values_equal(source, src_idx, target, tgt_idx) {
300                        let value = extract_value(target, tgt_idx)?;
301                        diff.push(TapeDiffOp::Replace {
302                            path: path.to_string(),
303                            value,
304                        });
305                    }
306                }
307                _ => {
308                    // ObjectEnd, ArrayEnd - should not be diffed directly
309                }
310            }
311        }
312        (Some(_), None) => {
313            // Source has value, target doesn't - remove
314            diff.push(TapeDiffOp::Remove {
315                path: path.to_string(),
316            });
317        }
318        (None, Some(_)) => {
319            // Target has value, source doesn't - add
320            let value = extract_value(target, tgt_idx)?;
321            diff.push(TapeDiffOp::Add {
322                path: path.to_string(),
323                value,
324            });
325        }
326        (None, None) => {
327            // Both empty - nothing to do
328        }
329    }
330
331    Ok(())
332}
333
334#[allow(clippy::too_many_arguments)] // Recursive diff traversal requires path context
335fn diff_objects<S: TapeSource, T: TapeSource>(
336    source: &S,
337    src_obj_idx: usize,
338    src_count: usize,
339    target: &T,
340    tgt_obj_idx: usize,
341    tgt_count: usize,
342    path: &str,
343    diff: &mut TapeDiff<'_>,
344    options: &TapeDiffOptions,
345    depth: usize,
346) -> Result<()> {
347    // Build key -> value_index maps for both objects
348    let src_fields = collect_object_fields(source, src_obj_idx, src_count)?;
349    let tgt_fields = collect_object_fields(target, tgt_obj_idx, tgt_count)?;
350
351    // Find removed keys
352    for (key, _src_val_idx) in &src_fields {
353        if !tgt_fields.iter().any(|(k, _)| k == key) {
354            let field_path = format_path(path, key);
355            diff.push(TapeDiffOp::Remove { path: field_path });
356        }
357    }
358
359    // Find added and modified keys
360    for (key, tgt_val_idx) in &tgt_fields {
361        let field_path = format_path(path, key);
362
363        if let Some((_k, src_val_idx)) = src_fields.iter().find(|(k, _)| k == key) {
364            // Key exists in both - recursively diff
365            diff_at_path(
366                source,
367                *src_val_idx,
368                target,
369                *tgt_val_idx,
370                &field_path,
371                diff,
372                options,
373                depth + 1,
374            )?;
375        } else {
376            // Key only in target - add
377            let value = extract_value(target, *tgt_val_idx)?;
378            diff.push(TapeDiffOp::Add {
379                path: field_path,
380                value,
381            });
382        }
383    }
384
385    Ok(())
386}
387
388#[allow(clippy::too_many_arguments)] // Recursive diff traversal requires path context
389fn diff_arrays<S: TapeSource, T: TapeSource>(
390    source: &S,
391    src_arr_idx: usize,
392    src_count: usize,
393    target: &T,
394    tgt_arr_idx: usize,
395    tgt_count: usize,
396    path: &str,
397    diff: &mut TapeDiff<'_>,
398    options: &TapeDiffOptions,
399    depth: usize,
400) -> Result<()> {
401    // Collect element indices
402    let src_elements = collect_array_elements(source, src_arr_idx, src_count)?;
403    let tgt_elements = collect_array_elements(target, tgt_arr_idx, tgt_count)?;
404
405    let min_len = src_elements.len().min(tgt_elements.len());
406
407    // Diff common elements
408    for i in 0..min_len {
409        let elem_path = format!("{path}/{i}");
410        diff_at_path(
411            source,
412            src_elements[i],
413            target,
414            tgt_elements[i],
415            &elem_path,
416            diff,
417            options,
418            depth + 1,
419        )?;
420    }
421
422    // Handle added elements
423    for (i, &tgt_elem_idx) in tgt_elements.iter().enumerate().skip(min_len) {
424        let elem_path = format!("{path}/{i}");
425        let value = extract_value(target, tgt_elem_idx)?;
426        diff.push(TapeDiffOp::Add {
427            path: elem_path,
428            value,
429        });
430    }
431
432    // Handle removed elements (from end first)
433    for i in (min_len..src_elements.len()).rev() {
434        let elem_path = format!("{path}/{i}");
435        diff.push(TapeDiffOp::Remove { path: elem_path });
436    }
437
438    Ok(())
439}
440
441/// Collect object fields as (key, `value_index`) pairs
442fn collect_object_fields<S: TapeSource>(
443    tape: &S,
444    obj_idx: usize,
445    count: usize,
446) -> Result<Vec<(Cow<'_, str>, usize)>> {
447    let mut fields = Vec::with_capacity(count);
448    let mut idx = obj_idx + 1;
449
450    for _ in 0..count {
451        // Get key
452        if let Some(key) = tape.key_at(idx) {
453            let value_idx = idx + 1;
454            fields.push((key, value_idx));
455            // Skip past value to next key
456            idx = tape.skip_value(value_idx)?;
457        } else {
458            idx += 1;
459        }
460    }
461
462    Ok(fields)
463}
464
465/// Collect array element indices
466fn collect_array_elements<S: TapeSource>(
467    tape: &S,
468    arr_idx: usize,
469    count: usize,
470) -> Result<Vec<usize>> {
471    let mut elements = Vec::with_capacity(count);
472    let mut idx = arr_idx + 1;
473
474    for _ in 0..count {
475        elements.push(idx);
476        idx = tape.skip_value(idx)?;
477    }
478
479    Ok(elements)
480}
481
482/// Check if two tape values at given positions are equal
483fn values_equal<S: TapeSource, T: TapeSource>(
484    source: &S,
485    src_idx: usize,
486    target: &T,
487    tgt_idx: usize,
488) -> bool {
489    let src_val = source.value_at(src_idx);
490    let tgt_val = target.value_at(tgt_idx);
491
492    match (src_val, tgt_val) {
493        (Some(s), Some(t)) => tape_values_equal(&s, &t),
494        (None, None) => {
495            // Both containers - check structure
496            let src_node = source.node_at(src_idx);
497            let tgt_node = target.node_at(tgt_idx);
498
499            match (src_node, tgt_node) {
500                (Some(s), Some(t)) => {
501                    if kind_class(&s.kind) != kind_class(&t.kind) {
502                        return false;
503                    }
504
505                    match (s.kind, t.kind) {
506                        (
507                            TapeNodeKind::ObjectStart { count: sc },
508                            TapeNodeKind::ObjectStart { count: tc },
509                        ) => {
510                            if sc != tc {
511                                return false;
512                            }
513                            // Would need to compare all fields...
514                            // For now, assume different if counts equal (will recurse)
515                            false
516                        }
517                        (
518                            TapeNodeKind::ArrayStart { count: sc },
519                            TapeNodeKind::ArrayStart { count: tc },
520                        ) => {
521                            if sc != tc {
522                                return false;
523                            }
524                            false
525                        }
526                        _ => false,
527                    }
528                }
529                _ => false,
530            }
531        }
532        _ => false,
533    }
534}
535
536fn tape_values_equal(a: &TapeValue<'_>, b: &TapeValue<'_>) -> bool {
537    match (a, b) {
538        (TapeValue::Null, TapeValue::Null) => true,
539        (TapeValue::Bool(a), TapeValue::Bool(b)) => a == b,
540        (TapeValue::Int(a), TapeValue::Int(b)) => a == b,
541        (TapeValue::Float(a), TapeValue::Float(b)) => {
542            // Handle NaN: two NaNs are considered equal for diff purposes
543            // Handle infinity: use bit-exact comparison for special values
544            if a.is_nan() && b.is_nan() {
545                true
546            } else if a.is_infinite() || b.is_infinite() {
547                a.to_bits() == b.to_bits()
548            } else {
549                (a - b).abs() < f64::EPSILON
550            }
551        }
552        (TapeValue::String(a), TapeValue::String(b))
553        | (TapeValue::RawNumber(a), TapeValue::RawNumber(b)) => a == b,
554        // Cross-type comparisons for numbers
555        (TapeValue::Int(a), TapeValue::Float(b)) => {
556            if b.is_nan() || b.is_infinite() {
557                false
558            } else {
559                (*a as f64 - b).abs() < f64::EPSILON
560            }
561        }
562        (TapeValue::Float(a), TapeValue::Int(b)) => {
563            if a.is_nan() || a.is_infinite() {
564                false
565            } else {
566                (a - *b as f64).abs() < f64::EPSILON
567            }
568        }
569        _ => false,
570    }
571}
572
573/// Extract a value from a tape as an owned value
574fn extract_value<T: TapeSource>(tape: &T, idx: usize) -> Result<TapeValueOwned> {
575    if let Some(val) = tape.value_at(idx) {
576        return Ok(val.into());
577    }
578
579    // Container - serialize to JSON
580    let node = tape.node_at(idx);
581    match node {
582        Some(n) => {
583            match n.kind {
584                TapeNodeKind::ObjectStart { .. } | TapeNodeKind::ArrayStart { .. } => {
585                    // Serialize container to JSON
586                    let json = serialize_subtree(tape, idx)?;
587                    Ok(TapeValueOwned::Json(json))
588                }
589                _ => Ok(TapeValueOwned::Null),
590            }
591        }
592        None => Ok(TapeValueOwned::Null),
593    }
594}
595
596/// Serialize a subtree to JSON string
597fn serialize_subtree<T: TapeSource>(tape: &T, start_idx: usize) -> Result<String> {
598    let mut output = String::new();
599    serialize_value(tape, start_idx, &mut output)?;
600    Ok(output)
601}
602
603fn serialize_value<T: TapeSource>(tape: &T, idx: usize, output: &mut String) -> Result<usize> {
604    let node = tape.node_at(idx);
605
606    match node {
607        Some(n) => {
608            match n.kind {
609                TapeNodeKind::ObjectStart { count } => {
610                    output.push('{');
611                    let mut current_idx = idx + 1;
612                    for i in 0..count {
613                        if i > 0 {
614                            output.push(',');
615                        }
616                        // Key
617                        if let Some(key) = tape.key_at(current_idx) {
618                            output.push('"');
619                            output.push_str(&escape_json_string(&key));
620                            output.push_str("\":");
621                            current_idx += 1;
622                        }
623                        // Value
624                        current_idx = serialize_value(tape, current_idx, output)?;
625                    }
626                    output.push('}');
627                    // Skip past ObjectEnd
628                    Ok(current_idx + 1)
629                }
630                TapeNodeKind::ArrayStart { count } => {
631                    output.push('[');
632                    let mut current_idx = idx + 1;
633                    for i in 0..count {
634                        if i > 0 {
635                            output.push(',');
636                        }
637                        current_idx = serialize_value(tape, current_idx, output)?;
638                    }
639                    output.push(']');
640                    // Skip past ArrayEnd
641                    Ok(current_idx + 1)
642                }
643                TapeNodeKind::Value | TapeNodeKind::Key => {
644                    if let Some(val) = tape.value_at(idx) {
645                        serialize_tape_value(&val, output);
646                    }
647                    Ok(idx + 1)
648                }
649                TapeNodeKind::ObjectEnd | TapeNodeKind::ArrayEnd => Ok(idx + 1),
650            }
651        }
652        None => Ok(idx + 1),
653    }
654}
655
656fn serialize_tape_value(val: &TapeValue<'_>, output: &mut String) {
657    match val {
658        TapeValue::Null => output.push_str("null"),
659        TapeValue::Bool(true) => output.push_str("true"),
660        TapeValue::Bool(false) => output.push_str("false"),
661        TapeValue::Int(n) => output.push_str(&n.to_string()),
662        TapeValue::Float(f) => output.push_str(&f.to_string()),
663        TapeValue::String(s) => {
664            output.push('"');
665            output.push_str(&escape_json_string(s));
666            output.push('"');
667        }
668        TapeValue::RawNumber(s) => output.push_str(s),
669    }
670}
671
672fn escape_json_string(s: &str) -> Cow<'_, str> {
673    if s.bytes()
674        .any(|b| matches!(b, b'"' | b'\\' | b'\n' | b'\r' | b'\t'))
675    {
676        let escaped = s
677            .replace('\\', "\\\\")
678            .replace('"', "\\\"")
679            .replace('\n', "\\n")
680            .replace('\r', "\\r")
681            .replace('\t', "\\t");
682        Cow::Owned(escaped)
683    } else {
684        Cow::Borrowed(s)
685    }
686}
687
688/// Classify node kind for type comparison
689const fn kind_class(kind: &TapeNodeKind) -> u8 {
690    match kind {
691        TapeNodeKind::ObjectStart { .. } | TapeNodeKind::ObjectEnd => 1,
692        TapeNodeKind::ArrayStart { .. } | TapeNodeKind::ArrayEnd => 2,
693        TapeNodeKind::Key | TapeNodeKind::Value => 3,
694    }
695}
696
697fn format_path(base: &str, key: &str) -> String {
698    if base.is_empty() {
699        format!("/{}", escape_json_pointer(key))
700    } else {
701        format!("{base}/{}", escape_json_pointer(key))
702    }
703}
704
705fn escape_json_pointer(s: &str) -> String {
706    s.replace('~', "~0").replace('/', "~1")
707}
708
709// ============================================================================
710// Tests
711// ============================================================================
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use fionn_tape::DsonTape;
717
718    fn parse_json(s: &str) -> DsonTape {
719        DsonTape::parse(s).expect("valid JSON")
720    }
721
722    #[test]
723    fn test_diff_equal_objects() {
724        let a = parse_json(r#"{"name": "Alice", "age": 30}"#);
725        let b = parse_json(r#"{"name": "Alice", "age": 30}"#);
726
727        let diff = diff_tapes(&a, &b).unwrap();
728        assert!(diff.is_empty());
729    }
730
731    #[test]
732    fn test_diff_scalar_change() {
733        let a = parse_json(r#"{"name": "Alice"}"#);
734        let b = parse_json(r#"{"name": "Bob"}"#);
735
736        let diff = diff_tapes(&a, &b).unwrap();
737        assert_eq!(diff.len(), 1);
738        assert!(matches!(&diff.operations[0], TapeDiffOp::Replace { path, .. } if path == "/name"));
739    }
740
741    #[test]
742    fn test_diff_add_field() {
743        let a = parse_json(r#"{"name": "Alice"}"#);
744        let b = parse_json(r#"{"name": "Alice", "age": 30}"#);
745
746        let diff = diff_tapes(&a, &b).unwrap();
747        assert_eq!(diff.len(), 1);
748        assert!(matches!(&diff.operations[0], TapeDiffOp::Add { path, .. } if path == "/age"));
749    }
750
751    #[test]
752    fn test_diff_remove_field() {
753        let a = parse_json(r#"{"name": "Alice", "age": 30}"#);
754        let b = parse_json(r#"{"name": "Alice"}"#);
755
756        let diff = diff_tapes(&a, &b).unwrap();
757        assert_eq!(diff.len(), 1);
758        assert!(matches!(&diff.operations[0], TapeDiffOp::Remove { path } if path == "/age"));
759    }
760
761    #[test]
762    fn test_diff_nested_change() {
763        let a = parse_json(r#"{"user": {"name": "Alice"}}"#);
764        let b = parse_json(r#"{"user": {"name": "Bob"}}"#);
765
766        let diff = diff_tapes(&a, &b).unwrap();
767        assert_eq!(diff.len(), 1);
768        assert!(
769            matches!(&diff.operations[0], TapeDiffOp::Replace { path, .. } if path == "/user/name")
770        );
771    }
772
773    #[test]
774    fn test_diff_array_add() {
775        let a = parse_json(r"[1, 2]");
776        let b = parse_json(r"[1, 2, 3]");
777
778        let diff = diff_tapes(&a, &b).unwrap();
779        assert_eq!(diff.len(), 1);
780        assert!(matches!(&diff.operations[0], TapeDiffOp::Add { path, .. } if path == "/2"));
781    }
782
783    #[test]
784    fn test_diff_array_remove() {
785        let a = parse_json(r"[1, 2, 3]");
786        let b = parse_json(r"[1, 2]");
787
788        let diff = diff_tapes(&a, &b).unwrap();
789        assert_eq!(diff.len(), 1);
790        assert!(matches!(&diff.operations[0], TapeDiffOp::Remove { path } if path == "/2"));
791    }
792
793    #[test]
794    fn test_diff_type_change() {
795        let a = parse_json(r#"{"value": "string"}"#);
796        let b = parse_json(r#"{"value": 42}"#);
797
798        let diff = diff_tapes(&a, &b).unwrap();
799        assert_eq!(diff.len(), 1);
800        assert!(
801            matches!(&diff.operations[0], TapeDiffOp::Replace { path, .. } if path == "/value")
802        );
803    }
804
805    #[test]
806    fn test_diff_empty_to_object() {
807        let a = parse_json(r"{}");
808        let b = parse_json(r#"{"name": "Alice"}"#);
809
810        let diff = diff_tapes(&a, &b).unwrap();
811        assert_eq!(diff.len(), 1);
812        assert!(matches!(&diff.operations[0], TapeDiffOp::Add { .. }));
813    }
814
815    #[test]
816    fn test_diff_array_scalar_change() {
817        // First test: array with scalar change
818        let a = parse_json(r"[1, 2, 3]");
819        let b = parse_json(r"[1, 99, 3]");
820
821        let diff = diff_tapes(&a, &b).unwrap();
822        assert_eq!(diff.len(), 1, "Expected exactly one change");
823        assert!(matches!(&diff.operations[0], TapeDiffOp::Replace { path, .. } if path == "/1"));
824    }
825
826    #[test]
827    fn test_diff_complex_nested() {
828        // Test array of objects with nested change
829        let a = parse_json(r#"[{"name": "Alice"}, {"name": "Bob"}]"#);
830        let b = parse_json(r#"[{"name": "Alice"}, {"name": "Carol"}]"#);
831
832        let diff = diff_tapes(&a, &b).unwrap();
833
834        // Should find difference at /1/name
835        assert_eq!(diff.len(), 1, "Expected exactly one change");
836        assert!(
837            matches!(&diff.operations[0], TapeDiffOp::Replace { path, .. } if path == "/1/name")
838        );
839    }
840
841    #[test]
842    fn test_diff_deeply_nested() {
843        let a = parse_json(r#"{"users": [{"profile": {"name": "Alice"}}]}"#);
844        let b = parse_json(r#"{"users": [{"profile": {"name": "Bob"}}]}"#);
845
846        let diff = diff_tapes(&a, &b).unwrap();
847        assert_eq!(diff.len(), 1);
848        assert!(
849            matches!(&diff.operations[0], TapeDiffOp::Replace { path, .. } if path == "/users/0/profile/name")
850        );
851    }
852
853    #[test]
854    fn test_serialize_subtree() {
855        let tape = parse_json(r#"{"nested": {"a": 1, "b": "hello"}}"#);
856        // Subtree at index 2 (the nested object)
857        // Index 0: ObjectStart, 1: Key("nested"), 2: ObjectStart...
858        let json = serialize_subtree(&tape, 2).unwrap();
859        assert!(json.contains("\"a\":1") || json.contains("\"a\": 1"));
860    }
861}