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::{compute_text_diff, opt_str_eq, tags_eq_unordered, DiffOp, TextDiffSummary};
14use crate::resource::ContentBlock;
15use std::collections::BTreeMap;
16
17/// Name → Braze `content_block_id`. Built during diff, consumed by
18/// apply to translate per-name plan entries into the id the update
19/// endpoint requires.
20pub type ContentBlockIdIndex = BTreeMap<String, String>;
21
22#[derive(Debug, Clone)]
23pub struct ContentBlockDiff {
24    pub name: String,
25    pub op: DiffOp<ContentBlock>,
26    pub text_diff: Option<TextDiffSummary>,
27    /// True when present in Braze but missing from Git.
28    pub orphan: bool,
29}
30
31impl ContentBlockDiff {
32    pub fn has_changes(&self) -> bool {
33        self.op.is_change() || self.orphan
34    }
35
36    pub fn is_orphan(&self) -> bool {
37        self.orphan
38    }
39
40    /// Canonical constructor for the remote-only / orphan shape. No
41    /// DELETE API → `op` stays `Unchanged`; callers branch on the
42    /// `orphan` flag instead.
43    pub fn orphan(name: impl Into<String>) -> Self {
44        Self {
45            name: name.into(),
46            op: DiffOp::Unchanged,
47            text_diff: None,
48            orphan: true,
49        }
50    }
51}
52
53/// Returns `None` only when both sides are absent. Local is desired
54/// state, remote is current Braze state.
55pub fn diff(
56    local: Option<&ContentBlock>,
57    remote: Option<&ContentBlock>,
58) -> Option<ContentBlockDiff> {
59    match (local, remote) {
60        (None, None) => None,
61        (Some(l), None) => Some(ContentBlockDiff {
62            name: l.name.clone(),
63            op: DiffOp::Added(l.clone()),
64            text_diff: None,
65            orphan: false,
66        }),
67        (None, Some(r)) => Some(ContentBlockDiff::orphan(&r.name)),
68        (Some(l), Some(r)) => {
69            if syncable_eq(l, r) {
70                Some(ContentBlockDiff {
71                    name: l.name.clone(),
72                    op: DiffOp::Unchanged,
73                    text_diff: None,
74                    orphan: false,
75                })
76            } else {
77                let text_diff = if l.content != r.content {
78                    Some(compute_text_diff(&r.content, &l.content))
79                } else {
80                    None
81                };
82                Some(ContentBlockDiff {
83                    name: l.name.clone(),
84                    op: DiffOp::Modified {
85                        from: r.clone(),
86                        to: l.clone(),
87                    },
88                    text_diff,
89                    orphan: false,
90                })
91            }
92        }
93    }
94}
95
96/// Equality for the fields braze-sync can actually push to Braze.
97/// Excludes `state` — see the module docs. Tags are compared as a
98/// multiset because Braze's content block APIs don't document tag-order
99/// stability across `/info` fetches; an order-sensitive comparison would
100/// surface a reorder as Modified, let apply push the local order back,
101/// and potentially flip on the next diff — the same infinite-drift
102/// failure mode the `state` exclusion exists to prevent.
103fn syncable_eq(a: &ContentBlock, b: &ContentBlock) -> bool {
104    a.name == b.name
105        && opt_str_eq(&a.description, &b.description)
106        && a.content == b.content
107        && tags_eq_unordered(&a.tags, &b.tags)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use crate::resource::ContentBlockState;
114
115    fn cb(name: &str, body: &str) -> ContentBlock {
116        ContentBlock {
117            name: name.into(),
118            description: Some(format!("{name} desc")),
119            content: body.into(),
120            tags: vec!["tag".into()],
121            state: ContentBlockState::Active,
122        }
123    }
124
125    #[test]
126    fn both_absent_returns_none() {
127        assert!(diff(None, None).is_none());
128    }
129
130    #[test]
131    fn local_only_is_added() {
132        let l = cb("promo", "Hello");
133        let d = diff(Some(&l), None).unwrap();
134        assert!(matches!(d.op, DiffOp::Added(_)));
135        assert!(!d.orphan);
136        assert!(d.has_changes());
137    }
138
139    #[test]
140    fn remote_only_is_orphan_not_removed() {
141        let r = cb("legacy", "old body");
142        let d = diff(None, Some(&r)).unwrap();
143        assert!(matches!(d.op, DiffOp::Unchanged));
144        assert!(d.orphan);
145        assert!(d.is_orphan());
146        assert!(d.has_changes());
147        assert!(d.text_diff.is_none());
148    }
149
150    #[test]
151    fn equal_blocks_are_unchanged() {
152        let l = cb("same", "body\n");
153        let r = cb("same", "body\n");
154        let d = diff(Some(&l), Some(&r)).unwrap();
155        assert!(matches!(d.op, DiffOp::Unchanged));
156        assert!(!d.orphan);
157        assert!(!d.has_changes());
158        assert!(d.text_diff.is_none());
159    }
160
161    #[test]
162    fn body_difference_is_modified_with_text_diff() {
163        let l = cb("body_drift", "line a\nline b\nline c\n");
164        let r = cb("body_drift", "line a\nold b\nline c\n");
165        let d = diff(Some(&l), Some(&r)).unwrap();
166        assert!(matches!(d.op, DiffOp::Modified { .. }));
167        let td = d.text_diff.expect("text diff present for body changes");
168        assert_eq!(td.additions, 1);
169        assert_eq!(td.deletions, 1);
170    }
171
172    #[test]
173    fn description_only_change_is_modified_without_text_diff() {
174        let mut l = cb("desc_drift", "same body\n");
175        let mut r = cb("desc_drift", "same body\n");
176        l.description = Some("new".into());
177        r.description = Some("old".into());
178        let d = diff(Some(&l), Some(&r)).unwrap();
179        assert!(matches!(d.op, DiffOp::Modified { .. }));
180        // Body identical → no text diff to show.
181        assert!(d.text_diff.is_none());
182    }
183
184    #[test]
185    fn tags_change_is_modified_without_text_diff() {
186        let mut l = cb("tag_drift", "body\n");
187        let mut r = cb("tag_drift", "body\n");
188        l.tags = vec!["a".into(), "b".into()];
189        r.tags = vec!["a".into()];
190        let d = diff(Some(&l), Some(&r)).unwrap();
191        assert!(matches!(d.op, DiffOp::Modified { .. }));
192        assert!(d.text_diff.is_none());
193    }
194
195    #[test]
196    fn tag_reorder_alone_is_not_drift() {
197        // Braze's content block APIs don't document tag-order stability
198        // across /info fetches, so a reorder must surface as Unchanged.
199        // Otherwise apply would push local order back and the diff could
200        // flip forever — same infinite-drift mode as the state exclusion.
201        let mut l = cb("tag_reorder", "body\n");
202        let mut r = cb("tag_reorder", "body\n");
203        l.tags = vec!["alpha".into(), "beta".into(), "gamma".into()];
204        r.tags = vec!["gamma".into(), "alpha".into(), "beta".into()];
205        let d = diff(Some(&l), Some(&r)).unwrap();
206        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
207        assert!(!d.has_changes());
208    }
209
210    #[test]
211    fn tag_multiset_difference_with_same_length_is_drift() {
212        // Regression guard: sort+eq must not collapse same-length vecs
213        // with different element sets into "equal".
214        let mut l = cb("tag_set", "body\n");
215        let mut r = cb("tag_set", "body\n");
216        l.tags = vec!["a".into(), "b".into()];
217        r.tags = vec!["a".into(), "c".into()];
218        let d = diff(Some(&l), Some(&r)).unwrap();
219        assert!(matches!(d.op, DiffOp::Modified { .. }));
220    }
221
222    #[test]
223    fn state_difference_alone_is_not_drift() {
224        let mut l = cb("state", "body\n");
225        let r = cb("state", "body\n");
226        l.state = ContentBlockState::Draft;
227        let d = diff(Some(&l), Some(&r)).unwrap();
228        assert!(matches!(d.op, DiffOp::Unchanged));
229        assert!(!d.has_changes());
230    }
231
232    #[test]
233    fn empty_local_description_equals_missing_remote_description() {
234        // Regression guard for the `opt_str_eq` fix. A local file with
235        // `description: ""` must diff equal against a remote /info
236        // response that omits the field entirely (which deserializes
237        // as `None`). Otherwise apply would push the empty string,
238        // Braze would normalize it back to no-description, and the
239        // next diff would flip — classic infinite-drift.
240        let mut l = cb("desc_empty_local", "body\n");
241        let mut r = cb("desc_empty_local", "body\n");
242        l.description = Some(String::new());
243        r.description = None;
244        let d = diff(Some(&l), Some(&r)).unwrap();
245        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
246        assert!(!d.has_changes());
247    }
248
249    #[test]
250    fn missing_local_description_equals_empty_remote_description() {
251        // The symmetric case: if Braze ever returns `description: ""`
252        // explicitly (the wire shape is ASSUMED, so this isn't
253        // impossible), a local file without the field must still diff
254        // equal so the two representations don't loop against each other.
255        let mut l = cb("desc_empty_remote", "body\n");
256        let mut r = cb("desc_empty_remote", "body\n");
257        l.description = None;
258        r.description = Some(String::new());
259        let d = diff(Some(&l), Some(&r)).unwrap();
260        assert!(matches!(d.op, DiffOp::Unchanged), "got {:?}", d.op);
261        assert!(!d.has_changes());
262    }
263
264    #[test]
265    fn real_description_difference_is_still_modified() {
266        // `opt_str_eq` must NOT collapse genuinely distinct descriptions.
267        // Guards against a fix that accidentally unwrap-or-empties
268        // both sides into the same non-empty string (which `==` would
269        // otherwise catch, but belt and braces).
270        let mut l = cb("desc_real", "body\n");
271        let mut r = cb("desc_real", "body\n");
272        l.description = Some("new".into());
273        r.description = Some("old".into());
274        let d = diff(Some(&l), Some(&r)).unwrap();
275        assert!(matches!(d.op, DiffOp::Modified { .. }));
276    }
277
278    #[test]
279    fn destructive_count_is_never_set_on_content_blocks() {
280        let r = cb("ghost", "x");
281        let orphan = diff(None, Some(&r)).unwrap();
282        assert!(!orphan.op.is_destructive());
283
284        let l2 = cb("changed", "new");
285        let r2 = cb("changed", "old");
286        let modified = diff(Some(&l2), Some(&r2)).unwrap();
287        assert!(!modified.op.is_destructive());
288    }
289}