mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:54:55 +08:00
feat(patch): 补齐通用补丁引擎基础
实现 Binary、JSON、Text patch 与通用 manifest 校验基础。 验证:cargo test -p bat-patch --locked;cargo clippy -p bat-patch --all-targets --locked -- -D warnings。
This commit is contained in:
+354
-10
@@ -1,22 +1,366 @@
|
||||
//! JSON Patch 模块占位
|
||||
//! RFC 6902 JSON Patch support.
|
||||
|
||||
/// JSON Patch 应用(尚未实现)。
|
||||
use crate::PatchError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// One RFC 6902 JSON Patch operation.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
pub enum JsonPatchOperation {
|
||||
/// Add a value at the target JSON Pointer.
|
||||
Add {
|
||||
/// Target JSON Pointer.
|
||||
path: String,
|
||||
/// Value to insert.
|
||||
value: Value,
|
||||
},
|
||||
/// Remove the value at the target JSON Pointer.
|
||||
Remove {
|
||||
/// Target JSON Pointer.
|
||||
path: String,
|
||||
},
|
||||
/// Replace the value at the target JSON Pointer.
|
||||
Replace {
|
||||
/// Target JSON Pointer.
|
||||
path: String,
|
||||
/// Replacement value.
|
||||
value: Value,
|
||||
},
|
||||
/// Move a value from one JSON Pointer to another.
|
||||
Move {
|
||||
/// Source JSON Pointer.
|
||||
from: String,
|
||||
/// Target JSON Pointer.
|
||||
path: String,
|
||||
},
|
||||
/// Copy a value from one JSON Pointer to another.
|
||||
Copy {
|
||||
/// Source JSON Pointer.
|
||||
from: String,
|
||||
/// Target JSON Pointer.
|
||||
path: String,
|
||||
},
|
||||
/// Assert that a JSON Pointer currently contains a value.
|
||||
Test {
|
||||
/// Target JSON Pointer.
|
||||
path: String,
|
||||
/// Expected value.
|
||||
value: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// Applies an RFC 6902 JSON Patch document to a JSON document string.
|
||||
pub fn apply_json_patch(doc: &str, patch: &str) -> crate::Result<String> {
|
||||
let mut document: Value = serde_json::from_str(doc)
|
||||
.map_err(|error| PatchError::ApplyFailed(format!("invalid JSON document: {error}")))?;
|
||||
let operations: Vec<JsonPatchOperation> = serde_json::from_str(patch)
|
||||
.map_err(|error| PatchError::ApplyFailed(format!("invalid JSON patch: {error}")))?;
|
||||
apply_json_patch_value(&mut document, &operations)?;
|
||||
serde_json::to_string(&document)
|
||||
.map_err(|error| PatchError::ApplyFailed(format!("failed to serialize JSON: {error}")))
|
||||
}
|
||||
|
||||
/// Applies parsed JSON Patch operations to a JSON value.
|
||||
///
|
||||
/// 返回 [`crate::PatchError::ApplyFailed`] 而非空字符串,避免调用方把未实现的
|
||||
/// 占位当成一次成功的补丁应用。
|
||||
pub fn apply_json_patch(_doc: &str, _patch: &str) -> crate::Result<String> {
|
||||
Err(crate::PatchError::ApplyFailed(
|
||||
"json patch 尚未实现".to_string(),
|
||||
))
|
||||
/// Each operation is applied atomically: when one operation fails, the document
|
||||
/// remains at the state produced by the previous successful operation.
|
||||
pub fn apply_json_patch_value(
|
||||
document: &mut Value,
|
||||
operations: &[JsonPatchOperation],
|
||||
) -> crate::Result<()> {
|
||||
for operation in operations {
|
||||
let mut next = document.clone();
|
||||
apply_operation(&mut next, operation)?;
|
||||
*document = next;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_operation(document: &mut Value, operation: &JsonPatchOperation) -> crate::Result<()> {
|
||||
match operation {
|
||||
JsonPatchOperation::Add { path, value } => add_value(document, path, value.clone()),
|
||||
JsonPatchOperation::Remove { path } => remove_value(document, path).map(drop),
|
||||
JsonPatchOperation::Replace { path, value } => replace_value(document, path, value.clone()),
|
||||
JsonPatchOperation::Move { from, path } => {
|
||||
if from == path {
|
||||
return Ok(());
|
||||
}
|
||||
let value = get_value(document, from)?.clone();
|
||||
remove_value(document, from)?;
|
||||
add_value(document, path, value)
|
||||
}
|
||||
JsonPatchOperation::Copy { from, path } => {
|
||||
let value = get_value(document, from)?.clone();
|
||||
add_value(document, path, value)
|
||||
}
|
||||
JsonPatchOperation::Test { path, value } => {
|
||||
let actual = get_value(document, path)?;
|
||||
if actual == value {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(failed(format!(
|
||||
"JSON patch test failed at {path}: expected {value}, actual {actual}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_value(document: &mut Value, path: &str, value: Value) -> crate::Result<()> {
|
||||
let tokens = parse_json_pointer(path)?;
|
||||
if tokens.is_empty() {
|
||||
*document = value;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let key = tokens.last().expect("checked non-empty").clone();
|
||||
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
|
||||
match parent {
|
||||
Value::Object(map) => {
|
||||
map.insert(key, value);
|
||||
Ok(())
|
||||
}
|
||||
Value::Array(items) => {
|
||||
if key == "-" {
|
||||
items.push(value);
|
||||
return Ok(());
|
||||
}
|
||||
let index = parse_array_index(&key, items.len(), true)?;
|
||||
items.insert(index, value);
|
||||
Ok(())
|
||||
}
|
||||
other => Err(failed(format!(
|
||||
"cannot add JSON patch value below non-container value {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_value(document: &mut Value, path: &str) -> crate::Result<Value> {
|
||||
let tokens = parse_json_pointer(path)?;
|
||||
if tokens.is_empty() {
|
||||
return Ok(std::mem::take(document));
|
||||
}
|
||||
|
||||
let key = tokens.last().expect("checked non-empty").clone();
|
||||
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
|
||||
match parent {
|
||||
Value::Object(map) => map
|
||||
.remove(&key)
|
||||
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist"))),
|
||||
Value::Array(items) => {
|
||||
let index = parse_array_index(&key, items.len(), false)?;
|
||||
Ok(items.remove(index))
|
||||
}
|
||||
other => Err(failed(format!(
|
||||
"cannot remove JSON patch value below non-container value {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_value(document: &mut Value, path: &str, value: Value) -> crate::Result<()> {
|
||||
let tokens = parse_json_pointer(path)?;
|
||||
if tokens.is_empty() {
|
||||
*document = value;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let key = tokens.last().expect("checked non-empty").clone();
|
||||
let parent = get_mut_by_tokens(document, &tokens[..tokens.len() - 1])?;
|
||||
match parent {
|
||||
Value::Object(map) => {
|
||||
let slot = map
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?;
|
||||
*slot = value;
|
||||
Ok(())
|
||||
}
|
||||
Value::Array(items) => {
|
||||
let index = parse_array_index(&key, items.len(), false)?;
|
||||
items[index] = value;
|
||||
Ok(())
|
||||
}
|
||||
other => Err(failed(format!(
|
||||
"cannot replace JSON patch value below non-container value {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_value<'a>(document: &'a Value, path: &str) -> crate::Result<&'a Value> {
|
||||
let tokens = parse_json_pointer(path)?;
|
||||
let mut current = document;
|
||||
for token in &tokens {
|
||||
current = match current {
|
||||
Value::Object(map) => map
|
||||
.get(token)
|
||||
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?,
|
||||
Value::Array(items) => {
|
||||
let index = parse_array_index(token, items.len(), false)?;
|
||||
items
|
||||
.get(index)
|
||||
.ok_or_else(|| failed(format!("JSON patch path {path} does not exist")))?
|
||||
}
|
||||
other => {
|
||||
return Err(failed(format!(
|
||||
"cannot traverse JSON patch path {path} through non-container value {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn get_mut_by_tokens<'a>(
|
||||
document: &'a mut Value,
|
||||
tokens: &[String],
|
||||
) -> crate::Result<&'a mut Value> {
|
||||
let mut current = document;
|
||||
for token in tokens {
|
||||
current = match current {
|
||||
Value::Object(map) => map
|
||||
.get_mut(token)
|
||||
.ok_or_else(|| failed(format!("JSON patch path segment {token} does not exist")))?,
|
||||
Value::Array(items) => {
|
||||
let index = parse_array_index(token, items.len(), false)?;
|
||||
items.get_mut(index).ok_or_else(|| {
|
||||
failed(format!("JSON patch path segment {token} does not exist"))
|
||||
})?
|
||||
}
|
||||
other => {
|
||||
return Err(failed(format!(
|
||||
"cannot traverse JSON patch path through non-container value {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
fn parse_array_index(token: &str, len: usize, allow_end: bool) -> crate::Result<usize> {
|
||||
if token.is_empty() || token == "-" {
|
||||
return Err(failed(format!("invalid JSON patch array index {token}")));
|
||||
}
|
||||
let index = token
|
||||
.parse::<usize>()
|
||||
.map_err(|_| failed(format!("invalid JSON patch array index {token}")))?;
|
||||
let max = if allow_end {
|
||||
len
|
||||
} else {
|
||||
len.checked_sub(1)
|
||||
.ok_or_else(|| failed("JSON patch array index exceeds empty array".to_string()))?
|
||||
};
|
||||
if index > max {
|
||||
return Err(failed(format!(
|
||||
"JSON patch array index {index} exceeds length {len}"
|
||||
)));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn parse_json_pointer(pointer: &str) -> crate::Result<Vec<String>> {
|
||||
if pointer.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !pointer.starts_with('/') {
|
||||
return Err(failed(format!(
|
||||
"JSON patch pointer must be empty or start with '/': {pointer}"
|
||||
)));
|
||||
}
|
||||
pointer[1..]
|
||||
.split('/')
|
||||
.map(decode_json_pointer_token)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_json_pointer_token(token: &str) -> crate::Result<String> {
|
||||
let mut decoded = String::with_capacity(token.len());
|
||||
let mut chars = token.chars();
|
||||
while let Some(character) = chars.next() {
|
||||
if character != '~' {
|
||||
decoded.push(character);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('0') => decoded.push('~'),
|
||||
Some('1') => decoded.push('/'),
|
||||
Some(other) => {
|
||||
return Err(failed(format!(
|
||||
"invalid JSON patch pointer escape ~{other}"
|
||||
)))
|
||||
}
|
||||
None => return Err(failed("invalid JSON patch pointer escape ~".to_string())),
|
||||
}
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
fn failed(message: String) -> PatchError {
|
||||
PatchError::ApplyFailed(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn apply_json_patch_reports_not_implemented() {
|
||||
let error = apply_json_patch("{}", "[]").unwrap_err();
|
||||
fn apply_json_patch_handles_all_core_operations() {
|
||||
let document = r#"{"name":"alice","items":["a","b"],"meta":{"keep":true}}"#;
|
||||
let patch = r#"[
|
||||
{"op":"test","path":"/meta/keep","value":true},
|
||||
{"op":"add","path":"/items/-","value":"c"},
|
||||
{"op":"replace","path":"/name","value":"bob"},
|
||||
{"op":"copy","from":"/meta","path":"/copied"},
|
||||
{"op":"move","from":"/items/0","path":"/first"},
|
||||
{"op":"remove","path":"/meta/keep"}
|
||||
]"#;
|
||||
|
||||
let output = apply_json_patch(document, patch).unwrap();
|
||||
let value: Value = serde_json::from_str(&output).unwrap();
|
||||
|
||||
assert_eq!(value["name"], json!("bob"));
|
||||
assert_eq!(value["items"], json!(["b", "c"]));
|
||||
assert_eq!(value["first"], json!("a"));
|
||||
assert_eq!(value["copied"], json!({"keep": true}));
|
||||
assert_eq!(value["meta"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_json_patch_supports_pointer_escapes() {
|
||||
let document = r#"{"a/b":{"tilde~key":1}}"#;
|
||||
let patch = r#"[{"op":"replace","path":"/a~1b/tilde~0key","value":2}]"#;
|
||||
|
||||
let output = apply_json_patch(document, patch).unwrap();
|
||||
let value: Value = serde_json::from_str(&output).unwrap();
|
||||
|
||||
assert_eq!(value["a/b"]["tilde~key"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_json_patch_rejects_failed_test_without_mutating_value() {
|
||||
let mut value = json!({"enabled": true});
|
||||
let operations = vec![
|
||||
JsonPatchOperation::Add {
|
||||
path: "/count".to_string(),
|
||||
value: json!(1),
|
||||
},
|
||||
JsonPatchOperation::Test {
|
||||
path: "/enabled".to_string(),
|
||||
value: json!(false),
|
||||
},
|
||||
];
|
||||
|
||||
let error = apply_json_patch_value(&mut value, &operations).unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
||||
assert_eq!(value, json!({"enabled": true, "count": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_json_patch_rejects_missing_remove_path() {
|
||||
let error = apply_json_patch(r#"{"items":[]}"#, r#"[{"op":"remove","path":"/missing"}]"#)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, crate::PatchError::ApplyFailed(_)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user