//! Deterministic UTF-8 text patch support. use crate::PatchError; use serde::{Deserialize, Serialize}; /// Current text patch schema version. pub const TEXT_PATCH_VERSION: u32 = 1; /// UTF-8 text patch made of source-relative replacement ranges. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TextPatch { /// Patch schema version. pub version: u32, /// Expected BLAKE3 hash of the source UTF-8 bytes. pub source_blake3: String, /// Expected BLAKE3 hash of the target UTF-8 bytes. pub target_blake3: String, /// Source byte length. pub source_size: u64, /// Target byte length. pub target_size: u64, /// Ordered source-relative operations. pub operations: Vec, } /// One source-relative text patch operation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum TextPatchOperation { /// Replaces a UTF-8 byte range in the original source text. ReplaceRange { /// Byte offset in the original source text. offset: u64, /// Number of source bytes to replace. length: u64, /// Optional text that must exactly match the source range. expected: Option, /// Replacement text. replacement: String, }, } /// Builds a deterministic one-hunk text patch from source and target text. pub fn diff(source: &str, target: &str) -> TextPatch { if source == target { return TextPatch { version: TEXT_PATCH_VERSION, source_blake3: blake3_hex(source.as_bytes()), target_blake3: blake3_hex(target.as_bytes()), source_size: source.len() as u64, target_size: target.len() as u64, operations: Vec::new(), }; } let prefix = common_prefix_boundary(source, target); let (source_suffix, target_suffix) = common_suffix_boundaries(source, target, prefix); let operation = TextPatchOperation::ReplaceRange { offset: prefix as u64, length: (source_suffix - prefix) as u64, expected: Some(source[prefix..source_suffix].to_string()), replacement: target[prefix..target_suffix].to_string(), }; TextPatch { version: TEXT_PATCH_VERSION, source_blake3: blake3_hex(source.as_bytes()), target_blake3: blake3_hex(target.as_bytes()), source_size: source.len() as u64, target_size: target.len() as u64, operations: vec![operation], } } /// Builds a text patch from caller-provided source-relative operations. pub fn from_operations( source: &str, operations: Vec, ) -> crate::Result { let target = apply_operations(source, &operations)?; Ok(TextPatch { version: TEXT_PATCH_VERSION, source_blake3: blake3_hex(source.as_bytes()), target_blake3: blake3_hex(target.as_bytes()), source_size: source.len() as u64, target_size: target.len() as u64, operations, }) } /// Applies a structured text patch. pub fn apply_text_patch(source: &str, patch: &TextPatch) -> crate::Result { if patch.version != TEXT_PATCH_VERSION { return Err(PatchError::ApplyFailed(format!( "unsupported text patch version {}", patch.version ))); } if patch.source_size != source.len() as u64 || patch.source_blake3 != blake3_hex(source.as_bytes()) { return Err(PatchError::ApplyFailed( "text patch source integrity mismatch".to_string(), )); } let output = apply_operations(source, &patch.operations)?; if output.len() as u64 != patch.target_size || patch.target_blake3 != blake3_hex(output.as_bytes()) { return Err(PatchError::ApplyFailed( "text patch target integrity mismatch".to_string(), )); } Ok(output) } /// Parses and applies a JSON-encoded text patch to a UTF-8 string. pub fn apply_patch(source: &str, patch: &str) -> crate::Result { let patch: TextPatch = serde_json::from_str(patch) .map_err(|error| PatchError::ApplyFailed(format!("invalid text patch JSON: {error}")))?; apply_text_patch(source, &patch) } /// Parses and applies a JSON-encoded text patch to UTF-8 bytes. pub fn apply_patch_bytes(source: &[u8], patch: &[u8]) -> crate::Result> { let source = std::str::from_utf8(source) .map_err(|error| PatchError::ApplyFailed(format!("source is not UTF-8: {error}")))?; let patch = std::str::from_utf8(patch) .map_err(|error| PatchError::ApplyFailed(format!("patch is not UTF-8: {error}")))?; Ok(apply_patch(source, patch)?.into_bytes()) } fn apply_operations(source: &str, operations: &[TextPatchOperation]) -> crate::Result { let mut output = String::with_capacity(source.len()); let mut cursor = 0usize; for operation in operations { let (offset, length, expected, replacement) = match operation { TextPatchOperation::ReplaceRange { offset, length, expected, replacement, } => (*offset, *length, expected, replacement), }; let start = usize::try_from(offset) .map_err(|_| PatchError::ApplyFailed("text patch offset overflow".to_string()))?; let length = usize::try_from(length) .map_err(|_| PatchError::ApplyFailed("text patch length overflow".to_string()))?; if start < cursor { return Err(PatchError::ApplyFailed(format!( "text patch operation at {start} overlaps previous range ending at {cursor}" ))); } let end = start .checked_add(length) .ok_or_else(|| PatchError::ApplyFailed("text patch range overflow".to_string()))?; let replaced = source.get(start..end).ok_or_else(|| { PatchError::ApplyFailed(format!( "text patch range {start}..{end} is outside the source or not UTF-8 aligned" )) })?; if let Some(expected) = expected { if replaced != expected { return Err(PatchError::ApplyFailed(format!( "text patch expected mismatch at {start}..{end}" ))); } } output.push_str(&source[cursor..start]); output.push_str(replacement); cursor = end; } output.push_str(&source[cursor..]); Ok(output) } fn common_prefix_boundary(source: &str, target: &str) -> usize { let mut prefix = 0usize; for ((source_index, source_char), (target_index, target_char)) in source.char_indices().zip(target.char_indices()) { if source_index != target_index || source_char != target_char { break; } prefix = source_index + source_char.len_utf8(); } prefix } fn common_suffix_boundaries(source: &str, target: &str, prefix: usize) -> (usize, usize) { let mut source_suffix = source.len(); let mut target_suffix = target.len(); let mut source_chars = source[prefix..].char_indices().rev(); let mut target_chars = target[prefix..].char_indices().rev(); while let (Some((source_index, source_char)), Some((target_index, target_char))) = (source_chars.next(), target_chars.next()) { if source_char != target_char { break; } source_suffix = prefix + source_index; target_suffix = prefix + target_index; } (source_suffix, target_suffix) } fn blake3_hex(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() } #[cfg(test)] mod tests { use super::*; #[test] fn text_patch_round_trips_unicode_change() { let source = "先生、こんにちは\nAbydos"; let target = "老师、你好\nAbydos"; let patch = diff(source, target); let patch_json = serde_json::to_string(&patch).unwrap(); assert_eq!(apply_text_patch(source, &patch).unwrap(), target); assert_eq!(apply_patch(source, &patch_json).unwrap(), target); assert_eq!( apply_patch_bytes(source.as_bytes(), patch_json.as_bytes()).unwrap(), target.as_bytes() ); } #[test] fn text_patch_applies_multiple_source_relative_ranges() { let source = "alpha beta gamma"; let patch = from_operations( source, vec![ TextPatchOperation::ReplaceRange { offset: 0, length: 5, expected: Some("alpha".to_string()), replacement: "one".to_string(), }, TextPatchOperation::ReplaceRange { offset: 11, length: 5, expected: Some("gamma".to_string()), replacement: "three".to_string(), }, ], ) .unwrap(); assert_eq!(apply_text_patch(source, &patch).unwrap(), "one beta three"); } #[test] fn text_patch_rejects_expected_mismatch() { let source = "alpha beta"; let operation = TextPatchOperation::ReplaceRange { offset: 0, length: 5, expected: Some("wrong".to_string()), replacement: "one".to_string(), }; let error = from_operations(source, vec![operation]).unwrap_err(); assert!(matches!(error, PatchError::ApplyFailed(_))); } #[test] fn text_patch_rejects_overlapping_ranges() { let source = "alpha beta"; let operation_a = TextPatchOperation::ReplaceRange { offset: 0, length: 5, expected: None, replacement: "one".to_string(), }; let operation_b = TextPatchOperation::ReplaceRange { offset: 3, length: 2, expected: None, replacement: "two".to_string(), }; let error = from_operations(source, vec![operation_a, operation_b]).unwrap_err(); assert!(matches!(error, PatchError::ApplyFailed(_))); } #[test] fn text_patch_rejects_non_boundary_range() { let source = "éclair"; let operation = TextPatchOperation::ReplaceRange { offset: 1, length: 1, expected: None, replacement: "e".to_string(), }; let error = from_operations(source, vec![operation]).unwrap_err(); assert!(matches!(error, PatchError::ApplyFailed(_))); } }