Skip to main content

polyspec_crudui_validator/compose/
patch.rs

1//! `$patch` application — add / remove / replace over the `$ref` base (SPEC
2//! §5). There are no `$after`/`$before`/`$merge`/`$change`/`$remove` directives;
3//! `$patch` is the only overlay.
4//!
5//! CRUDUI normalization (the analysis patch_ops): `$patch` is an OBJECT of operations.
6//! Two order-preserving input shapes are supported:
7//!
8//!   1. Deep-path set — `"field.validate.required": ".other"`. The dotted key is
9//!      split into path segments and the value is SET at that node (creating
10//!      intermediate objects). SPEC §5 canonical form. The value replaces a
11//!      scalar leaf; for object values it deep-merges (both objects → deep
12//!      merge, else the latter wins).
13//!
14//!   2. Structured ops — explicit `add` / `remove` / `replace` keys:
15//!      - add:     `{ "path.to.new": value, … }`   — deep-merge value at path
16//!      - replace: `{ "path.to.key": value, … }`   — same merge rule (scalar override)
17//!      - remove:  `[ "path.to.key", … ] | { … }`  — deep delete
18//!
19//! Resolution order: base ($ref) first, then $patch overlays. add/replace
20//! deep-merge; remove deep-deletes; deep-path set splits then applies. An
21//! unresolved patch (op shape error, path conflict, strict-remove miss) is a
22//! LOAD ERROR — never `valid:true`.
23
24use serde_json::{Map, Value};
25
26use super::errors::{ComposeErrorCode, ComposeLoadError, ComposeResult};
27use super::members::kind_of;
28
29/// Apply a `$patch` value to the (already `$ref`-expanded) base spec.
30pub 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    // Apply entries in declaration order (serde_json preserve_order = insert order).
47    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            // Deep-path set (SPEC §5 canonical form): "a.b.c": value.
56            _ => {
57                let segments = split_path(key)?;
58                result = set_deep_path(result, &segments, val)?;
59            }
60        }
61    }
62    Ok(result)
63}
64
65/// Structured add/replace: a map of deep-path → value, deep-merged at each path.
66fn 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
88/// Structured remove: an array of deep-paths, or a nested `{k:{sub:…}}` map.
89fn 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        // Nested map form: recurse where both sides are objects.
109        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
117/// Split a dotted deep-path into segments. Empty path is a shape error.
118fn 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
128/// Set a value at a deep path, creating intermediate objects. When both the
129/// existing leaf and the new value are plain objects, DEEP-MERGE;
130/// otherwise the new value REPLACES. Returns a new tree.
131///
132/// ORDER: a key that already exists must keep its declared position. We read the
133/// existing value (get().cloned()) WITHOUT removing it, then `insert` under the
134/// same key — IndexMap::insert keeps an existing key in place. A `remove`
135/// (= swap_remove) before re-insert would corrupt the declaration order that merge
136/// priority depends on. A brand-new key is appended (add).
137fn 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        // Read existing leaf in place (no remove → no swap reorder), merge, re-set.
147        let existing = node.get(head).cloned();
148        let merged = merge_value(existing.as_ref(), value);
149        node.insert(head.clone(), merged); // existing key keeps its slot
150        return Ok(node);
151    }
152
153    // Read the child in place; descend a clone; re-insert under the same key.
154    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)); // new key appended
158        }
159        Some(Value::Object(obj)) => {
160            let sub = set_deep_path(obj, rest, value)?;
161            node.insert(head.clone(), Value::Object(sub)); // existing key keeps slot
162        }
163        Some(_) => {
164            // Intermediate node is a scalar/array — cannot descend into it.
165            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
174/// Deep-merge leaf rule: both plain objects →
175/// recursive deep merge; otherwise the latter value wins (scalar/array override).
176fn 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
188/// Delete a value at a deep path. Strict: a missing target is a load error.
189fn 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        // Pure delete: shift_remove keeps the ORDER of the surviving keys (the
205        // default remove = swap_remove would move the last key into this slot).
206        node.shift_remove(head);
207        return Ok(node);
208    }
209
210    // Descend: read the child in place, recurse on a clone, re-insert under the
211    // same key so the parent keeps its declared position (insert preserves slot).
212    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
225/// Nested-map remove: for each key, recurse when both the target
226/// and the removal spec are objects, else unset the key. A missing key is
227/// tolerated here (silently skipped), unlike the array-path form.
228fn 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            // Both target and spec are objects: recurse. Read the target in place,
232            // recurse on a clone, re-insert under the same key — the surviving key
233            // keeps its declared slot (insert preserves an existing key's order).
234            (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            // Otherwise unset the whole key. shift_remove keeps the ORDER of the
239            // remaining keys (default remove = swap_remove would reorder them).
240            _ => {
241                base.shift_remove(key);
242            }
243        }
244    }
245    base
246}
247
248#[cfg(test)]
249mod order_tests {
250    //! Verify the literal unsorted key sequence for every patch write path. Map
251    //! equality does not detect reordering, so these tests compare keys and
252    //! serialized bytes directly.
253
254    use super::apply_patch;
255    use serde_json::{json, Value};
256
257    /// Top-level key sequence of a JSON object value (no sort).
258    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        // `field` is slot 1 of 4: a leaf-set must NOT move it to the end (a
265        // swap_remove of `field` would pull the last key `d` into slot 1, then
266        // append `field` → [a,d,c,field]). The patched leaf `required` is the
267        // MIDDLE of 3 inner keys (email,required,maxlength): a swap_remove of a
268        // non-last key would shove `maxlength` into the hole → [email,maxlength,
269        // required]. Both are real swap_remove guards (not last-key no-ops).
270        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        // Nested key order under field.validate must survive in full.
286        assert_eq!(
287            keys(&out["field"]["validate"]),
288            vec!["email", "required", "maxlength"]
289        );
290        // Byte oracle: required updated in place, no reordering at any level.
291        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        // replace a.design.class (in place) + add newf (append).
308        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        // Remove the MIDDLE sibling: swap_remove would pull the last key (`z`)
326        // into the hole and scramble order; shift_remove keeps a,c,z order.
327        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        // Remove field.options.max_tags — `field` (slot 0) stays put, and the
341        // surviving inner key order (keyword_min_length) is unchanged.
342        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}