Skip to main content

braze_sync/diff/
catalog.rs

1//! Catalog Schema diff. See IMPLEMENTATION.md ยง11.1.
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#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::resource::CatalogFieldType;
99
100    fn field(name: &str, t: CatalogFieldType) -> CatalogField {
101        CatalogField {
102            name: name.into(),
103            field_type: t,
104        }
105    }
106
107    fn cat(name: &str, fields: Vec<CatalogField>) -> Catalog {
108        Catalog {
109            name: name.into(),
110            description: None,
111            fields,
112        }
113    }
114
115    #[test]
116    fn both_absent_returns_none() {
117        assert!(diff_schema(None, None).is_none());
118    }
119
120    #[test]
121    fn local_only_is_added() {
122        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
123        let d = diff_schema(Some(&l), None).unwrap();
124        assert!(matches!(d.op, DiffOp::Added(_)));
125        assert!(d.has_changes());
126        assert!(!d.has_destructive());
127    }
128
129    #[test]
130    fn remote_only_is_removed_and_destructive() {
131        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
132        let d = diff_schema(None, Some(&r)).unwrap();
133        assert!(matches!(d.op, DiffOp::Removed(_)));
134        assert!(d.has_changes());
135        assert!(d.has_destructive());
136    }
137
138    #[test]
139    fn equal_catalogs_are_unchanged() {
140        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
141        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
142        let d = diff_schema(Some(&l), Some(&r)).unwrap();
143        assert!(matches!(d.op, DiffOp::Unchanged));
144        assert!(d.field_diffs.is_empty());
145        assert!(!d.has_changes());
146        assert!(!d.has_destructive());
147    }
148
149    #[test]
150    fn added_field_is_non_destructive() {
151        let l = cat(
152            "c",
153            vec![
154                field("id", CatalogFieldType::String),
155                field("score", CatalogFieldType::Number),
156            ],
157        );
158        let r = cat("c", vec![field("id", CatalogFieldType::String)]);
159        let d = diff_schema(Some(&l), Some(&r)).unwrap();
160        assert!(matches!(d.op, DiffOp::Modified { .. }));
161        assert_eq!(d.field_diffs.len(), 1);
162        assert!(matches!(d.field_diffs[0], DiffOp::Added(_)));
163        assert!(d.has_changes());
164        assert!(!d.has_destructive());
165    }
166
167    #[test]
168    fn removed_field_is_destructive() {
169        let l = cat("c", vec![field("id", CatalogFieldType::String)]);
170        let r = cat(
171            "c",
172            vec![
173                field("id", CatalogFieldType::String),
174                field("legacy", CatalogFieldType::String),
175            ],
176        );
177        let d = diff_schema(Some(&l), Some(&r)).unwrap();
178        assert_eq!(d.field_diffs.len(), 1);
179        assert!(matches!(d.field_diffs[0], DiffOp::Removed(_)));
180        assert!(d.has_destructive());
181    }
182
183    #[test]
184    fn type_change_is_modified_field() {
185        let l = cat("c", vec![field("v", CatalogFieldType::Number)]);
186        let r = cat("c", vec![field("v", CatalogFieldType::String)]);
187        let d = diff_schema(Some(&l), Some(&r)).unwrap();
188        assert_eq!(d.field_diffs.len(), 1);
189        assert!(matches!(d.field_diffs[0], DiffOp::Modified { .. }));
190        assert!(d.has_changes());
191        // Type change is not a deletion โ†’ not destructive at the field op layer.
192        assert!(!d.has_destructive());
193    }
194
195    #[test]
196    fn unchanged_fields_are_not_recorded() {
197        let l = cat(
198            "c",
199            vec![
200                field("id", CatalogFieldType::String),
201                field("score", CatalogFieldType::Number),
202            ],
203        );
204        let r = cat(
205            "c",
206            vec![
207                field("id", CatalogFieldType::String),
208                field("score", CatalogFieldType::Number),
209            ],
210        );
211        let d = diff_schema(Some(&l), Some(&r)).unwrap();
212        assert!(d.field_diffs.is_empty());
213    }
214
215    #[test]
216    fn field_order_difference_is_not_drift() {
217        let l = cat(
218            "c",
219            vec![
220                field("a", CatalogFieldType::String),
221                field("b", CatalogFieldType::Number),
222            ],
223        );
224        let r = cat(
225            "c",
226            vec![
227                field("b", CatalogFieldType::Number),
228                field("a", CatalogFieldType::String),
229            ],
230        );
231        let d = diff_schema(Some(&l), Some(&r)).unwrap();
232        // Normalized comparison makes field order irrelevant at both the
233        // top-level op and the field-diff layer.
234        assert!(matches!(d.op, DiffOp::Unchanged));
235        assert!(d.field_diffs.is_empty());
236        assert!(!d.has_changes());
237    }
238
239    #[test]
240    fn description_only_difference_is_not_drift() {
241        let l = Catalog {
242            name: "c".into(),
243            description: Some("local description".into()),
244            fields: vec![field("id", CatalogFieldType::String)],
245        };
246        let r = Catalog {
247            name: "c".into(),
248            description: Some("remote description".into()),
249            fields: vec![field("id", CatalogFieldType::String)],
250        };
251        let d = diff_schema(Some(&l), Some(&r)).unwrap();
252        assert!(matches!(d.op, DiffOp::Unchanged));
253        assert!(d.field_diffs.is_empty());
254        assert!(!d.has_changes());
255    }
256}