polyspec_crudui_validator/compose/
patch.rs1use serde_json::{Map, Value};
25
26use super::errors::{ComposeErrorCode, ComposeLoadError, ComposeResult};
27use super::members::kind_of;
28
29pub fn apply_patch(base: Map<String, Value>, patch: &Value) -> ComposeResult<Map<String, Value>> {
31 let p = match patch {
32 Value::Object(obj) => obj,
33 other => {
34 return Err(ComposeLoadError::new(
35 ComposeErrorCode::PatchShape,
36 format!(
37 "$patch must be an object of operations, got {}",
38 kind_of(other)
39 ),
40 ));
41 }
42 };
43
44 let mut result = base;
45
46 for (key, val) in p {
48 match key.as_str() {
49 "add" | "replace" => {
50 result = apply_add_replace(result, val, key)?;
51 }
52 "remove" => {
53 result = apply_remove(result, val)?;
54 }
55 _ => {
57 let segments = split_path(key)?;
58 result = set_deep_path(result, &segments, val)?;
59 }
60 }
61 }
62 Ok(result)
63}
64
65fn apply_add_replace(
67 base: Map<String, Value>,
68 val: &Value,
69 op: &str,
70) -> ComposeResult<Map<String, Value>> {
71 let obj = match val {
72 Value::Object(obj) => obj,
73 _ => {
74 return Err(ComposeLoadError::new(
75 ComposeErrorCode::PatchShape,
76 format!("$patch.{} must be an object of deep-path → value", op),
77 ));
78 }
79 };
80 let mut result = base;
81 for (path, value) in obj {
82 let segments = split_path(path)?;
83 result = set_deep_path(result, &segments, value)?;
84 }
85 Ok(result)
86}
87
88fn apply_remove(base: Map<String, Value>, val: &Value) -> ComposeResult<Map<String, Value>> {
90 match val {
91 Value::Array(items) => {
92 let mut result = base;
93 for path in items {
94 let s = match path {
95 Value::String(s) => s,
96 _ => {
97 return Err(ComposeLoadError::new(
98 ComposeErrorCode::PatchShape,
99 "$patch.remove array entries must be strings",
100 ));
101 }
102 };
103 let segments = split_path(s)?;
104 result = remove_deep_path(result, &segments)?;
105 }
106 Ok(result)
107 }
108 Value::Object(spec) => Ok(remove_nested(base, spec)),
110 _ => Err(ComposeLoadError::new(
111 ComposeErrorCode::PatchShape,
112 "$patch.remove must be an array of paths or a nested object",
113 )),
114 }
115}
116
117fn split_path(path: &str) -> ComposeResult<Vec<String>> {
119 if path.is_empty() {
120 return Err(ComposeLoadError::new(
121 ComposeErrorCode::PatchShape,
122 "$patch path must be non-empty",
123 ));
124 }
125 Ok(path.split('.').map(|s| s.to_string()).collect())
126}
127
128fn set_deep_path(
138 mut node: Map<String, Value>,
139 segments: &[String],
140 value: &Value,
141) -> ComposeResult<Map<String, Value>> {
142 let head = &segments[0];
143 let rest = &segments[1..];
144
145 if rest.is_empty() {
146 let existing = node.get(head).cloned();
148 let merged = merge_value(existing.as_ref(), value);
149 node.insert(head.clone(), merged); return Ok(node);
151 }
152
153 match node.get(head).cloned() {
155 None => {
156 let sub = set_deep_path(Map::new(), rest, value)?;
157 node.insert(head.clone(), Value::Object(sub)); }
159 Some(Value::Object(obj)) => {
160 let sub = set_deep_path(obj, rest, value)?;
161 node.insert(head.clone(), Value::Object(sub)); }
163 Some(_) => {
164 return Err(ComposeLoadError::new(
166 ComposeErrorCode::PatchPathConflict,
167 format!("$patch cannot descend into non-object at '{}'", head),
168 ));
169 }
170 }
171 Ok(node)
172}
173
174fn merge_value(existing: Option<&Value>, incoming: &Value) -> Value {
177 if let (Some(Value::Object(ex)), Value::Object(inc)) = (existing, incoming) {
178 let mut out = ex.clone();
179 for (k, v) in inc {
180 let merged = merge_value(out.get(k), v);
181 out.insert(k.clone(), merged);
182 }
183 return Value::Object(out);
184 }
185 incoming.clone()
186}
187
188fn remove_deep_path(
190 mut node: Map<String, Value>,
191 segments: &[String],
192) -> ComposeResult<Map<String, Value>> {
193 let head = &segments[0];
194 let rest = &segments[1..];
195
196 if !node.contains_key(head) {
197 return Err(ComposeLoadError::new(
198 ComposeErrorCode::PatchRemoveTargetMissing,
199 format!("$patch remove target not found: '{}'", segments.join(".")),
200 ));
201 }
202
203 if rest.is_empty() {
204 node.shift_remove(head);
207 return Ok(node);
208 }
209
210 match node.get(head).cloned() {
213 Some(Value::Object(obj)) => {
214 let sub = remove_deep_path(obj, rest)?;
215 node.insert(head.clone(), Value::Object(sub));
216 Ok(node)
217 }
218 _ => Err(ComposeLoadError::new(
219 ComposeErrorCode::PatchRemoveTargetMissing,
220 format!("$patch remove cannot descend into non-object at '{}'", head),
221 )),
222 }
223}
224
225fn remove_nested(mut base: Map<String, Value>, spec: &Map<String, Value>) -> Map<String, Value> {
229 for (key, sub) in spec {
230 match (base.get(key).cloned(), sub) {
231 (Some(Value::Object(target)), Value::Object(sub_obj)) => {
235 let next = remove_nested(target, sub_obj);
236 base.insert(key.clone(), Value::Object(next));
237 }
238 _ => {
241 base.shift_remove(key);
242 }
243 }
244 }
245 base
246}
247
248#[cfg(test)]
249mod order_tests {
250 use super::apply_patch;
255 use serde_json::{json, Value};
256
257 fn keys(v: &Value) -> Vec<String> {
259 v.as_object().unwrap().keys().cloned().collect()
260 }
261
262 #[test]
263 fn deep_path_set_keeps_target_field_in_place() {
264 let base = json!({
271 "a": {"t": 1},
272 "field": {"validate": {"email": true, "required": false, "maxlength": 9}},
273 "c": {"t": 2},
274 "d": {"t": 3}
275 });
276 let out = apply_patch(
277 base.as_object().unwrap().clone(),
278 &json!({
279 "field.validate.required": ".other"
280 }),
281 )
282 .unwrap();
283 let out = Value::Object(out);
284 assert_eq!(keys(&out), vec!["a", "field", "c", "d"]);
285 assert_eq!(
287 keys(&out["field"]["validate"]),
288 vec!["email", "required", "maxlength"]
289 );
290 assert_eq!(
292 serde_json::to_string(&out["field"]).unwrap(),
293 r#"{"validate":{"email":true,"required":".other","maxlength":9}}"#
294 );
295 }
296
297 #[test]
298 fn deep_path_set_appends_only_genuinely_new_keys() {
299 let base = json!({"a": 1, "b": 2});
300 let out = apply_patch(base.as_object().unwrap().clone(), &json!({"c": 3})).unwrap();
301 assert_eq!(keys(&Value::Object(out)), vec!["a", "b", "c"]);
302 }
303
304 #[test]
305 fn add_appends_replace_keeps_position() {
306 let base = json!({"a": {"design": {"class": "old"}}, "b": 2, "c": 3});
307 let out = apply_patch(
309 base.as_object().unwrap().clone(),
310 &json!({
311 "replace": {"a.design.class": "new"},
312 "add": {"newf": {"t": 1}}
313 }),
314 )
315 .unwrap();
316 assert_eq!(
317 keys(&Value::Object(out.clone())),
318 vec!["a", "b", "c", "newf"]
319 );
320 assert_eq!(out["a"]["design"]["class"], json!("new"));
321 }
322
323 #[test]
324 fn remove_leaf_preserves_sibling_order() {
325 let base = json!({"a": 1, "remove_me": 2, "c": 3, "z": 4});
328 let out = apply_patch(
329 base.as_object().unwrap().clone(),
330 &json!({
331 "remove": ["remove_me"]
332 }),
333 )
334 .unwrap();
335 assert_eq!(keys(&Value::Object(out)), vec!["a", "c", "z"]);
336 }
337
338 #[test]
339 fn remove_deep_keeps_parent_and_inner_order() {
340 let base = json!({
343 "field": {"type": "tags", "options": {"max_tags": 5, "keyword_min_length": 2}},
344 "sibling": {"t": 1}
345 });
346 let out = apply_patch(
347 base.as_object().unwrap().clone(),
348 &json!({
349 "remove": ["field.options.max_tags"]
350 }),
351 )
352 .unwrap();
353 let out = Value::Object(out);
354 assert_eq!(keys(&out), vec!["field", "sibling"]);
355 assert_eq!(keys(&out["field"]), vec!["type", "options"]);
356 assert_eq!(keys(&out["field"]["options"]), vec!["keyword_min_length"]);
357 }
358
359 #[test]
360 fn remove_nested_map_form_preserves_order() {
361 let base = json!({
362 "f": {"options": {"max_tags": 5, "min": 2}},
363 "g": {"t": 1}
364 });
365 let out = apply_patch(
366 base.as_object().unwrap().clone(),
367 &json!({
368 "remove": {"f": {"options": {"max_tags": true}}}
369 }),
370 )
371 .unwrap();
372 let out = Value::Object(out);
373 assert_eq!(keys(&out), vec!["f", "g"]);
374 assert_eq!(keys(&out["f"]["options"]), vec!["min"]);
375 }
376
377 #[test]
378 fn remove_nested_whole_key_preserves_remaining_order() {
379 let base = json!({"a": {"t": 1}, "b": {"t": 2}, "c": {"t": 3}});
380 let out = apply_patch(
381 base.as_object().unwrap().clone(),
382 &json!({
383 "remove": {"b": true}
384 }),
385 )
386 .unwrap();
387 assert_eq!(keys(&Value::Object(out)), vec!["a", "c"]);
388 }
389}