Skip to main content

nap_core/merge/
normalization.rs

1//! Pre-diff normalization engine.
2//!
3//! # Why normalize?
4//!
5//! Before generating diffs, the merge engine fills "missing" paths in
6//! the candidate document from the base document.  This ensures that
7//! omission (not having an opinion about a field) is not misinterpreted
8//! as deletion.
9//!
10//! # Rule
11//!
12//! - **missing** = no change → copy from base
13//! - **null** = explicit deletion → leave `null`
14//! - **present** = modified → leave unchanged
15//!
16//! This is a **protocol invariant** — it is not configurable, not
17//! expressed in SDL, and must never vary across implementations.
18
19use serde_json::Value;
20
21/// Normalize a candidate document against a base document.
22///
23/// For every leaf path in `base`:
24/// - If the path is missing in `candidate`, copy the base value.
25/// - If the path exists as `null`, leave `null` (explicit deletion).
26/// - If the path exists with a value, leave unchanged.
27///
28/// Paths in `candidate` that do not exist in `base` are preserved
29/// (they represent additions).
30///
31/// # Returns
32///
33/// A new `Value` representing the normalized candidate.
34pub fn normalize(base: &Value, candidate: &Value) -> Value {
35    // Walk both trees simultaneously and produce a merged result.
36    merge_missing(base, candidate)
37}
38
39/// Recursively merge `base` into `candidate` for missing paths only.
40fn merge_missing(base: &Value, candidate: &Value) -> Value {
41    match (base, candidate) {
42        // Both are objects — recurse into shared keys
43        (Value::Object(base_map), Value::Object(candidate_map)) => {
44            let mut result = candidate_map.clone();
45
46            for (key, base_val) in base_map {
47                match result.get(key) {
48                    // Path missing in candidate → copy from base
49                    None => {
50                        result.insert(key.clone(), base_val.clone());
51                    }
52                    // Path exists in candidate → recurse deeper
53                    Some(candidate_val) => {
54                        let merged = merge_missing(base_val, candidate_val);
55                        // Only update if the merge actually changed something
56                        // (avoids unnecessary cloning of identical values)
57                        if &merged != candidate_val {
58                            result.insert(key.clone(), merged);
59                        }
60                    }
61                }
62            }
63
64            Value::Object(result)
65        }
66
67        // Base is object, candidate is missing/deeper structure mismatch
68        // → candidate wins (it's not an object, so we can't recurse)
69        (Value::Object(_), _) => candidate.clone(),
70
71        // At least one is a non-object → leaf, no further recursion needed
72        // Candidate value stands (whether it matches base or differs)
73        _ => candidate.clone(),
74    }
75}
76
77/// Check if a value is the JSON `null` literal.
78pub fn is_explicit_null(value: &Value) -> bool {
79    value.is_null()
80}
81
82/// Extract a value from an optional JSON value reference.
83/// Returns `None` for missing values and `Some(Value::Null)` for explicit nulls.
84pub fn extract_optional(value: Option<&Value>) -> OptionalValue {
85    match value {
86        None => OptionalValue::Missing,
87        Some(v) if v.is_null() => OptionalValue::Null,
88        Some(v) => OptionalValue::Present(v.clone()),
89    }
90}
91
92/// Represents the presence semantics of a value.
93#[derive(Debug, Clone, PartialEq)]
94pub enum OptionalValue {
95    /// Path does not exist in the document.
96    Missing,
97    /// Path explicitly set to null (deletion intent).
98    Null,
99    /// Path has an actual value.
100    Present(Value),
101}
102
103// ── Tests ──────────────────────────────────────────────────────────────
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use serde_json::json;
109
110    #[test]
111    fn test_missing_field_copied_from_base() {
112        let base = json!({"name": "Obi Wan", "homeworld": "Stewjon"});
113        let candidate = json!({"name": "Obi Wan Kenobi"});
114
115        let result = normalize(&base, &candidate);
116
117        assert_eq!(result.get("name"), Some(&json!("Obi Wan Kenobi")));
118        assert_eq!(result.get("homeworld"), Some(&json!("Stewjon")));
119    }
120
121    #[test]
122    fn test_explicit_null_preserved() {
123        let base = json!({"homeworld": "Stewjon"});
124        let candidate = json!({"homeworld": null});
125
126        let result = normalize(&base, &candidate);
127
128        assert_eq!(result.get("homeworld"), Some(&Value::Null));
129    }
130
131    #[test]
132    fn test_present_field_unchanged() {
133        let base = json!({"name": "Luke", "species": "human"});
134        let candidate = json!({"name": "Luke Skywalker", "species": "human"});
135
136        let result = normalize(&base, &candidate);
137
138        assert_eq!(result.get("name"), Some(&json!("Luke Skywalker")));
139        assert_eq!(result.get("species"), Some(&json!("human")));
140    }
141
142    #[test]
143    fn test_additions_are_preserved() {
144        let base = json!({"name": "Luke"});
145        let candidate = json!({"name": "Luke", "homeworld": "Tatooine"});
146
147        let result = normalize(&base, &candidate);
148
149        assert_eq!(result.get("name"), Some(&json!("Luke")));
150        assert_eq!(result.get("homeworld"), Some(&json!("Tatooine")));
151    }
152
153    #[test]
154    fn test_nested_missing_filled() {
155        let base = json!({
156            "character": {
157                "name": "Obi Wan",
158                "homeworld": "Stewjon"
159            }
160        });
161        let candidate = json!({
162            "character": {
163                "name": "Obi Wan Kenobi"
164            }
165        });
166
167        let result = normalize(&base, &candidate);
168
169        assert_eq!(
170            result.pointer("/character/name"),
171            Some(&json!("Obi Wan Kenobi"))
172        );
173        assert_eq!(
174            result.pointer("/character/homeworld"),
175            Some(&json!("Stewjon"))
176        );
177    }
178
179    #[test]
180    fn test_nested_null_preserved() {
181        let base = json!({
182            "character": {
183                "homeworld": "Stewjon",
184                "species": "human"
185            }
186        });
187        let candidate = json!({
188            "character": {
189                "homeworld": null,
190                "species": "human"
191            }
192        });
193
194        let result = normalize(&base, &candidate);
195
196        assert_eq!(result.pointer("/character/homeworld"), Some(&Value::Null));
197        assert_eq!(result.pointer("/character/species"), Some(&json!("human")));
198    }
199
200    #[test]
201    fn test_all_equal_no_change() {
202        let base = json!({"a": 1, "b": 2});
203        let candidate = json!({"a": 1, "b": 2});
204
205        let result = normalize(&base, &candidate);
206
207        assert_eq!(result, candidate);
208    }
209
210    #[test]
211    fn test_candidate_wins_for_new_paths() {
212        let base = json!({"a": 1});
213        let candidate = json!({"a": 1, "b": 2, "c": 3});
214
215        let result = normalize(&base, &candidate);
216
217        assert_eq!(result, candidate);
218    }
219
220    #[test]
221    fn test_empty_base_returns_candidate_unchanged() {
222        let base = json!({});
223        let candidate = json!({"a": 1, "b": 2});
224
225        let result = normalize(&base, &candidate);
226
227        assert_eq!(result, candidate);
228    }
229
230    #[test]
231    fn test_type_mismatch_candidate_wins() {
232        // If base has an object but candidate has a string at the same path,
233        // candidate wins (can't recurse into different types).
234        let base = json!({"nested": {"key": "value"}});
235        let candidate = json!({"nested": "replaced"});
236
237        let result = normalize(&base, &candidate);
238
239        assert_eq!(result, candidate);
240    }
241
242    #[test]
243    fn test_optional_value_semantics() {
244        assert_eq!(extract_optional(None), OptionalValue::Missing);
245        assert_eq!(extract_optional(Some(&Value::Null)), OptionalValue::Null);
246        assert_eq!(
247            extract_optional(Some(&json!("hello"))),
248            OptionalValue::Present(json!("hello"))
249        );
250        assert!(is_explicit_null(&Value::Null));
251        assert!(!is_explicit_null(&json!("hello")));
252    }
253}