Skip to main content

oxihuman_core/
json_patch.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! JSON Patch (RFC 6902) applier stub.
6
7/// A single JSON Patch operation.
8#[derive(Debug, Clone, PartialEq)]
9pub enum PatchOp {
10    Add { path: String, value: String },
11    Remove { path: String },
12    Replace { path: String, value: String },
13    Move { from: String, path: String },
14    Copy { from: String, path: String },
15    Test { path: String, value: String },
16}
17
18/// Error type for patch operations.
19#[derive(Debug, Clone, PartialEq)]
20pub enum PatchError {
21    InvalidOperation(String),
22    PathNotFound(String),
23    TestFailed { path: String, expected: String },
24    MissingField(String),
25}
26
27impl std::fmt::Display for PatchError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::InvalidOperation(s) => write!(f, "invalid operation: {s}"),
31            Self::PathNotFound(p) => write!(f, "path not found: {p}"),
32            Self::TestFailed { path, expected } => {
33                write!(f, "test failed at {path}: expected {expected}")
34            }
35            Self::MissingField(s) => write!(f, "missing required field: {s}"),
36        }
37    }
38}
39
40/// A JSON Patch document (sequence of operations).
41#[derive(Debug, Clone, Default)]
42pub struct JsonPatch {
43    ops: Vec<PatchOp>,
44}
45
46impl JsonPatch {
47    /// Create an empty patch.
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    /// Add an operation to the patch.
53    pub fn push(&mut self, op: PatchOp) {
54        self.ops.push(op);
55    }
56
57    /// Return the operations in this patch.
58    pub fn ops(&self) -> &[PatchOp] {
59        &self.ops
60    }
61
62    /// Return the number of operations.
63    pub fn len(&self) -> usize {
64        self.ops.len()
65    }
66
67    /// Return `true` if the patch has no operations.
68    pub fn is_empty(&self) -> bool {
69        self.ops.is_empty()
70    }
71}
72
73/// Parse a JSON Patch operation kind string.
74pub fn parse_op_kind(s: &str) -> Result<&'static str, PatchError> {
75    match s {
76        "add" => Ok("add"),
77        "remove" => Ok("remove"),
78        "replace" => Ok("replace"),
79        "move" => Ok("move"),
80        "copy" => Ok("copy"),
81        "test" => Ok("test"),
82        other => Err(PatchError::InvalidOperation(other.to_string())),
83    }
84}
85
86/// Validate that a patch path is non-empty.
87pub fn validate_path(path: &str) -> Result<(), PatchError> {
88    if path.is_empty() {
89        return Err(PatchError::PathNotFound(path.to_string()));
90    }
91    Ok(())
92}
93
94/// Count operations of each kind in a patch.
95pub fn count_ops(patch: &JsonPatch) -> [usize; 6] {
96    /* [add, remove, replace, move, copy, test] */
97    let mut counts = [0usize; 6];
98    for op in patch.ops() {
99        let idx = match op {
100            PatchOp::Add { .. } => 0,
101            PatchOp::Remove { .. } => 1,
102            PatchOp::Replace { .. } => 2,
103            PatchOp::Move { .. } => 3,
104            PatchOp::Copy { .. } => 4,
105            PatchOp::Test { .. } => 5,
106        };
107        counts[idx] += 1;
108    }
109    counts
110}
111
112/// Return `true` if the patch contains any `Test` operations.
113pub fn has_test_ops(patch: &JsonPatch) -> bool {
114    patch
115        .ops()
116        .iter()
117        .any(|op| matches!(op, PatchOp::Test { .. }))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_empty_patch() {
126        /* new patch has no ops */
127        let p = JsonPatch::new();
128        assert!(p.is_empty());
129        assert_eq!(p.len(), 0);
130    }
131
132    #[test]
133    fn test_push_ops() {
134        /* push increments len */
135        let mut p = JsonPatch::new();
136        p.push(PatchOp::Add {
137            path: "/a".to_string(),
138            value: "1".to_string(),
139        });
140        p.push(PatchOp::Remove {
141            path: "/b".to_string(),
142        });
143        assert_eq!(p.len(), 2);
144    }
145
146    #[test]
147    fn test_count_ops() {
148        /* count_ops categorises correctly */
149        let mut p = JsonPatch::new();
150        p.push(PatchOp::Add {
151            path: "/x".to_string(),
152            value: "v".to_string(),
153        });
154        p.push(PatchOp::Add {
155            path: "/y".to_string(),
156            value: "v".to_string(),
157        });
158        p.push(PatchOp::Remove {
159            path: "/z".to_string(),
160        });
161        let counts = count_ops(&p);
162        assert_eq!(counts[0], 2); /* add */
163        assert_eq!(counts[1], 1); /* remove */
164    }
165
166    #[test]
167    fn test_has_test_ops_false() {
168        /* no test ops */
169        let mut p = JsonPatch::new();
170        p.push(PatchOp::Replace {
171            path: "/a".to_string(),
172            value: "1".to_string(),
173        });
174        assert!(!has_test_ops(&p));
175    }
176
177    #[test]
178    fn test_has_test_ops_true() {
179        /* patch with test op */
180        let mut p = JsonPatch::new();
181        p.push(PatchOp::Test {
182            path: "/a".to_string(),
183            value: "1".to_string(),
184        });
185        assert!(has_test_ops(&p));
186    }
187
188    #[test]
189    fn test_validate_path_ok() {
190        /* non-empty path is valid */
191        assert!(validate_path("/foo").is_ok());
192    }
193
194    #[test]
195    fn test_validate_path_empty() {
196        /* empty path is invalid */
197        assert!(validate_path("").is_err());
198    }
199
200    #[test]
201    fn test_parse_op_kind_valid() {
202        /* known operations parse without error */
203        assert!(parse_op_kind("add").is_ok());
204        assert!(parse_op_kind("remove").is_ok());
205    }
206
207    #[test]
208    fn test_parse_op_kind_invalid() {
209        /* unknown operation returns error */
210        assert!(parse_op_kind("upsert").is_err());
211    }
212
213    #[test]
214    fn test_ops_slice() {
215        /* ops() returns correct slice */
216        let mut p = JsonPatch::new();
217        p.push(PatchOp::Remove {
218            path: "/k".to_string(),
219        });
220        assert_eq!(p.ops().len(), 1);
221    }
222}