Skip to main content

nap_core/merge/
diff.rs

1//! Public diff API — user-facing change inspection.
2//!
3//! The merge engine itself does NOT depend on diff.
4//! Diff is a presentation-layer utility for:
5//!
6//! - **Agent conflict resolution** — "show me what changed"
7//! - **Portals UI** — React Flow display of additions/removals
8//! - **Review workflows** — preview before publishing
9
10use serde::Serialize;
11use serde_json::Value;
12
13use crate::merge::normalization::normalize;
14use crate::merge::path::build_path_map;
15use crate::merge::sdl::SdlDocument;
16
17/// The result of a diff operation.
18#[derive(Debug, Clone, Serialize)]
19pub struct DiffResult {
20    pub changes: Vec<Change>,
21}
22
23impl DiffResult {
24    /// Returns `true` if there are no changes.
25    pub fn is_empty(&self) -> bool {
26        self.changes.is_empty()
27    }
28
29    /// Returns the number of changes.
30    pub fn len(&self) -> usize {
31        self.changes.len()
32    }
33
34    /// Returns changes grouped by operation type.
35    pub fn by_operation(&self) -> (Vec<&Change>, Vec<&Change>, Vec<&Change>) {
36        let mut added = Vec::new();
37        let mut removed = Vec::new();
38        let mut modified = Vec::new();
39        for c in &self.changes {
40            match c.op {
41                ChangeOp::Added => added.push(c),
42                ChangeOp::Removed => removed.push(c),
43                ChangeOp::Modified => modified.push(c),
44            }
45        }
46        (added, removed, modified)
47    }
48}
49
50/// A single change detected by the diff engine.
51#[derive(Debug, Clone, Serialize)]
52pub struct Change {
53    /// The canonical path to the changed value.
54    /// e.g. `"root.properties.homeworld"`
55    pub path: String,
56
57    /// The type of change.
58    pub op: ChangeOp,
59
60    /// The previous value (None for additions).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub old_value: Option<Value>,
63
64    /// The new value (None for removals).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub new_value: Option<Value>,
67}
68
69/// The kind of change operation.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "snake_case")]
72pub enum ChangeOp {
73    /// A new field was added.
74    Added,
75    /// A field was removed (set to null).
76    Removed,
77    /// A field was modified.
78    Modified,
79}
80
81/// Compute the diff between a base document and a candidate document.
82///
83/// # Normalization
84///
85/// The candidate is automatically normalized against the base before diffing.
86/// This ensures that "missing = no change" is correctly interpreted.
87///
88/// # Paths
89///
90/// Paths use canonical form with identity keys when the SDL provides them.
91///
92/// # Arguments
93///
94/// * `base` - The reference document.
95/// * `candidate` - The document to compare against base.
96/// * `schema` - The SDL document (provides identity rules for path resolution).
97///
98/// # Returns
99///
100/// A `DiffResult` containing all detected changes.
101pub fn diff(base: &Value, candidate: &Value, schema: &SdlDocument) -> DiffResult {
102    // Build path maps (with SDL-aware identity resolution)
103    let base_paths = build_path_map(base, schema);
104    let candidate_paths = build_path_map(candidate, schema);
105
106    let mut changes = Vec::new();
107
108    // Find modified and removed paths
109    for (path, base_val) in &base_paths {
110        match candidate_paths.get(path) {
111            None | Some(Value::Null) => {
112                // Path missing or explicitly null in candidate
113                let sub_path = format!("root.{path}");
114                changes.push(Change {
115                    path: sub_path,
116                    op: ChangeOp::Removed,
117                    old_value: Some(base_val.clone()),
118                    new_value: None,
119                });
120            }
121            Some(candidate_val) if candidate_val != base_val => {
122                let sub_path = format!("root.{path}");
123                changes.push(Change {
124                    path: sub_path,
125                    op: ChangeOp::Modified,
126                    old_value: Some(base_val.clone()),
127                    new_value: Some(candidate_val.clone()),
128                });
129            }
130            _ => {
131                // Values are equal — no change
132            }
133        }
134    }
135
136    // Find added paths (in candidate but not in base)
137    for (path, candidate_val) in &candidate_paths {
138        if !base_paths.contains_key(path) && !candidate_val.is_null() {
139            let sub_path = format!("root.{path}");
140            changes.push(Change {
141                path: sub_path,
142                op: ChangeOp::Added,
143                old_value: None,
144                new_value: Some(candidate_val.clone()),
145            });
146        }
147    }
148
149    // Sort by path for deterministic output
150    changes.sort_by(|a, b| a.path.cmp(&b.path));
151
152    DiffResult { changes }
153}
154
155/// Convenience: compute diff with automatic normalization.
156///
157/// Equivalent to:
158/// ```ignore
159/// let norm = normalize(base, candidate);
160/// diff(base, &norm, schema)
161/// ```
162pub fn diff_normalized(base: &Value, candidate: &Value, schema: &SdlDocument) -> DiffResult {
163    let normalized = normalize(base, candidate);
164    diff(base, &normalized, schema)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::merge::sdl::SdlDocument;
171    use serde_json::json;
172
173    fn test_sdl() -> SdlDocument {
174        SdlDocument::from_yaml(
175            r#"
176schema:
177  version: "1.0"
178  required: []
179  properties:
180    name:
181      type: string
182      merge:
183        type: replace
184    tags:
185      type: array
186      merge:
187        type: ordered_unique
188        identity:
189          mode: primitive_value
190    characters:
191      type: array
192      merge:
193        type: ordered_unique
194        identity:
195          mode: key
196          key: id
197"#,
198        )
199        .unwrap()
200    }
201
202    #[test]
203    fn test_diff_modified() {
204        let base = json!({"name": "Luke"});
205        let candidate = json!({"name": "Luke Skywalker"});
206
207        let result = diff(&base, &candidate, &test_sdl());
208        assert_eq!(result.len(), 1);
209        assert_eq!(result.changes[0].op, ChangeOp::Modified);
210        assert_eq!(result.changes[0].path, "root.name");
211        assert_eq!(result.changes[0].old_value, Some(json!("Luke")));
212        assert_eq!(result.changes[0].new_value, Some(json!("Luke Skywalker")));
213    }
214
215    #[test]
216    fn test_diff_added() {
217        let base = json!({"name": "Luke"});
218        let candidate = json!({"name": "Luke", "homeworld": "Tatooine"});
219
220        let result = diff(&base, &candidate, &test_sdl());
221        assert_eq!(result.len(), 1);
222        assert_eq!(result.changes[0].op, ChangeOp::Added);
223        assert_eq!(result.changes[0].path, "root.homeworld");
224        assert_eq!(result.changes[0].old_value, None);
225        assert_eq!(result.changes[0].new_value, Some(json!("Tatooine")));
226    }
227
228    #[test]
229    fn test_diff_removed() {
230        let base = json!({"name": "Luke", "homeworld": "Tatooine"});
231        let candidate = json!({"name": "Luke", "homeworld": null});
232
233        let result = diff(&base, &candidate, &test_sdl());
234        assert_eq!(result.len(), 1);
235        assert_eq!(result.changes[0].op, ChangeOp::Removed);
236        assert_eq!(result.changes[0].path, "root.homeworld");
237    }
238
239    #[test]
240    fn test_diff_no_changes() {
241        let base = json!({"name": "Luke"});
242        let candidate = json!({"name": "Luke"});
243
244        let result = diff(&base, &candidate, &test_sdl());
245        assert!(result.is_empty());
246    }
247
248    #[test]
249    fn test_diff_by_operation() {
250        let base = json!({"a": 1, "b": 2});
251        let candidate = json!({"a": 10, "c": 3});
252
253        let result = diff(&base, &candidate, &test_sdl());
254        let (added, removed, modified) = result.by_operation();
255        assert_eq!(added.len(), 1); // c added
256        assert_eq!(removed.len(), 1); // b removed
257        assert_eq!(modified.len(), 1); // a modified
258    }
259
260    #[test]
261    fn test_diff_deterministic() {
262        let base = json!({"b": 2, "a": 1});
263        let candidate = json!({"a": 10, "c": 3});
264
265        let result1 = diff(&base, &candidate, &test_sdl());
266        let result2 = diff(&base, &candidate, &test_sdl());
267        assert_eq!(result1.changes.len(), result2.changes.len());
268        // Paths should be sorted consistently
269        for (c1, c2) in result1.changes.iter().zip(result2.changes.iter()) {
270            assert_eq!(c1.path, c2.path);
271        }
272    }
273}