use crate::models::{CosmosNumber, PatchOperation};
use serde_json::Value;
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum PatchEvalError {
InvalidPath(String),
MissingParent(String),
MissingTarget(String),
InvalidArrayIndex {
token: String,
path: String,
},
ArrayIndexOutOfRange {
index: usize,
len: usize,
path: String,
},
TypeMismatch {
expected: &'static str,
actual: &'static str,
path: String,
},
CannotRemoveRoot,
InvalidMove(String),
}
impl fmt::Display for PatchEvalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PatchEvalError::InvalidPath(p) => write!(f, "invalid JSON Pointer: '{p}'"),
PatchEvalError::MissingParent(p) => write!(f, "missing parent path: '{p}'"),
PatchEvalError::MissingTarget(p) => write!(f, "missing path: '{p}'"),
PatchEvalError::InvalidArrayIndex { token, path } => {
write!(f, "invalid array index '{token}' in path '{path}'")
}
PatchEvalError::ArrayIndexOutOfRange { index, len, path } => write!(
f,
"array index {index} out of range (len={len}) in path '{path}'"
),
PatchEvalError::TypeMismatch {
expected,
actual,
path,
} => write!(
f,
"type mismatch at '{path}': expected {expected}, got {actual}"
),
PatchEvalError::CannotRemoveRoot => write!(f, "cannot remove root document"),
PatchEvalError::InvalidMove(msg) => write!(f, "invalid move: {msg}"),
}
}
}
impl std::error::Error for PatchEvalError {}
impl From<PatchEvalError> for crate::error::CosmosError {
fn from(err: PatchEvalError) -> Self {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message(err.to_string())
.build()
}
}
pub(crate) fn apply_patch_ops(
doc: &mut Value,
ops: &[PatchOperation],
) -> Result<(), PatchEvalError> {
for op in ops {
apply_one(doc, op)?;
}
Ok(())
}
fn apply_one(doc: &mut Value, op: &PatchOperation) -> Result<(), PatchEvalError> {
match op {
PatchOperation::Add { path, value } => add_or_set(doc, path, value.clone(), AddOrSet::Add),
PatchOperation::Set { path, value } => add_or_set(doc, path, value.clone(), AddOrSet::Set),
PatchOperation::Replace { path, value } => replace(doc, path, value.clone()),
PatchOperation::Remove { path } => remove(doc, path).map(|_| ()),
PatchOperation::Increment { path, value } => increment(doc, path, *value),
PatchOperation::Move { from, path } => move_value(doc, from, path),
}
}
fn parse_pointer(path: &str) -> Result<Vec<String>, PatchEvalError> {
if path.is_empty() {
return Ok(Vec::new());
}
if !path.starts_with('/') {
return Err(PatchEvalError::InvalidPath(path.to_string()));
}
let mut tokens = Vec::new();
for raw in path[1..].split('/') {
tokens.push(unescape_token(raw)?);
}
Ok(tokens)
}
fn unescape_token(raw: &str) -> Result<String, PatchEvalError> {
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars();
while let Some(c) = chars.next() {
if c == '~' {
match chars.next() {
Some('0') => out.push('~'),
Some('1') => out.push('/'),
_ => return Err(PatchEvalError::InvalidPath(raw.to_string())),
}
} else {
out.push(c);
}
}
Ok(out)
}
fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn navigate_parent_mut<'a, 'b>(
doc: &'a mut Value,
tokens: &'b [String],
path: &str,
) -> Result<(&'a mut Value, &'b str), PatchEvalError> {
if tokens.is_empty() {
return Err(PatchEvalError::InvalidPath(path.to_string()));
}
let (last, prefix) = tokens.split_last().expect("non-empty");
let mut cursor = doc;
for token in prefix {
cursor = step_into(cursor, token, path)?;
}
Ok((cursor, last.as_str()))
}
fn step_into<'a>(
node: &'a mut Value,
token: &str,
full_path: &str,
) -> Result<&'a mut Value, PatchEvalError> {
match node {
Value::Object(map) => map
.get_mut(token)
.ok_or_else(|| PatchEvalError::MissingParent(full_path.to_string())),
Value::Array(arr) => {
let idx = parse_array_index(token, full_path, arr.len(), false)?;
arr.get_mut(idx)
.ok_or_else(|| PatchEvalError::MissingParent(full_path.to_string()))
}
_ => Err(PatchEvalError::MissingParent(full_path.to_string())),
}
}
fn parse_array_index(
token: &str,
full_path: &str,
len: usize,
allow_append: bool,
) -> Result<usize, PatchEvalError> {
if token == "-" {
if allow_append {
return Ok(len);
}
return Err(PatchEvalError::InvalidArrayIndex {
token: token.to_string(),
path: full_path.to_string(),
});
}
let idx: usize = token
.parse()
.map_err(|_| PatchEvalError::InvalidArrayIndex {
token: token.to_string(),
path: full_path.to_string(),
})?;
if !allow_append && len == 0 {
return Err(PatchEvalError::ArrayIndexOutOfRange {
index: idx,
len,
path: full_path.to_string(),
});
}
let upper = if allow_append {
len
} else {
len.saturating_sub(1)
};
if idx > upper {
return Err(PatchEvalError::ArrayIndexOutOfRange {
index: idx,
len,
path: full_path.to_string(),
});
}
Ok(idx)
}
#[derive(Copy, Clone)]
enum AddOrSet {
Add,
Set,
}
fn add_or_set(
doc: &mut Value,
path: &str,
new_value: Value,
mode: AddOrSet,
) -> Result<(), PatchEvalError> {
let tokens = parse_pointer(path)?;
if tokens.is_empty() {
*doc = new_value;
return Ok(());
}
let (parent, last) = navigate_parent_mut(doc, &tokens, path)?;
match parent {
Value::Object(map) => {
match mode {
AddOrSet::Add | AddOrSet::Set => {
map.insert(last.to_string(), new_value);
}
}
Ok(())
}
Value::Array(arr) => {
if matches!(mode, AddOrSet::Set) && last != "-" {
let idx = parse_array_index(last, path, arr.len(), false)?;
arr[idx] = new_value;
return Ok(());
}
let idx = parse_array_index(last, path, arr.len(), true)?;
if idx == arr.len() {
arr.push(new_value);
} else {
arr.insert(idx, new_value);
}
Ok(())
}
other => Err(PatchEvalError::TypeMismatch {
expected: "object or array",
actual: json_type_name(other),
path: path.to_string(),
}),
}
}
fn replace(doc: &mut Value, path: &str, new_value: Value) -> Result<(), PatchEvalError> {
let tokens = parse_pointer(path)?;
if tokens.is_empty() {
*doc = new_value;
return Ok(());
}
let (parent, last) = navigate_parent_mut(doc, &tokens, path)?;
match parent {
Value::Object(map) => {
if !map.contains_key(last) {
return Err(PatchEvalError::MissingTarget(path.to_string()));
}
map.insert(last.to_string(), new_value);
Ok(())
}
Value::Array(arr) => {
let idx = parse_array_index(last, path, arr.len(), false)?;
arr[idx] = new_value;
Ok(())
}
other => Err(PatchEvalError::TypeMismatch {
expected: "object or array",
actual: json_type_name(other),
path: path.to_string(),
}),
}
}
fn remove(doc: &mut Value, path: &str) -> Result<Value, PatchEvalError> {
let tokens = parse_pointer(path)?;
if tokens.is_empty() {
return Err(PatchEvalError::CannotRemoveRoot);
}
let (parent, last) = navigate_parent_mut(doc, &tokens, path)?;
match parent {
Value::Object(map) => map
.remove(last)
.ok_or_else(|| PatchEvalError::MissingTarget(path.to_string())),
Value::Array(arr) => {
let idx = parse_array_index(last, path, arr.len(), false)?;
if arr.is_empty() {
return Err(PatchEvalError::MissingTarget(path.to_string()));
}
Ok(arr.remove(idx))
}
other => Err(PatchEvalError::TypeMismatch {
expected: "object or array",
actual: json_type_name(other),
path: path.to_string(),
}),
}
}
fn increment(doc: &mut Value, path: &str, delta: CosmosNumber) -> Result<(), PatchEvalError> {
let tokens = parse_pointer(path)?;
if tokens.is_empty() {
return Err(PatchEvalError::MissingTarget(path.to_string()));
}
let (parent, last) = navigate_parent_mut(doc, &tokens, path)?;
let leaf = match parent {
Value::Object(map) => map
.get_mut(last)
.ok_or_else(|| PatchEvalError::MissingTarget(path.to_string()))?,
Value::Array(arr) => {
let idx = parse_array_index(last, path, arr.len(), false)?;
arr.get_mut(idx)
.ok_or_else(|| PatchEvalError::MissingTarget(path.to_string()))?
}
other => {
return Err(PatchEvalError::TypeMismatch {
expected: "object or array",
actual: json_type_name(other),
path: path.to_string(),
});
}
};
let num = match leaf {
Value::Number(n) => n.clone(),
other => {
return Err(PatchEvalError::TypeMismatch {
expected: "number",
actual: json_type_name(other),
path: path.to_string(),
});
}
};
let new_value = match (delta, num.as_i64(), num.as_f64()) {
(CosmosNumber::Int(d), Some(target), _) => {
let sum = target
.checked_add(d)
.ok_or_else(|| PatchEvalError::TypeMismatch {
expected: "i64 (within range)",
actual: "i64 (overflow)",
path: path.to_string(),
})?;
Value::Number(sum.into())
}
(CosmosNumber::Int(_), None, Some(_)) => {
return Err(PatchEvalError::TypeMismatch {
expected: "integer number",
actual: "fractional number",
path: path.to_string(),
});
}
(CosmosNumber::Float(d), Some(target), _) => {
const F64_INT_LIMIT: u64 = 1u64 << 53;
if target.unsigned_abs() > F64_INT_LIMIT {
return Err(PatchEvalError::TypeMismatch {
expected: "integer target within ±2^53 for fractional Increment",
actual: "integer outside ±2^53",
path: path.to_string(),
});
}
let sum = (target as f64) + d;
match serde_json::Number::from_f64(sum) {
Some(n) => Value::Number(n),
None => {
return Err(PatchEvalError::TypeMismatch {
expected: "finite number",
actual: "non-finite",
path: path.to_string(),
});
}
}
}
(CosmosNumber::Float(d), None, Some(target)) => {
let sum = target + d;
match serde_json::Number::from_f64(sum) {
Some(n) => Value::Number(n),
None => {
return Err(PatchEvalError::TypeMismatch {
expected: "finite number",
actual: "non-finite",
path: path.to_string(),
});
}
}
}
_ => {
return Err(PatchEvalError::TypeMismatch {
expected: "number",
actual: "non-numeric",
path: path.to_string(),
});
}
};
*leaf = new_value;
Ok(())
}
fn move_value(doc: &mut Value, from: &str, to: &str) -> Result<(), PatchEvalError> {
if from == to {
return Ok(());
}
let from_tokens = parse_pointer(from)?;
let to_tokens = parse_pointer(to)?;
if to_tokens.len() > from_tokens.len() && to_tokens[..from_tokens.len()] == from_tokens[..] {
return Err(PatchEvalError::InvalidMove(format!(
"destination '{to}' is a descendant of source '{from}'"
)));
}
let mut staged = doc.clone();
let value = remove(&mut staged, from)?;
add_or_set(&mut staged, to, value, AddOrSet::Add)?;
*doc = staged;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn apply(doc: Value, ops: &[PatchOperation]) -> Result<Value, PatchEvalError> {
let mut d = doc;
apply_patch_ops(&mut d, ops)?;
Ok(d)
}
#[test]
fn add_into_object_inserts_key() {
let doc = json!({"a": 1});
let out = apply(doc, &[PatchOperation::add("/b", json!(2))]).unwrap();
assert_eq!(out, json!({"a": 1, "b": 2}));
}
#[test]
fn add_with_dash_appends_to_array() {
let doc = json!({"xs": [1, 2]});
let out = apply(doc, &[PatchOperation::add("/xs/-", json!(3))]).unwrap();
assert_eq!(out, json!({"xs": [1, 2, 3]}));
}
#[test]
fn add_at_array_index_inserts() {
let doc = json!({"xs": [1, 3]});
let out = apply(doc, &[PatchOperation::add("/xs/1", json!(2))]).unwrap();
assert_eq!(out, json!({"xs": [1, 2, 3]}));
}
#[test]
fn add_with_missing_parent_fails() {
let doc = json!({});
let err = apply(doc, &[PatchOperation::add("/a/b", json!(1))]).unwrap_err();
assert!(matches!(err, PatchEvalError::MissingParent(_)), "{err}");
}
#[test]
fn set_creates_missing_leaf_in_object() {
let doc = json!({"a": 1});
let out = apply(doc, &[PatchOperation::set("/b", json!(2))]).unwrap();
assert_eq!(out, json!({"a": 1, "b": 2}));
}
#[test]
fn replace_missing_leaf_fails() {
let doc = json!({"a": 1});
let err = apply(doc, &[PatchOperation::replace("/b", json!(2))]).unwrap_err();
assert!(matches!(err, PatchEvalError::MissingTarget(_)), "{err}");
}
#[test]
fn replace_allows_type_change() {
let doc = json!({"a": 1});
let out = apply(doc, &[PatchOperation::replace("/a", json!("hi"))]).unwrap();
assert_eq!(out, json!({"a": "hi"}));
}
#[test]
fn remove_strips_leaf() {
let doc = json!({"a": 1, "b": 2});
let out = apply(doc, &[PatchOperation::remove("/a")]).unwrap();
assert_eq!(out, json!({"b": 2}));
}
#[test]
fn remove_root_fails() {
let doc = json!({"a": 1});
let err = apply(doc, &[PatchOperation::remove("")]).unwrap_err();
assert!(matches!(err, PatchEvalError::CannotRemoveRoot));
}
#[test]
fn remove_missing_fails() {
let doc = json!({"a": 1});
let err = apply(doc, &[PatchOperation::remove("/missing")]).unwrap_err();
assert!(matches!(err, PatchEvalError::MissingTarget(_)), "{err}");
}
#[test]
fn increment_int_preserves_fidelity() {
let doc = json!({"n": 9_000_000_000_000_000i64});
let out = apply(doc, &[PatchOperation::increment("/n", 1i64)]).unwrap();
let n = out["n"].as_i64().unwrap();
assert_eq!(n, 9_000_000_000_000_001);
}
#[test]
fn increment_int_on_float_fails() {
let doc = json!({"n": 1.5});
let err = apply(doc, &[PatchOperation::increment("/n", 1i64)]).unwrap_err();
assert!(matches!(err, PatchEvalError::TypeMismatch { .. }), "{err}");
}
#[test]
fn increment_float_on_int_promotes_to_float() {
let doc = json!({"n": 2});
let out = apply(doc, &[PatchOperation::increment("/n", 0.5f64)]).unwrap();
assert!((out["n"].as_f64().unwrap() - 2.5).abs() < f64::EPSILON);
}
#[test]
fn increment_float_on_huge_int_rejects() {
let doc = json!({ "n": 9_007_199_254_740_993i64 });
let err = apply(doc, &[PatchOperation::increment("/n", 0.0f64)]).unwrap_err();
assert!(
matches!(err, PatchEvalError::TypeMismatch { .. }),
"Float-delta Increment on i64 outside ±2^53 must reject; got {err}"
);
let doc = json!({ "n": -9_007_199_254_740_993i64 });
let err = apply(doc, &[PatchOperation::increment("/n", 0.0f64)]).unwrap_err();
assert!(
matches!(err, PatchEvalError::TypeMismatch { .. }),
"Float-delta Increment on negative i64 outside ±2^53 must reject; got {err}"
);
let doc = json!({ "n": 9_007_199_254_740_992i64 });
let out = apply(doc, &[PatchOperation::increment("/n", 0.0f64)]).unwrap();
assert_eq!(out["n"].as_f64().unwrap(), 9_007_199_254_740_992.0);
}
#[test]
fn increment_on_non_number_fails() {
let doc = json!({"n": "five"});
let err = apply(doc, &[PatchOperation::increment("/n", 1i64)]).unwrap_err();
assert!(matches!(err, PatchEvalError::TypeMismatch { .. }), "{err}");
}
#[test]
fn move_renames_field() {
let doc = json!({"a": 1});
let out = apply(doc, &[PatchOperation::move_value("/a", "/b")]).unwrap();
assert_eq!(out, json!({"b": 1}));
}
#[test]
fn move_to_same_path_is_noop() {
let doc = json!({"a": 1});
let out = apply(doc, &[PatchOperation::move_value("/a", "/a")]).unwrap();
assert_eq!(out, json!({"a": 1}));
}
#[test]
fn move_into_own_descendant_fails() {
let doc = json!({"a": {"x": 1}});
let err = apply(doc, &[PatchOperation::move_value("/a", "/a/inner")]).unwrap_err();
assert!(matches!(err, PatchEvalError::InvalidMove(_)), "{err}");
}
#[test]
fn move_into_escaped_descendant_fails() {
let doc = json!({"a/b": {"x": 1}});
let err = apply(doc, &[PatchOperation::move_value("/a~1b", "/a~1b/c")]).unwrap_err();
assert!(matches!(err, PatchEvalError::InvalidMove(_)), "{err}");
}
#[test]
fn move_to_sibling_with_shared_prefix_succeeds() {
let doc = json!({"a": 1, "ab": {"x": 2}});
let out = apply(doc, &[PatchOperation::move_value("/a", "/ab/y")]).unwrap();
assert_eq!(out, json!({"ab": {"x": 2, "y": 1}}));
}
#[test]
fn move_failure_leaves_doc_unchanged() {
let doc = json!({"a": 1, "b": {}});
let original = doc.clone();
let mut d = doc;
let err = apply_patch_ops(
&mut d,
&[PatchOperation::move_value("/a", "/missing/parent/x")],
)
.unwrap_err();
assert!(matches!(err, PatchEvalError::MissingParent(_)), "{err}");
assert_eq!(
d, original,
"move_value must be atomic: a failed move must leave the document unchanged"
);
}
#[test]
fn set_at_array_index_replaces_in_place() {
let doc = json!({"xs": [1, 2, 3]});
let out = apply(doc, &[PatchOperation::set("/xs/1", json!(9))]).unwrap();
assert_eq!(out, json!({"xs": [1, 9, 3]}));
}
#[test]
fn set_with_dash_appends_to_array() {
let doc = json!({"xs": [1, 2]});
let out = apply(doc, &[PatchOperation::set("/xs/-", json!(3))]).unwrap();
assert_eq!(out, json!({"xs": [1, 2, 3]}));
}
#[test]
fn set_at_array_index_out_of_range_fails() {
let doc = json!({"xs": [1, 2]});
let err = apply(doc, &[PatchOperation::set("/xs/5", json!(9))]).unwrap_err();
assert!(
matches!(err, PatchEvalError::ArrayIndexOutOfRange { .. }),
"{err}"
);
}
#[test]
fn pointer_escapes() {
let doc = json!({"a/b": 1, "tilde~": "x"});
let out = apply(doc.clone(), &[PatchOperation::replace("/a~1b", json!(2))]).unwrap();
assert_eq!(out["a/b"], json!(2));
let out2 = apply(doc, &[PatchOperation::replace("/tilde~0", json!("y"))]).unwrap();
assert_eq!(out2["tilde~"], json!("y"));
}
#[test]
fn invalid_pointer_fails() {
let doc = json!({});
let err = apply(doc, &[PatchOperation::add("invalid", json!(1))]).unwrap_err();
assert!(matches!(err, PatchEvalError::InvalidPath(_)), "{err}");
}
#[test]
fn array_index_out_of_range_fails() {
let doc = json!({"xs": [1]});
let err = apply(doc, &[PatchOperation::add("/xs/5", json!(2))]).unwrap_err();
assert!(
matches!(err, PatchEvalError::ArrayIndexOutOfRange { .. }),
"{err}"
);
}
#[test]
fn ops_apply_in_order() {
let doc = json!({"a": 0});
let out = apply(
doc,
&[
PatchOperation::increment("/a", 1i64),
PatchOperation::move_value("/a", "/b"),
PatchOperation::increment("/b", 1i64),
],
)
.unwrap();
assert_eq!(out["b"], json!(2));
assert!(
out.get("a").is_none(),
"after Move /a /b, /a must be absent; got {out}"
);
}
#[test]
fn set_on_empty_array_index_zero_errors_not_panics() {
let doc = json!({"xs": []});
let err = apply(doc, &[PatchOperation::set("/xs/0", json!("v"))]).unwrap_err();
assert!(
matches!(
err,
PatchEvalError::ArrayIndexOutOfRange {
index: 0,
len: 0,
..
}
),
"{err}"
);
}
#[test]
fn replace_on_empty_array_index_zero_errors_not_panics() {
let doc = json!({"xs": []});
let err = apply(doc, &[PatchOperation::replace("/xs/0", json!("v"))]).unwrap_err();
assert!(
matches!(
err,
PatchEvalError::ArrayIndexOutOfRange {
index: 0,
len: 0,
..
}
),
"{err}"
);
}
#[test]
fn add_on_empty_array_index_zero_succeeds() {
let doc = json!({"xs": []});
let out = apply(doc, &[PatchOperation::add("/xs/0", json!("v"))]).unwrap();
assert_eq!(out, json!({"xs": ["v"]}));
}
#[test]
fn add_on_empty_array_with_dash_succeeds() {
let doc = json!({"xs": []});
let out = apply(doc, &[PatchOperation::add("/xs/-", json!("v"))]).unwrap();
assert_eq!(out, json!({"xs": ["v"]}));
}
}