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};
5
6#[derive(Debug, Clone)]
7pub struct CatalogSchemaDiff {
8    pub name: String,
9    pub op: DiffOp<Catalog>,
10    pub field_diffs: Vec<DiffOp<CatalogField>>,
11}
12
13impl CatalogSchemaDiff {
14    pub fn has_changes(&self) -> bool {
15        self.op.is_change() || self.field_diffs.iter().any(|d| d.is_change())
16    }
17
18    pub fn has_destructive(&self) -> bool {
19        self.op.is_destructive() || self.field_diffs.iter().any(|d| d.is_destructive())
20    }
21}
22
23/// Diff a catalog schema between local intent and remote (Braze) state.
24///
25/// Returns `None` only when both sides are absent. The local side is treated
26/// as the "to" / desired state and the remote as the "from".
27pub fn diff_schema(local: Option<&Catalog>, remote: Option<&Catalog>) -> Option<CatalogSchemaDiff> {
28    match (local, remote) {
29        (None, None) => None,
30        (Some(l), None) => Some(CatalogSchemaDiff {
31            name: l.name.clone(),
32            op: DiffOp::Added(l.clone()),
33            field_diffs: vec![],
34        }),
35        (None, Some(r)) => Some(CatalogSchemaDiff {
36            name: r.name.clone(),
37            op: DiffOp::Removed(r.clone()),
38            field_diffs: vec![],
39        }),
40        (Some(l), Some(r)) => {
41            let field_diffs = diff_fields(&l.fields, &r.fields);
42            // Base the top-level op solely on field-level changes.
43            // Description-only differences are not actionable in v0.1.0
44            // (no endpoint to update catalog descriptions), so treating
45            // them as Modified would show "1 changed" with no detail
46            // lines and "Applied 0 change(s)" — confusing for users.
47            let op = if field_diffs.is_empty() {
48                DiffOp::Unchanged
49            } else {
50                DiffOp::Modified {
51                    from: r.clone(),
52                    to: l.clone(),
53                }
54            };
55            Some(CatalogSchemaDiff {
56                name: l.name.clone(),
57                op,
58                field_diffs,
59            })
60        }
61    }
62}
63
64/// Field-level diff. `Unchanged` field-pairs are *not* recorded in the
65/// output to keep diff summaries quiet.
66///
67/// Output ordering: Added and Modified ops come first (sorted by field
68/// name via BTreeMap iteration), followed by Removed ops (also sorted
69/// by field name). This is deterministic across runs and ensures
70/// `apply` processes additions before removals — the safer direction.
71fn diff_fields(local: &[CatalogField], remote: &[CatalogField]) -> Vec<DiffOp<CatalogField>> {
72    use std::collections::BTreeMap;
73    let l: BTreeMap<&String, &CatalogField> = local.iter().map(|f| (&f.name, f)).collect();
74    let r: BTreeMap<&String, &CatalogField> = remote.iter().map(|f| (&f.name, f)).collect();
75
76    let mut ops = Vec::new();
77    for (name, lf) in &l {
78        match r.get(name) {
79            None => ops.push(DiffOp::Added((*lf).clone())),
80            Some(rf) if rf != lf => ops.push(DiffOp::Modified {
81                from: (*rf).clone(),
82                to: (*lf).clone(),
83            }),
84            Some(_) => {} // Unchanged: omit from output
85        }
86    }
87    for (name, rf) in &r {
88        if !l.contains_key(name) {
89            ops.push(DiffOp::Removed((*rf).clone()));
90        }
91    }
92    ops
93}
94
95#[derive(Debug, Clone)]
96pub struct CatalogItemsDiff {
97    pub catalog_name: String,
98    pub added_ids: Vec<String>,
99    pub modified_ids: Vec<String>,
100    pub removed_ids: Vec<String>,
101    pub unchanged_count: usize,
102}
103
104impl CatalogItemsDiff {
105    pub fn has_changes(&self) -> bool {
106        !self.added_ids.is_empty() || !self.modified_ids.is_empty() || !self.removed_ids.is_empty()
107    }
108
109    pub fn has_destructive(&self) -> bool {
110        !self.removed_ids.is_empty()
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::resource::CatalogFieldType;
118
119    fn field(name: &str, t: CatalogFieldType) -> CatalogField {
120        CatalogField {
121            name: name.into(),
122            field_type: t,
123        }
124    }
125
126    fn cat(name: &str, fields: Vec<CatalogField>) -> Catalog {
127        Catalog {
128            name: name.into(),
129            description: None,
130            fields,
131        }
132    }
133
134    #[test]
135    fn both_absent_returns_none() {
136        assert!(diff_schema(None, None).is_none());
137    }
138
139    #[test]
140    fn local_only_is_added() {
141        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
142        let d = diff_schema(Some(&l), None).unwrap();
143        assert!(matches!(d.op, DiffOp::Added(_)));
144        assert!(d.has_changes());
145        assert!(!d.has_destructive());
146    }
147
148    #[test]
149    fn remote_only_is_removed_and_destructive() {
150        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
151        let d = diff_schema(None, Some(&r)).unwrap();
152        assert!(matches!(d.op, DiffOp::Removed(_)));
153        assert!(d.has_changes());
154        assert!(d.has_destructive());
155    }
156
157    #[test]
158    fn equal_catalogs_are_unchanged() {
159        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
160        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
161        let d = diff_schema(Some(&l), Some(&r)).unwrap();
162        assert!(matches!(d.op, DiffOp::Unchanged));
163        assert!(d.field_diffs.is_empty());
164        assert!(!d.has_changes());
165        assert!(!d.has_destructive());
166    }
167
168    #[test]
169    fn added_field_is_non_destructive() {
170        let l = cat(
171            "c",
172            vec![
173                field("id", CatalogFieldType::String),
174                field("score", CatalogFieldType::Number),
175            ],
176        );
177        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
178        let d = diff_schema(Some(&l), Some(&r)).unwrap();
179        assert!(matches!(d.op, DiffOp::Modified { .. }));
180        assert_eq!(d.field_diffs.len(), 1);
181        assert!(matches!(d.field_diffs[0], DiffOp::Added(_)));
182        assert!(d.has_changes());
183        assert!(!d.has_destructive());
184    }
185
186    #[test]
187    fn removed_field_is_destructive() {
188        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
189        let r = cat(
190            "c",
191            vec![
192                field("id", CatalogFieldType::String),
193                field("legacy", CatalogFieldType::String),
194            ],
195        );
196        let d = diff_schema(Some(&l), Some(&r)).unwrap();
197        assert_eq!(d.field_diffs.len(), 1);
198        assert!(matches!(d.field_diffs[0], DiffOp::Removed(_)));
199        assert!(d.has_destructive());
200    }
201
202    #[test]
203    fn type_change_is_modified_field() {
204        let l = cat("c", vec![field("v", CatalogFieldType::Number)]);
205        let r = cat("c", vec![field("v", CatalogFieldType::String)]);
206        let d = diff_schema(Some(&l), Some(&r)).unwrap();
207        assert_eq!(d.field_diffs.len(), 1);
208        assert!(matches!(d.field_diffs[0], DiffOp::Modified { .. }));
209        assert!(d.has_changes());
210        // Type change is not a deletion → not destructive at the field op layer.
211        assert!(!d.has_destructive());
212    }
213
214    #[test]
215    fn unchanged_fields_are_not_recorded() {
216        let l = cat(
217            "c",
218            vec![
219                field("id", CatalogFieldType::String),
220                field("score", CatalogFieldType::Number),
221            ],
222        );
223        let r = cat(
224            "c",
225            vec![
226                field("id", CatalogFieldType::String),
227                field("score", CatalogFieldType::Number),
228            ],
229        );
230        let d = diff_schema(Some(&l), Some(&r)).unwrap();
231        assert!(d.field_diffs.is_empty());
232    }
233
234    #[test]
235    fn field_order_difference_is_not_drift() {
236        let l = cat(
237            "c",
238            vec![
239                field("a", CatalogFieldType::String),
240                field("b", CatalogFieldType::Number),
241            ],
242        );
243        let r = cat(
244            "c",
245            vec![
246                field("b", CatalogFieldType::Number),
247                field("a", CatalogFieldType::String),
248            ],
249        );
250        let d = diff_schema(Some(&l), Some(&r)).unwrap();
251        // Normalized comparison makes field order irrelevant at both the
252        // top-level op and the field-diff layer.
253        assert!(matches!(d.op, DiffOp::Unchanged));
254        assert!(d.field_diffs.is_empty());
255        assert!(!d.has_changes());
256    }
257
258    #[test]
259    fn description_only_difference_is_not_drift() {
260        let l = Catalog {
261            name: "c".into(),
262            description: Some("local description".into()),
263            fields: vec![field("id", CatalogFieldType::String)],
264        };
265        let r = Catalog {
266            name: "c".into(),
267            description: Some("remote description".into()),
268            fields: vec![field("id", CatalogFieldType::String)],
269        };
270        let d = diff_schema(Some(&l), Some(&r)).unwrap();
271        assert!(matches!(d.op, DiffOp::Unchanged));
272        assert!(d.field_diffs.is_empty());
273        assert!(!d.has_changes());
274    }
275
276    #[test]
277    fn items_diff_stub_destructive_when_removed() {
278        let d = CatalogItemsDiff {
279            catalog_name: "c".into(),
280            added_ids: vec![],
281            modified_ids: vec![],
282            removed_ids: vec!["x".into()],
283            unchanged_count: 0,
284        };
285        assert!(d.has_changes());
286        assert!(d.has_destructive());
287    }
288}