Skip to main content

braze_sync/diff/
catalog.rs

1//! Catalog Schema and Catalog Items diff. See IMPLEMENTATION.md §11.1 / §11.2.
2
3use crate::diff::DiffOp;
4use crate::resource::{Catalog, CatalogField};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
8pub struct CatalogSchemaDiff {
9    pub name: String,
10    pub op: DiffOp<Catalog>,
11    pub field_diffs: Vec<DiffOp<CatalogField>>,
12}
13
14impl CatalogSchemaDiff {
15    pub fn has_changes(&self) -> bool {
16        self.op.is_change() || self.field_diffs.iter().any(|d| d.is_change())
17    }
18
19    pub fn has_destructive(&self) -> bool {
20        self.op.is_destructive() || self.field_diffs.iter().any(|d| d.is_destructive())
21    }
22}
23
24/// Diff a catalog schema between local intent and remote (Braze) state.
25///
26/// Returns `None` only when both sides are absent. The local side is treated
27/// as the "to" / desired state and the remote as the "from".
28pub fn diff_schema(local: Option<&Catalog>, remote: Option<&Catalog>) -> Option<CatalogSchemaDiff> {
29    match (local, remote) {
30        (None, None) => None,
31        (Some(l), None) => Some(CatalogSchemaDiff {
32            name: l.name.clone(),
33            op: DiffOp::Added(l.clone()),
34            field_diffs: vec![],
35        }),
36        (None, Some(r)) => Some(CatalogSchemaDiff {
37            name: r.name.clone(),
38            op: DiffOp::Removed(r.clone()),
39            field_diffs: vec![],
40        }),
41        (Some(l), Some(r)) => {
42            let field_diffs = diff_fields(&l.fields, &r.fields);
43            // Base the top-level op solely on field-level changes.
44            // Description-only differences are not actionable in v0.1.0
45            // (no endpoint to update catalog descriptions), so treating
46            // them as Modified would show "1 changed" with no detail
47            // lines and "Applied 0 change(s)" — confusing for users.
48            let op = if field_diffs.is_empty() {
49                DiffOp::Unchanged
50            } else {
51                DiffOp::Modified {
52                    from: r.clone(),
53                    to: l.clone(),
54                }
55            };
56            Some(CatalogSchemaDiff {
57                name: l.name.clone(),
58                op,
59                field_diffs,
60            })
61        }
62    }
63}
64
65/// Field-level diff. `Unchanged` field-pairs are *not* recorded in the
66/// output to keep diff summaries quiet.
67///
68/// Output ordering: Added and Modified ops come first (sorted by field
69/// name via BTreeMap iteration), followed by Removed ops (also sorted
70/// by field name). This is deterministic across runs and ensures
71/// `apply` processes additions before removals — the safer direction.
72fn diff_fields(local: &[CatalogField], remote: &[CatalogField]) -> Vec<DiffOp<CatalogField>> {
73    use std::collections::BTreeMap;
74    let l: BTreeMap<&String, &CatalogField> = local.iter().map(|f| (&f.name, f)).collect();
75    let r: BTreeMap<&String, &CatalogField> = remote.iter().map(|f| (&f.name, f)).collect();
76
77    let mut ops = Vec::new();
78    for (name, lf) in &l {
79        match r.get(name) {
80            None => ops.push(DiffOp::Added((*lf).clone())),
81            Some(rf) if rf != lf => ops.push(DiffOp::Modified {
82                from: (*rf).clone(),
83                to: (*lf).clone(),
84            }),
85            Some(_) => {} // Unchanged: omit from output
86        }
87    }
88    for (name, rf) in &r {
89        if !l.contains_key(name) {
90            ops.push(DiffOp::Removed((*rf).clone()));
91        }
92    }
93    ops
94}
95
96#[derive(Debug, Clone)]
97pub struct CatalogItemsDiff {
98    pub catalog_name: String,
99    pub added_ids: Vec<String>,
100    pub modified_ids: Vec<String>,
101    pub removed_ids: Vec<String>,
102    pub unchanged_count: usize,
103}
104
105impl CatalogItemsDiff {
106    pub fn has_changes(&self) -> bool {
107        !self.added_ids.is_empty() || !self.modified_ids.is_empty() || !self.removed_ids.is_empty()
108    }
109
110    pub fn has_destructive(&self) -> bool {
111        !self.removed_ids.is_empty()
112    }
113}
114
115/// Diff catalog items by comparing their content hashes. Only the hash
116/// maps are needed — the actual row data is not loaded or compared.
117///
118/// `catalog_name` is passed explicitly so the caller controls which name
119/// appears in the diff — the local map may be empty when the catalog
120/// only exists remotely.
121///
122/// Output id lists are sorted for deterministic display and test assertions.
123pub fn diff_items(
124    catalog_name: &str,
125    local_hashes: &HashMap<String, String>,
126    remote_hashes: &HashMap<String, String>,
127) -> CatalogItemsDiff {
128    let mut added = Vec::new();
129    let mut modified = Vec::new();
130    let mut removed = Vec::new();
131    let mut unchanged: usize = 0;
132
133    for (id, lhash) in local_hashes {
134        match remote_hashes.get(id) {
135            None => added.push(id.clone()),
136            Some(rhash) if rhash != lhash => modified.push(id.clone()),
137            Some(_) => unchanged += 1,
138        }
139    }
140    for id in remote_hashes.keys() {
141        if !local_hashes.contains_key(id) {
142            removed.push(id.clone());
143        }
144    }
145
146    added.sort();
147    modified.sort();
148    removed.sort();
149
150    CatalogItemsDiff {
151        catalog_name: catalog_name.to_string(),
152        added_ids: added,
153        modified_ids: modified,
154        removed_ids: removed,
155        unchanged_count: unchanged,
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::resource::CatalogFieldType;
163
164    fn field(name: &str, t: CatalogFieldType) -> CatalogField {
165        CatalogField {
166            name: name.into(),
167            field_type: t,
168        }
169    }
170
171    fn cat(name: &str, fields: Vec<CatalogField>) -> Catalog {
172        Catalog {
173            name: name.into(),
174            description: None,
175            fields,
176        }
177    }
178
179    #[test]
180    fn both_absent_returns_none() {
181        assert!(diff_schema(None, None).is_none());
182    }
183
184    #[test]
185    fn local_only_is_added() {
186        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
187        let d = diff_schema(Some(&l), None).unwrap();
188        assert!(matches!(d.op, DiffOp::Added(_)));
189        assert!(d.has_changes());
190        assert!(!d.has_destructive());
191    }
192
193    #[test]
194    fn remote_only_is_removed_and_destructive() {
195        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
196        let d = diff_schema(None, Some(&r)).unwrap();
197        assert!(matches!(d.op, DiffOp::Removed(_)));
198        assert!(d.has_changes());
199        assert!(d.has_destructive());
200    }
201
202    #[test]
203    fn equal_catalogs_are_unchanged() {
204        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
205        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
206        let d = diff_schema(Some(&l), Some(&r)).unwrap();
207        assert!(matches!(d.op, DiffOp::Unchanged));
208        assert!(d.field_diffs.is_empty());
209        assert!(!d.has_changes());
210        assert!(!d.has_destructive());
211    }
212
213    #[test]
214    fn added_field_is_non_destructive() {
215        let l = cat(
216            "c",
217            vec![
218                field("id", CatalogFieldType::String),
219                field("score", CatalogFieldType::Number),
220            ],
221        );
222        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
223        let d = diff_schema(Some(&l), Some(&r)).unwrap();
224        assert!(matches!(d.op, DiffOp::Modified { .. }));
225        assert_eq!(d.field_diffs.len(), 1);
226        assert!(matches!(d.field_diffs[0], DiffOp::Added(_)));
227        assert!(d.has_changes());
228        assert!(!d.has_destructive());
229    }
230
231    #[test]
232    fn removed_field_is_destructive() {
233        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
234        let r = cat(
235            "c",
236            vec![
237                field("id", CatalogFieldType::String),
238                field("legacy", CatalogFieldType::String),
239            ],
240        );
241        let d = diff_schema(Some(&l), Some(&r)).unwrap();
242        assert_eq!(d.field_diffs.len(), 1);
243        assert!(matches!(d.field_diffs[0], DiffOp::Removed(_)));
244        assert!(d.has_destructive());
245    }
246
247    #[test]
248    fn type_change_is_modified_field() {
249        let l = cat("c", vec![field("v", CatalogFieldType::Number)]);
250        let r = cat("c", vec![field("v", CatalogFieldType::String)]);
251        let d = diff_schema(Some(&l), Some(&r)).unwrap();
252        assert_eq!(d.field_diffs.len(), 1);
253        assert!(matches!(d.field_diffs[0], DiffOp::Modified { .. }));
254        assert!(d.has_changes());
255        // Type change is not a deletion → not destructive at the field op layer.
256        assert!(!d.has_destructive());
257    }
258
259    #[test]
260    fn unchanged_fields_are_not_recorded() {
261        let l = cat(
262            "c",
263            vec![
264                field("id", CatalogFieldType::String),
265                field("score", CatalogFieldType::Number),
266            ],
267        );
268        let r = cat(
269            "c",
270            vec![
271                field("id", CatalogFieldType::String),
272                field("score", CatalogFieldType::Number),
273            ],
274        );
275        let d = diff_schema(Some(&l), Some(&r)).unwrap();
276        assert!(d.field_diffs.is_empty());
277    }
278
279    #[test]
280    fn field_order_difference_is_not_drift() {
281        let l = cat(
282            "c",
283            vec![
284                field("a", CatalogFieldType::String),
285                field("b", CatalogFieldType::Number),
286            ],
287        );
288        let r = cat(
289            "c",
290            vec![
291                field("b", CatalogFieldType::Number),
292                field("a", CatalogFieldType::String),
293            ],
294        );
295        let d = diff_schema(Some(&l), Some(&r)).unwrap();
296        // Normalized comparison makes field order irrelevant at both the
297        // top-level op and the field-diff layer.
298        assert!(matches!(d.op, DiffOp::Unchanged));
299        assert!(d.field_diffs.is_empty());
300        assert!(!d.has_changes());
301    }
302
303    #[test]
304    fn description_only_difference_is_not_drift() {
305        let l = Catalog {
306            name: "c".into(),
307            description: Some("local description".into()),
308            fields: vec![field("id", CatalogFieldType::String)],
309        };
310        let r = Catalog {
311            name: "c".into(),
312            description: Some("remote description".into()),
313            fields: vec![field("id", CatalogFieldType::String)],
314        };
315        let d = diff_schema(Some(&l), Some(&r)).unwrap();
316        assert!(matches!(d.op, DiffOp::Unchanged));
317        assert!(d.field_diffs.is_empty());
318        assert!(!d.has_changes());
319    }
320
321    #[test]
322    fn items_diff_stub_destructive_when_removed() {
323        let d = CatalogItemsDiff {
324            catalog_name: "c".into(),
325            added_ids: vec![],
326            modified_ids: vec![],
327            removed_ids: vec!["x".into()],
328            unchanged_count: 0,
329        };
330        assert!(d.has_changes());
331        assert!(d.has_destructive());
332    }
333
334    fn hashes(pairs: &[(&str, &str)]) -> HashMap<String, String> {
335        pairs
336            .iter()
337            .map(|(id, h)| (id.to_string(), h.to_string()))
338            .collect()
339    }
340
341    #[test]
342    fn diff_items_both_empty() {
343        let d = diff_items("c", &hashes(&[]), &hashes(&[]));
344        assert!(!d.has_changes());
345        assert_eq!(d.unchanged_count, 0);
346    }
347
348    #[test]
349    fn diff_items_all_added() {
350        let local = hashes(&[("a", "h1"), ("b", "h2")]);
351        let remote = hashes(&[]);
352        let d = diff_items("c", &local, &remote);
353        assert_eq!(d.added_ids, vec!["a", "b"]);
354        assert!(d.modified_ids.is_empty());
355        assert!(d.removed_ids.is_empty());
356        assert_eq!(d.unchanged_count, 0);
357    }
358
359    #[test]
360    fn diff_items_all_removed() {
361        let local = hashes(&[]);
362        let remote = hashes(&[("a", "h1"), ("b", "h2")]);
363        let d = diff_items("c", &local, &remote);
364        assert!(d.added_ids.is_empty());
365        assert!(d.modified_ids.is_empty());
366        assert_eq!(d.removed_ids, vec!["a", "b"]);
367        assert!(d.has_destructive());
368    }
369
370    #[test]
371    fn diff_items_all_unchanged() {
372        let local = hashes(&[("a", "h1"), ("b", "h2")]);
373        let remote = hashes(&[("a", "h1"), ("b", "h2")]);
374        let d = diff_items("c", &local, &remote);
375        assert!(!d.has_changes());
376        assert_eq!(d.unchanged_count, 2);
377    }
378
379    #[test]
380    fn diff_items_mixed() {
381        let local = hashes(&[("a", "h1"), ("b", "h2_new"), ("d", "h4")]);
382        let remote = hashes(&[("a", "h1"), ("b", "h2_old"), ("c", "h3")]);
383        let d = diff_items("c", &local, &remote);
384        assert_eq!(d.added_ids, vec!["d"]);
385        assert_eq!(d.modified_ids, vec!["b"]);
386        assert_eq!(d.removed_ids, vec!["c"]);
387        assert_eq!(d.unchanged_count, 1);
388    }
389
390    #[test]
391    fn diff_items_ids_are_sorted() {
392        let local = hashes(&[("z", "h"), ("a", "h"), ("m", "h")]);
393        let remote = hashes(&[]);
394        let d = diff_items("c", &local, &remote);
395        assert_eq!(d.added_ids, vec!["a", "m", "z"]);
396    }
397
398    #[test]
399    fn diff_items_uses_explicit_catalog_name() {
400        let local = hashes(&[]);
401        let remote = hashes(&[("a", "h1")]);
402        let d = diff_items("remote_only", &local, &remote);
403        assert_eq!(d.catalog_name, "remote_only");
404        assert_eq!(d.removed_ids, vec!["a"]);
405    }
406}