Files
BlueArchiveToolkit/crates/bat-patch/src/text.rs
T
nyaKazuha f4880a71bd feat(patch): 补齐通用补丁引擎基础
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。

验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
2026-07-31 00:21:50 +08:00

306 lines
10 KiB
Rust

//! 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<TextPatchOperation>,
}
/// 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<String>,
/// 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<TextPatchOperation>,
) -> crate::Result<TextPatch> {
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<String> {
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<String> {
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<Vec<u8>> {
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<String> {
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(_)));
}
}