Skip to main content

fionn_diff/
tape_patch.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Tape patch bridge - apply tape diffs to produce values
3//!
4//! This module bridges the gap between native tape diffing and value mutation.
5//! Since tapes are immutable read structures, patching works by:
6//!
7//! 1. Converting a source tape to a mutable `serde_json::Value`
8//! 2. Applying `TapeDiff` operations to the Value
9//! 3. Optionally serializing back to the original format
10//!
11//! # Performance
12//!
13//! This approach trades some overhead for compatibility:
14//! - Tape→Value conversion: ~50-100 MiB/s
15//! - Patch application: Very fast (in-memory mutations)
16//! - Value→Format: Depends on target serializer
17//!
18//! For most use cases, this is still faster than:
19//! - Parse→Value→Diff→Patch→Serialize (traditional approach)
20//!
21//! Because the diff itself is computed on tapes (250x faster for cross-format).
22//!
23//! # Example
24//!
25//! ```ignore
26//! use fionn_diff::{diff_tapes, apply_tape_diff, tape_to_value};
27//! use fionn_simd::transform::UnifiedTape;
28//! use fionn_core::format::FormatKind;
29//!
30//! // Parse two YAML documents
31//! let tape_a = UnifiedTape::parse(yaml_a.as_bytes(), FormatKind::Yaml)?;
32//! let tape_b = UnifiedTape::parse(yaml_b.as_bytes(), FormatKind::Yaml)?;
33//!
34//! // Compute diff on tapes (fast!)
35//! let diff = diff_tapes(&tape_a, &tape_b)?;
36//!
37//! // Apply diff to get patched value
38//! let mut value = tape_to_value(&tape_a)?;
39//! apply_tape_diff(&mut value, &diff)?;
40//!
41//! // value now equals what tape_b represents
42//! ```
43
44use fionn_core::Result;
45use fionn_core::tape_source::{TapeNodeKind, TapeSource, TapeValue};
46use serde_json::{Map, Value};
47
48use crate::diff_tape::{TapeDiff, TapeDiffOp, TapeValueOwned};
49
50// ============================================================================
51// Tape to Value Conversion
52// ============================================================================
53
54/// Convert a tape to a `serde_json::Value`
55///
56/// This enables applying patches to tape-parsed data since tapes are immutable.
57///
58/// # Errors
59///
60/// Returns an error if the tape structure is malformed.
61pub fn tape_to_value<T: TapeSource>(tape: &T) -> Result<Value> {
62    if tape.is_empty() {
63        return Ok(Value::Null);
64    }
65
66    let (value, _) = convert_node(tape, 0)?;
67    Ok(value)
68}
69
70/// Convert a subtree of a tape to a value
71fn convert_node<T: TapeSource>(tape: &T, idx: usize) -> Result<(Value, usize)> {
72    let node = tape.node_at(idx);
73
74    match node {
75        Some(n) => {
76            match n.kind {
77                TapeNodeKind::ObjectStart { count } => {
78                    let mut map = Map::with_capacity(count);
79                    let mut current_idx = idx + 1;
80
81                    for _ in 0..count {
82                        // Get key
83                        let key = tape
84                            .key_at(current_idx)
85                            .map(std::borrow::Cow::into_owned)
86                            .unwrap_or_default();
87                        current_idx += 1;
88
89                        // Get value
90                        let (value, next_idx) = convert_node(tape, current_idx)?;
91                        map.insert(key, value);
92                        current_idx = next_idx;
93                    }
94
95                    // No ObjectEnd marker in simd_json tape format
96                    Ok((Value::Object(map), current_idx))
97                }
98
99                TapeNodeKind::ArrayStart { count } => {
100                    let mut arr = Vec::with_capacity(count);
101                    let mut current_idx = idx + 1;
102
103                    for _ in 0..count {
104                        let (value, next_idx) = convert_node(tape, current_idx)?;
105                        arr.push(value);
106                        current_idx = next_idx;
107                    }
108
109                    // No ArrayEnd marker in simd_json tape format
110                    Ok((Value::Array(arr), current_idx))
111                }
112
113                TapeNodeKind::Value | TapeNodeKind::Key => {
114                    // Keys shouldn't be converted directly, but handle gracefully
115                    let value = n.value.map_or(Value::Null, tape_value_to_json);
116                    Ok((value, idx + 1))
117                }
118
119                TapeNodeKind::ObjectEnd | TapeNodeKind::ArrayEnd => {
120                    // End markers (for formats that have them) - return null and advance
121                    Ok((Value::Null, idx + 1))
122                }
123            }
124        }
125        None => Ok((Value::Null, idx + 1)),
126    }
127}
128
129/// Convert `TapeValue` to `serde_json::Value`
130fn tape_value_to_json(val: TapeValue<'_>) -> Value {
131    match val {
132        TapeValue::Null => Value::Null,
133        TapeValue::Bool(b) => Value::Bool(b),
134        TapeValue::Int(n) => Value::Number(n.into()),
135        TapeValue::Float(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
136        TapeValue::String(s) => Value::String(s.into_owned()),
137        TapeValue::RawNumber(s) => {
138            // Try to parse as number, fallback to string
139            #[allow(clippy::option_if_let_else)]
140            // Chained if-let-else is clearer for fallback parsing
141            if let Ok(n) = s.parse::<i64>() {
142                Value::Number(n.into())
143            } else if let Ok(f) = s.parse::<f64>() {
144                serde_json::Number::from_f64(f)
145                    .map_or_else(|| Value::String(s.into_owned()), Value::Number)
146            } else {
147                Value::String(s.into_owned())
148            }
149        }
150    }
151}
152
153// ============================================================================
154// TapeDiff Application
155// ============================================================================
156
157/// Apply a `TapeDiff` to a mutable value
158///
159/// This converts tape diff operations to value mutations.
160///
161/// # Errors
162///
163/// Returns an error if a path is invalid or an operation fails.
164pub fn apply_tape_diff(value: &mut Value, diff: &TapeDiff<'_>) -> Result<()> {
165    for op in &diff.operations {
166        apply_tape_diff_op(value, op)?;
167    }
168    Ok(())
169}
170
171/// Apply a single `TapeDiffOp` to a value
172fn apply_tape_diff_op(value: &mut Value, op: &TapeDiffOp<'_>) -> Result<()> {
173    match op {
174        TapeDiffOp::Add {
175            path,
176            value: new_value,
177        }
178        | TapeDiffOp::Replace {
179            path,
180            value: new_value,
181        } => {
182            let json_value = tape_value_owned_to_json(new_value);
183            set_at_path(value, path, json_value)?;
184        }
185
186        TapeDiffOp::Remove { path } => {
187            remove_at_path(value, path)?;
188        }
189
190        TapeDiffOp::Move { from, path } => {
191            let moved = remove_at_path(value, from)?;
192            set_at_path(value, path, moved)?;
193        }
194
195        TapeDiffOp::Copy { from, path } => {
196            let copied = get_at_path(value, from)?.clone();
197            set_at_path(value, path, copied)?;
198        }
199
200        TapeDiffOp::AddRef {
201            path,
202            tape_index: _,
203            ..
204        }
205        | TapeDiffOp::ReplaceRef {
206            path,
207            tape_index: _,
208            ..
209        } => {
210            // Ref operations require the target tape - use placeholder
211            set_at_path(value, path, Value::Null)?;
212        }
213    }
214
215    Ok(())
216}
217
218/// Convert `TapeValueOwned` to `serde_json::Value`
219fn tape_value_owned_to_json(val: &TapeValueOwned) -> Value {
220    match val {
221        TapeValueOwned::Null => Value::Null,
222        TapeValueOwned::Bool(b) => Value::Bool(*b),
223        TapeValueOwned::Int(n) => Value::Number((*n).into()),
224        TapeValueOwned::Float(f) => {
225            serde_json::Number::from_f64(*f).map_or(Value::Null, Value::Number)
226        }
227        TapeValueOwned::String(s) => Value::String(s.clone()),
228        TapeValueOwned::RawNumber(s) => {
229            #[allow(clippy::option_if_let_else)]
230            // Chained if-let-else is clearer for fallback parsing
231            if let Ok(n) = s.parse::<i64>() {
232                Value::Number(n.into())
233            } else if let Ok(f) = s.parse::<f64>() {
234                serde_json::Number::from_f64(f)
235                    .map_or_else(|| Value::String(s.clone()), Value::Number)
236            } else {
237                Value::String(s.clone())
238            }
239        }
240        TapeValueOwned::Json(json_str) => {
241            // Parse the JSON string into a Value
242            serde_json::from_str(json_str).unwrap_or(Value::Null)
243        }
244    }
245}
246
247// ============================================================================
248// Path Navigation
249// ============================================================================
250
251/// Get a value at a JSON Pointer path
252fn get_at_path<'a>(value: &'a Value, path: &str) -> Result<&'a Value> {
253    if path.is_empty() {
254        return Ok(value);
255    }
256
257    let segments = parse_json_pointer(path)?;
258    let mut current = value;
259
260    for segment in segments {
261        current = match current {
262            Value::Object(map) => map.get(&segment).ok_or_else(|| {
263                fionn_core::DsonError::InvalidField(format!("Path not found: {path}"))
264            })?,
265            Value::Array(arr) => {
266                let index: usize = segment.parse().map_err(|_| {
267                    fionn_core::DsonError::InvalidField(format!("Invalid array index: {segment}"))
268                })?;
269                arr.get(index).ok_or_else(|| {
270                    fionn_core::DsonError::InvalidField(format!("Index out of bounds: {index}"))
271                })?
272            }
273            _ => {
274                return Err(fionn_core::DsonError::InvalidField(format!(
275                    "Cannot navigate into scalar at {path}"
276                )));
277            }
278        };
279    }
280
281    Ok(current)
282}
283
284/// Set a value at a JSON Pointer path
285fn set_at_path(value: &mut Value, path: &str, new_value: Value) -> Result<()> {
286    if path.is_empty() {
287        *value = new_value;
288        return Ok(());
289    }
290
291    let segments = parse_json_pointer(path)?;
292    if segments.is_empty() {
293        *value = new_value;
294        return Ok(());
295    }
296
297    // Navigate to parent
298    let parent_segments = &segments[..segments.len() - 1];
299    let final_key = &segments[segments.len() - 1];
300
301    let mut current = value;
302    for segment in parent_segments {
303        current = match current {
304            Value::Object(map) => map
305                .entry(segment.clone())
306                .or_insert(Value::Object(Map::new())),
307            Value::Array(arr) => {
308                let index: usize = segment.parse().map_err(|_| {
309                    fionn_core::DsonError::InvalidField(format!("Invalid array index: {segment}"))
310                })?;
311                // Extend array if needed
312                while arr.len() <= index {
313                    arr.push(Value::Null);
314                }
315                arr.get_mut(index).ok_or_else(|| {
316                    fionn_core::DsonError::InvalidField(format!("Index out of bounds: {index}"))
317                })?
318            }
319            _ => {
320                return Err(fionn_core::DsonError::InvalidField(
321                    "Cannot navigate into scalar at path".to_string(),
322                ));
323            }
324        };
325    }
326
327    // Set final value
328    match current {
329        Value::Object(map) => {
330            map.insert(final_key.clone(), new_value);
331        }
332        Value::Array(arr) => {
333            if final_key == "-" {
334                arr.push(new_value);
335            } else {
336                let index: usize = final_key.parse().map_err(|_| {
337                    fionn_core::DsonError::InvalidField(format!("Invalid array index: {final_key}"))
338                })?;
339                while arr.len() <= index {
340                    arr.push(Value::Null);
341                }
342                arr[index] = new_value;
343            }
344        }
345        _ => {
346            return Err(fionn_core::DsonError::InvalidField(
347                "Cannot set value on scalar".to_string(),
348            ));
349        }
350    }
351
352    Ok(())
353}
354
355/// Remove a value at a JSON Pointer path
356fn remove_at_path(value: &mut Value, path: &str) -> Result<Value> {
357    if path.is_empty() {
358        return Err(fionn_core::DsonError::InvalidField(
359            "Cannot remove root".to_string(),
360        ));
361    }
362
363    let segments = parse_json_pointer(path)?;
364    if segments.is_empty() {
365        return Err(fionn_core::DsonError::InvalidField(
366            "Cannot remove root".to_string(),
367        ));
368    }
369
370    // Navigate to parent
371    let parent_segments = &segments[..segments.len() - 1];
372    let final_key = &segments[segments.len() - 1];
373
374    let mut current = value;
375    for segment in parent_segments {
376        current = match current {
377            Value::Object(map) => map.get_mut(segment).ok_or_else(|| {
378                fionn_core::DsonError::InvalidField(format!("Path not found: {path}"))
379            })?,
380            Value::Array(arr) => {
381                let index: usize = segment.parse().map_err(|_| {
382                    fionn_core::DsonError::InvalidField(format!("Invalid array index: {segment}"))
383                })?;
384                arr.get_mut(index).ok_or_else(|| {
385                    fionn_core::DsonError::InvalidField(format!("Index out of bounds: {index}"))
386                })?
387            }
388            _ => {
389                return Err(fionn_core::DsonError::InvalidField(format!(
390                    "Cannot navigate into scalar at {path}"
391                )));
392            }
393        };
394    }
395
396    // Remove from parent
397    match current {
398        Value::Object(map) => map.remove(final_key).ok_or_else(|| {
399            fionn_core::DsonError::InvalidField(format!("Key not found: {final_key}"))
400        }),
401        Value::Array(arr) => {
402            let index: usize = final_key.parse().map_err(|_| {
403                fionn_core::DsonError::InvalidField(format!("Invalid array index: {final_key}"))
404            })?;
405            if index >= arr.len() {
406                return Err(fionn_core::DsonError::InvalidField(format!(
407                    "Index out of bounds: {index}"
408                )));
409            }
410            Ok(arr.remove(index))
411        }
412        _ => Err(fionn_core::DsonError::InvalidField(
413            "Cannot remove from scalar".to_string(),
414        )),
415    }
416}
417
418/// Parse JSON Pointer path into segments
419fn parse_json_pointer(path: &str) -> Result<Vec<String>> {
420    if path.is_empty() {
421        return Ok(vec![]);
422    }
423
424    if !path.starts_with('/') {
425        return Err(fionn_core::DsonError::InvalidField(format!(
426            "JSON Pointer must start with '/': {path}"
427        )));
428    }
429
430    Ok(path[1..].split('/').map(unescape_json_pointer).collect())
431}
432
433/// Unescape JSON Pointer segment
434fn unescape_json_pointer(s: &str) -> String {
435    s.replace("~1", "/").replace("~0", "~")
436}
437
438// ============================================================================
439// Value to Format Serialization
440// ============================================================================
441
442/// Serialize a value back to JSON string
443#[must_use]
444pub fn value_to_json(value: &Value) -> String {
445    serde_json::to_string(value).unwrap_or_default()
446}
447
448/// Serialize a value back to pretty JSON string
449#[must_use]
450pub fn value_to_json_pretty(value: &Value) -> String {
451    serde_json::to_string_pretty(value).unwrap_or_default()
452}
453
454/// Serialize a value to YAML string (requires yaml feature)
455///
456/// # Errors
457///
458/// Returns an error if the value cannot be serialized to YAML.
459#[cfg(feature = "yaml")]
460pub fn value_to_yaml(value: &Value) -> Result<String> {
461    serde_yaml::to_string(value).map_err(|e| fionn_core::DsonError::InvalidField(e.to_string()))
462}
463
464/// Serialize a value to TOML string (requires toml feature)
465///
466/// Note: TOML requires a table at root level.
467///
468/// # Errors
469///
470/// Returns an error if the value cannot be serialized to TOML.
471#[cfg(feature = "toml")]
472pub fn value_to_toml(value: &Value) -> Result<String> {
473    // Convert to toml::Value first
474    let toml_value: toml::Value = serde_json::from_value(value.clone())
475        .map_err(|e| fionn_core::DsonError::InvalidField(e.to_string()))?;
476
477    toml::to_string(&toml_value).map_err(|e| fionn_core::DsonError::InvalidField(e.to_string()))
478}
479
480// ============================================================================
481// Full Pipeline Helpers
482// ============================================================================
483
484/// Full pipeline: apply diff from `tape_b` to value derived from `tape_a`
485///
486/// This is the common use case for cross-format patching:
487/// 1. Parse both documents as tapes
488/// 2. Compute diff between tapes (fast!)
489/// 3. Convert source tape to Value
490/// 4. Apply diff to Value
491///
492/// # Example
493///
494/// ```ignore
495/// let tape_a = UnifiedTape::parse(yaml_a.as_bytes(), FormatKind::Yaml)?;
496/// let tape_b = UnifiedTape::parse(yaml_b.as_bytes(), FormatKind::Yaml)?;
497///
498/// let diff = diff_tapes(&tape_a, &tape_b)?;
499/// let patched = patch_tape(&tape_a, &diff)?;
500/// // patched is now equivalent to tape_b's data
501/// ```
502///
503/// # Errors
504///
505/// Returns an error if the source tape cannot be converted or the diff cannot be applied.
506pub fn patch_tape<T: TapeSource>(source_tape: &T, diff: &TapeDiff<'_>) -> Result<Value> {
507    let mut value = tape_to_value(source_tape)?;
508    apply_tape_diff(&mut value, diff)?;
509    Ok(value)
510}
511
512/// Three-way patch: apply diff to a tape and return as Value
513///
514/// Takes a base tape, computes diff against target tape, applies to base.
515///
516/// # Errors
517///
518/// Returns an error if the diff cannot be computed or applied.
519pub fn three_way_patch<S: TapeSource, T: TapeSource>(base: &S, target: &T) -> Result<Value> {
520    use crate::diff_tape::diff_tapes;
521
522    let diff = diff_tapes(base, target)?;
523    patch_tape(base, &diff)
524}
525
526// ============================================================================
527// Tests
528// ============================================================================
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::diff_tape::diff_tapes;
534    use fionn_tape::DsonTape;
535
536    fn parse_json(s: &str) -> DsonTape {
537        DsonTape::parse(s).expect("valid JSON")
538    }
539
540    #[test]
541    fn test_tape_to_value_simple() {
542        let tape = parse_json(r#"{"name": "Alice", "age": 30}"#);
543        let value = tape_to_value(&tape).unwrap();
544
545        assert_eq!(value["name"], "Alice");
546        assert_eq!(value["age"], 30);
547    }
548
549    #[test]
550    fn test_tape_to_value_nested() {
551        let tape = parse_json(r#"{"user": {"name": "Alice", "profile": {"age": 30}}}"#);
552        let value = tape_to_value(&tape).unwrap();
553
554        assert_eq!(value["user"]["name"], "Alice");
555        assert_eq!(value["user"]["profile"]["age"], 30);
556    }
557
558    #[test]
559    fn test_tape_to_value_array() {
560        let tape = parse_json(r"[1, 2, 3, 4, 5]");
561        let value = tape_to_value(&tape).unwrap();
562
563        assert!(value.is_array());
564        assert_eq!(value.as_array().unwrap().len(), 5);
565        assert_eq!(value[0], 1);
566        assert_eq!(value[4], 5);
567    }
568
569    #[test]
570    fn test_tape_to_value_mixed() {
571        let tape = parse_json(r#"{"items": [{"id": 1}, {"id": 2}]}"#);
572        let value = tape_to_value(&tape).unwrap();
573
574        assert_eq!(value["items"][0]["id"], 1);
575        assert_eq!(value["items"][1]["id"], 2);
576    }
577
578    #[test]
579    fn test_apply_tape_diff_replace() {
580        let tape_a = parse_json(r#"{"name": "Alice"}"#);
581        let tape_b = parse_json(r#"{"name": "Bob"}"#);
582
583        let diff = diff_tapes(&tape_a, &tape_b).unwrap();
584        let mut value = tape_to_value(&tape_a).unwrap();
585
586        apply_tape_diff(&mut value, &diff).unwrap();
587
588        assert_eq!(value["name"], "Bob");
589    }
590
591    #[test]
592    fn test_apply_tape_diff_add() {
593        let tape_a = parse_json(r#"{"name": "Alice"}"#);
594        let tape_b = parse_json(r#"{"name": "Alice", "age": 30}"#);
595
596        let diff = diff_tapes(&tape_a, &tape_b).unwrap();
597        let mut value = tape_to_value(&tape_a).unwrap();
598
599        apply_tape_diff(&mut value, &diff).unwrap();
600
601        assert_eq!(value["name"], "Alice");
602        assert_eq!(value["age"], 30);
603    }
604
605    #[test]
606    fn test_apply_tape_diff_remove() {
607        let tape_a = parse_json(r#"{"name": "Alice", "age": 30}"#);
608        let tape_b = parse_json(r#"{"name": "Alice"}"#);
609
610        let diff = diff_tapes(&tape_a, &tape_b).unwrap();
611        let mut value = tape_to_value(&tape_a).unwrap();
612
613        apply_tape_diff(&mut value, &diff).unwrap();
614
615        assert_eq!(value["name"], "Alice");
616        assert!(value.get("age").is_none());
617    }
618
619    #[test]
620    fn test_apply_tape_diff_nested() {
621        let tape_a = parse_json(r#"{"user": {"name": "Alice"}}"#);
622        let tape_b = parse_json(r#"{"user": {"name": "Bob"}}"#);
623
624        let diff = diff_tapes(&tape_a, &tape_b).unwrap();
625        let mut value = tape_to_value(&tape_a).unwrap();
626
627        apply_tape_diff(&mut value, &diff).unwrap();
628
629        assert_eq!(value["user"]["name"], "Bob");
630    }
631
632    #[test]
633    fn test_patch_tape_helper() {
634        let tape_a = parse_json(r#"{"name": "Alice", "count": 1}"#);
635        let tape_b = parse_json(r#"{"name": "Bob", "count": 2}"#);
636
637        let diff = diff_tapes(&tape_a, &tape_b).unwrap();
638        let patched = patch_tape(&tape_a, &diff).unwrap();
639
640        assert_eq!(patched["name"], "Bob");
641        assert_eq!(patched["count"], 2);
642    }
643
644    #[test]
645    fn test_three_way_patch() {
646        let tape_a = parse_json(r#"{"version": 1}"#);
647        let tape_b = parse_json(r#"{"version": 2}"#);
648
649        let result = three_way_patch(&tape_a, &tape_b).unwrap();
650
651        assert_eq!(result["version"], 2);
652    }
653
654    #[test]
655    fn test_roundtrip_to_json() {
656        let tape = parse_json(r#"{"items": [1, 2, 3]}"#);
657        let value = tape_to_value(&tape).unwrap();
658        let json = value_to_json(&value);
659
660        // Parse back and compare
661        let reparsed: Value = serde_json::from_str(&json).unwrap();
662        assert_eq!(reparsed["items"][0], 1);
663    }
664
665    #[test]
666    fn test_parse_json_pointer() {
667        assert_eq!(parse_json_pointer("").unwrap(), Vec::<String>::new());
668        assert_eq!(parse_json_pointer("/").unwrap(), vec![""]);
669        assert_eq!(parse_json_pointer("/foo").unwrap(), vec!["foo"]);
670        assert_eq!(parse_json_pointer("/foo/bar").unwrap(), vec!["foo", "bar"]);
671        assert_eq!(parse_json_pointer("/a~1b").unwrap(), vec!["a/b"]);
672        assert_eq!(parse_json_pointer("/c~0d").unwrap(), vec!["c~d"]);
673    }
674
675    #[test]
676    fn test_set_at_path_nested() {
677        let mut value = serde_json::json!({});
678
679        set_at_path(&mut value, "/user/name", Value::String("Alice".to_string())).unwrap();
680
681        assert_eq!(value["user"]["name"], "Alice");
682    }
683
684    #[test]
685    fn test_set_at_path_array() {
686        let mut value = serde_json::json!([1, 2, 3]);
687
688        set_at_path(&mut value, "/1", Value::Number(99.into())).unwrap();
689
690        assert_eq!(value[1], 99);
691    }
692
693    #[test]
694    fn test_remove_at_path() {
695        let mut value = serde_json::json!({"a": 1, "b": 2});
696
697        let removed = remove_at_path(&mut value, "/a").unwrap();
698
699        assert_eq!(removed, 1);
700        assert!(value.get("a").is_none());
701        assert_eq!(value["b"], 2);
702    }
703}