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