Skip to main content

braze_sync/diff/
content_block.rs

1//! Content Block diff.
2//!
3//! ### Why `state` is excluded from the syncable comparison
4//!
5//! Braze's `/content_blocks/info` response does not carry a state field
6//! and the braze client defaults every fetched block to `Active`.
7//! Comparing whole-struct equality would make any local file with
8//! `state: draft` diff as Modified forever — the "infinite drift" mode
9//! the orphan design is meant to prevent for resources with no DELETE
10//! endpoint. Excluding `state` keeps the local file free to carry that
11//! metadata for human readers without producing false-positive diffs.
12
13use crate::diff::DiffOp;
14use crate::resource::ContentBlock;
15use similar::{ChangeTag, TextDiff};
16use std::collections::BTreeMap;
17
18/// Name → Braze `content_block_id`. Built during diff, consumed by
19/// apply to translate per-name plan entries into the id the update
20/// endpoint requires.
21pub type ContentBlockIdIndex = BTreeMap<String, String>;
22
23#[derive(Debug, Clone)]
24pub struct ContentBlockDiff {
25    pub name: String,
26    pub op: DiffOp<ContentBlock>,
27    pub text_diff: Option<TextDiffSummary>,
28    /// True when present in Braze but missing from Git.
29    pub orphan: bool,
30}
31
32#[derive(Debug, Clone)]
33pub struct TextDiffSummary {
34    pub additions: usize,
35    pub deletions: usize,
36}
37
38impl ContentBlockDiff {
39    pub fn has_changes(&self) -> bool {
40        self.op.is_change() || self.orphan
41    }
42
43    pub fn is_orphan(&self) -> bool {
44        self.orphan
45    }
46
47    /// Canonical constructor for the remote-only / orphan shape. No
48    /// DELETE API → `op` stays `Unchanged`; callers branch on the
49    /// `orphan` flag instead.
50    pub fn orphan(name: impl Into<String>) -> Self {
51        Self {
52            name: name.into(),
53            op: DiffOp::Unchanged,
54            text_diff: None,
55            orphan: true,
56        }
57    }
58}
59
60/// Returns `None` only when both sides are absent. Local is desired
61/// state, remote is current Braze state.
62pub fn diff(
63    local: Option<&ContentBlock>,
64    remote: Option<&ContentBlock>,
65) -> Option<ContentBlockDiff> {
66    match (local, remote) {
67        (None, None) => None,
68        (Some(l), None) => Some(ContentBlockDiff {
69            name: l.name.clone(),
70            op: DiffOp::Added(l.clone()),
71            text_diff: None,
72            orphan: false,
73        }),
74        (None, Some(r)) => Some(ContentBlockDiff::orphan(&r.name)),
75        (Some(l), Some(r)) => {
76            if syncable_eq(l, r) {
77                Some(ContentBlockDiff {
78                    name: l.name.clone(),
79                    op: DiffOp::Unchanged,
80                    text_diff: None,
81                    orphan: false,
82                })
83            } else {
84                let text_diff = if l.content != r.content {
85                    Some(compute_text_diff(&r.content, &l.content))
86                } else {
87                    None
88                };
89                Some(ContentBlockDiff {
90                    name: l.name.clone(),
91                    op: DiffOp::Modified {
92                        from: r.clone(),
93                        to: l.clone(),
94                    },
95                    text_diff,
96                    orphan: false,
97                })
98            }
99        }
100    }
101}
102
103/// Equality for the fields braze-sync can actually push to Braze.
104/// Excludes `state` — see the module docs. Tags are compared as a
105/// multiset because Braze's content block APIs don't document tag-order
106/// stability across `/info` fetches; an order-sensitive comparison would
107/// surface a reorder as Modified, let apply push the local order back,
108/// and potentially flip on the next diff — the same infinite-drift
109/// failure mode the `state` exclusion exists to prevent.
110fn syncable_eq(a: &ContentBlock, b: &ContentBlock) -> bool {
111    a.name == b.name
112        && desc_eq(&a.description, &b.description)
113        && a.content == b.content
114        && tags_eq_unordered(&a.tags, &b.tags)
115}
116
117/// `None` and `Some("")` both mean "no description" for the purposes
118/// of diffing. Braze's `/info` omits the field entirely when a block
119/// has no description, so it always deserializes as `None`; a local
120/// file that carries `description: ""` (either because an operator
121/// typed an empty value or because YAML round-tripped a missing key
122/// that way) would otherwise diff as Modified forever, push the empty
123/// string on apply, come back as `None` again on the next fetch, and
124/// loop — the same infinite-drift failure mode as the `state`
125/// exclusion. Treating the two as equal here is the narrowest fix
126/// that preserves file fidelity (we deliberately do NOT normalize on
127/// load, so an intentional `description: ""` still round-trips to
128/// disk byte-for-byte).
129fn desc_eq(a: &Option<String>, b: &Option<String>) -> bool {
130    a.as_deref().unwrap_or("") == b.as_deref().unwrap_or("")
131}
132
133/// Multiset equality: same length + same elements after sort. Keeps
134/// duplicate-awareness (unlike a set) so `["a","a"]` vs `["a"]` still
135/// surfaces as a real change on the off chance Braze ever returns
136/// duplicated tags.
137fn tags_eq_unordered(a: &[String], b: &[String]) -> bool {
138    if a.len() != b.len() {
139        return false;
140    }
141    let mut a: Vec<&str> = a.iter().map(String::as_str).collect();
142    let mut b: Vec<&str> = b.iter().map(String::as_str).collect();
143    a.sort_unstable();
144    b.sort_unstable();
145    a == b
146}
147
148fn compute_text_diff(from: &str, to: &str) -> TextDiffSummary {
149    let diff = TextDiff::from_lines(from, to);
150    let mut additions = 0;
151    let mut deletions = 0;
152    for change in diff.iter_all_changes() {
153        match change.tag() {
154            ChangeTag::Insert => additions += 1,
155            ChangeTag::Delete => deletions += 1,
156            ChangeTag::Equal => {}
157        }
158    }
159    TextDiffSummary {
160        additions,
161        deletions,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::resource::ContentBlockState;
169
170    fn cb(name: &str, body: &str) -> ContentBlock {
171        ContentBlock {
172            name: name.into(),
173            description: Some(format!("{name} desc")),
174            content: body.into(),
175            tags: vec!["tag".into()],
176            state: ContentBlockState::Active,
177        }
178    }
179
180    #[test]
181    fn both_absent_returns_none() {
182        assert!(diff(None, None).is_none());
183    }
184
185    #[test]
186    fn local_only_is_added() {
187        let l = cb("promo", "Hello");
188        let d = diff(Some(&l), None).unwrap();
189        assert!(matches!(d.op, DiffOp::Added(_)));
190        assert!(!d.orphan);
191        assert!(d.has_changes());
192    }
193
194    #[test]
195    fn remote_only_is_orphan_not_removed() {
196        let r = cb("legacy", "old body");
197        let d = diff(None, Some(&r)).unwrap();
198        assert!(matches!(d.op, DiffOp::Unchanged));
199        assert!(d.orphan);
200        assert!(d.is_orphan());
201        assert!(d.has_changes());
202        assert!(d.text_diff.is_none());
203    }
204
205    #[test]
206    fn equal_blocks_are_unchanged() {
207        let l = cb("same", "body\n");
208        let r = cb("same", "body\n");
209        let d = diff(Some(&l), Some(&r)).unwrap();
210        assert!(matches!(d.op, DiffOp::Unchanged));
211        assert!(!d.orphan);
212        assert!(!d.has_changes());
213        assert!(d.text_diff.is_none());
214    }
215
216    #[test]
217    fn body_difference_is_modified_with_text_diff() {
218        let l = cb("body_drift", "line a\nline b\nline c\n");
219        let r = cb("body_drift", "line a\nold b\nline c\n");
220        let d = diff(Some(&l), Some(&r)).unwrap();
221        assert!(matches!(d.op, DiffOp::Modified { .. }));
222        let td = d.text_diff.expect("text diff present for body changes");
223        assert_eq!(td.additions, 1);
224        assert_eq!(td.deletions, 1);
225    }
226
227    #[test]
228    fn description_only_change_is_modified_without_text_diff() {
229        let mut l = cb("desc_drift", "same body\n");
230        let mut r = cb("desc_drift", "same body\n");
231        l.description = Some("new".into());
232        r.description = Some("old".into());
233        let d = diff(Some(&l), Some(&r)).unwrap();
234        assert!(matches!(d.op, DiffOp::Modified { .. }));
235        // Body identical → no text diff to show.
236        assert!(d.text_diff.is_none());
237    }
238
239    #[test]
240    fn tags_change_is_modified_without_text_diff() {
241        let mut l = cb("tag_drift", "body\n");
242        let mut r = cb("tag_drift", "body\n");
243        l.tags = vec!["a".into(), "b".into()];
244        r.tags = vec!["a".into()];
245        let d = diff(Some(&l), Some(&r)).unwrap();
246        assert!(matches!(d.op, DiffOp::Modified { .. }));
247        assert!(d.text_diff.is_none());
248    }
249
250    #[test]
251    fn tag_reorder_alone_is_not_drift() {
252        // Braze's content block APIs don't document tag-order stability
253        // across /info fetches, so a reorder must surface as Unchanged.
254        // Otherwise apply would push local order back and the diff could
255        // flip forever — same infinite-drift mode as the state exclusion.
256        let mut l = cb("tag_reorder", "body\n");
257        let mut r = cb("tag_reorder", "body\n");
258        l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
259        r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
260        let d = diff(Some(&l), Some(&r)).unwrap();
261        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
262        assert!(!d.has_changes());
263    }
264
265    #[test]
266    fn tag_multiset_difference_with_same_length_is_drift() {
267        // Regression guard: sort+eq must not collapse same-length vecs
268        // with different element sets into "equal".
269        let mut l = cb("tag_set", "body\n");
270        let mut r = cb("tag_set", "body\n");
271        l.tags = vec!["a".into(), "b".into()];
272        r.tags = vec!["a".into(), "c".into()];
273        let d = diff(Some(&l), Some(&r)).unwrap();
274        assert!(matches!(d.op, DiffOp::Modified { .. }));
275    }
276
277    #[test]
278    fn state_difference_alone_is_not_drift() {
279        let mut l = cb("state", "body\n");
280        let r = cb("state", "body\n");
281        l.state = ContentBlockState::Draft;
282        let d = diff(Some(&l), Some(&r)).unwrap();
283        assert!(matches!(d.op, DiffOp::Unchanged));
284        assert!(!d.has_changes());
285    }
286
287    #[test]
288    fn empty_local_description_equals_missing_remote_description() {
289        // Regression guard for the `desc_eq` fix. A local file with
290        // `description: ""` must diff equal against a remote /info
291        // response that omits the field entirely (which deserializes
292        // as `None`). Otherwise apply would push the empty string,
293        // Braze would normalize it back to no-description, and the
294        // next diff would flip — classic infinite-drift.
295        let mut l = cb("desc_empty_local", "body\n");
296        let mut r = cb("desc_empty_local", "body\n");
297        l.description = Some(String::new());
298        r.description = None;
299        let d = diff(Some(&l), Some(&r)).unwrap();
300        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
301        assert!(!d.has_changes());
302    }
303
304    #[test]
305    fn missing_local_description_equals_empty_remote_description() {
306        // The symmetric case: if Braze ever returns `description: ""`
307        // explicitly (the wire shape is ASSUMED, so this isn't
308        // impossible), a local file without the field must still diff
309        // equal so the two representations don't loop against each other.
310        let mut l = cb("desc_empty_remote", "body\n");
311        let mut r = cb("desc_empty_remote", "body\n");
312        l.description = None;
313        r.description = Some(String::new());
314        let d = diff(Some(&l), Some(&r)).unwrap();
315        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
316        assert!(!d.has_changes());
317    }
318
319    #[test]
320    fn real_description_difference_is_still_modified() {
321        // `desc_eq` must NOT collapse genuinely distinct descriptions.
322        // Guards against a fix that accidentally unwrap-or-empties
323        // both sides into the same non-empty string (which `==` would
324        // otherwise catch, but belt and braces).
325        let mut l = cb("desc_real", "body\n");
326        let mut r = cb("desc_real", "body\n");
327        l.description = Some("new".into());
328        r.description = Some("old".into());
329        let d = diff(Some(&l), Some(&r)).unwrap();
330        assert!(matches!(d.op, DiffOp::Modified { .. }));
331    }
332
333    #[test]
334    fn destructive_count_is_never_set_on_content_blocks() {
335        let r = cb("ghost", "x");
336        let orphan = diff(None, Some(&r)).unwrap();
337        assert!(!orphan.op.is_destructive());
338
339        let l2 = cb("changed", "new");
340        let r2 = cb("changed", "old");
341        let modified = diff(Some(&l2), Some(&r2)).unwrap();
342        assert!(!modified.op.is_destructive());
343    }
344}