Skip to main content

braze_sync/diff/
content_block_order.rs

1//! Apply-order computation for content blocks.
2//!
3//! ### Why this module exists
4//!
5//! Braze's `POST /content_blocks/create` validates the body at create time
6//! and rejects the request when a `{{content_blocks.${other_block}}}`
7//! include refers to a block that does not yet exist in the workspace.
8//! Worse, the failure surfaces as **HTTP 500** with an opaque body, not
9//! as a 4xx validation error — so a single forward reference halts the
10//! whole apply pass and leaves earlier writes committed.
11//!
12//! Default alphabetical apply order is unaware of these cross-block
13//! dependencies and any `A → B` reference where `A < B` alphabetically
14//! breaks on a fresh workspace. This module parses references out of
15//! the local Liquid bodies, builds a `referrer → target` graph over the
16//! actionable diffs, and re-emits them in dependency order (targets
17//! before referrers). Cycles are reported with named blocks before any
18//! HTTP write fires; references that point outside the actionable set
19//! (already-present blocks, or out-of-scope names) are treated as
20//! no-ops — the referrer becomes a leaf for sort purposes.
21//!
22//! Pure module: no I/O, no Braze calls.
23
24use crate::diff::{DiffOp, ResourceDiff};
25use regex_lite::Regex;
26use std::collections::{BTreeMap, BTreeSet};
27use std::sync::OnceLock;
28
29/// Match `{{content_blocks.${NAME}}}` with whitespace tolerance and an
30/// optional `| id: '...'` filter. The id alias is not load-bearing for
31/// the dependency graph — only `NAME` matters. Inside `${...}` we accept
32/// any run of non-space, non-`}`, non-`|` characters so unusual but
33/// valid Braze names (digits, hyphens) round-trip.
34fn ref_regex() -> &'static Regex {
35    static RE: OnceLock<Regex> = OnceLock::new();
36    RE.get_or_init(|| {
37        Regex::new(r"\{\{\s*content_blocks\.\$\{\s*([^\s}|]+)\s*\}(?:\s*\|[^}]*)?\s*\}\}")
38            .expect("static regex")
39    })
40}
41
42/// Names of content blocks referenced by `body` via the Liquid include
43/// syntax. Order is source-order; duplicates are preserved (callers
44/// dedupe as needed). Self-references are not filtered here — the
45/// caller has the surrounding context to decide.
46pub fn extract_block_references(body: &str) -> Vec<String> {
47    ref_regex()
48        .captures_iter(body)
49        .map(|cap| cap[1].to_string())
50        .collect()
51}
52
53/// A dependency cycle. The path is reported in the order encountered
54/// during DFS, with the closing node repeated so callers can render
55/// `A → B → C → A`.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Cycle {
58    pub path: Vec<String>,
59}
60
61impl std::fmt::Display for Cycle {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.path.join(" → "))
64    }
65}
66
67/// Topologically sort `nodes` by `edges` (referrer → targets). Targets
68/// that are not in `nodes` are treated as no-op edges — the referrer
69/// just becomes a leaf for sort purposes. Output is dependency-order:
70/// targets before referrers. Stable across runs: ties broken by input
71/// order of `nodes`, so the dry-run plan for the same repo state is
72/// reproducible. Edge target lists are walked in given order; callers
73/// who want deterministic cycle-path messages should pre-sort/dedup.
74pub fn topo_sort<'a>(
75    nodes: &'a [String],
76    edges: &'a BTreeMap<String, Vec<String>>,
77) -> Result<Vec<String>, Cycle> {
78    let in_set: BTreeSet<&'a str> = nodes.iter().map(String::as_str).collect();
79    let mut visited: BTreeSet<&'a str> = BTreeSet::new();
80    let mut on_stack: Vec<&'a str> = Vec::new();
81    let mut on_stack_set: BTreeSet<&'a str> = BTreeSet::new();
82    let mut out: Vec<&'a str> = Vec::with_capacity(nodes.len());
83
84    for n in nodes {
85        let n = n.as_str();
86        if !visited.contains(n) {
87            visit(
88                n,
89                edges,
90                &in_set,
91                &mut visited,
92                &mut on_stack,
93                &mut on_stack_set,
94                &mut out,
95            )?;
96        }
97    }
98    Ok(out.into_iter().map(str::to_owned).collect())
99}
100
101fn visit<'a>(
102    node: &'a str,
103    edges: &'a BTreeMap<String, Vec<String>>,
104    in_set: &BTreeSet<&'a str>,
105    visited: &mut BTreeSet<&'a str>,
106    on_stack: &mut Vec<&'a str>,
107    on_stack_set: &mut BTreeSet<&'a str>,
108    out: &mut Vec<&'a str>,
109) -> Result<(), Cycle> {
110    if on_stack_set.contains(node) {
111        // Cycle: cut the prefix before the first occurrence and append
112        // the closing node so the message reads `A → B → C → A`.
113        let start = on_stack
114            .iter()
115            .position(|n| *n == node)
116            .expect("on_stack and on_stack_set must stay in sync");
117        let mut path: Vec<String> = on_stack[start..].iter().map(|s| (*s).to_owned()).collect();
118        path.push(node.to_owned());
119        return Err(Cycle { path });
120    }
121    if visited.contains(node) {
122        return Ok(());
123    }
124    on_stack.push(node);
125    on_stack_set.insert(node);
126
127    if let Some(targets) = edges.get(node) {
128        for t in targets {
129            let t = t.as_str();
130            if in_set.contains(t) {
131                visit(t, edges, in_set, visited, on_stack, on_stack_set, out)?;
132            }
133        }
134    }
135
136    on_stack.pop();
137    on_stack_set.remove(node);
138    visited.insert(node);
139    out.push(node);
140    Ok(())
141}
142
143/// Reorder content_block diffs so that, for every actionable
144/// (`Added`/`Modified`) diff with a `{{content_blocks.${target}}}`
145/// reference whose `target` is also in the actionable set, `target`
146/// appears earlier in the returned list than the referrer.
147///
148/// Non-actionable diffs (`Unchanged`, orphans) keep their relative
149/// position among themselves but are emitted **after** the actionable
150/// block — they don't fire writes, so apply order doesn't affect them
151/// and putting them after the actionable group keeps the dry-run plan
152/// readable: changes first, no-ops last.
153pub fn reorder_content_block_diffs_by_dependency(
154    diffs: Vec<ResourceDiff>,
155) -> Result<Vec<ResourceDiff>, Cycle> {
156    let mut others: Vec<ResourceDiff> = Vec::new();
157    let mut inactionable: Vec<ResourceDiff> = Vec::new();
158    let mut actionable_names: Vec<String> = Vec::new();
159    let mut by_name: BTreeMap<String, ResourceDiff> = BTreeMap::new();
160    let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
161
162    for d in diffs {
163        match d {
164            ResourceDiff::ContentBlock(cb) => {
165                let body: Option<&str> = if cb.orphan {
166                    None
167                } else {
168                    match &cb.op {
169                        DiffOp::Added(b) => Some(b.content.as_str()),
170                        DiffOp::Modified { to, .. } => Some(to.content.as_str()),
171                        _ => None,
172                    }
173                };
174                match body {
175                    Some(b) => {
176                        let name = cb.name.clone();
177                        // Self-references are a Braze-side problem, not
178                        // an apply-order problem — drop them so they
179                        // don't surface as false-positive cycles.
180                        let mut refs: Vec<String> = extract_block_references(b)
181                            .into_iter()
182                            .filter(|t| *t != name)
183                            .collect();
184                        refs.sort();
185                        refs.dedup();
186                        if !refs.is_empty() {
187                            edges.insert(name.clone(), refs);
188                        }
189                        actionable_names.push(name.clone());
190                        by_name.insert(name, ResourceDiff::ContentBlock(cb));
191                    }
192                    None => inactionable.push(ResourceDiff::ContentBlock(cb)),
193                }
194            }
195            other => others.push(other),
196        }
197    }
198
199    let sorted_names = topo_sort(&actionable_names, &edges)?;
200
201    let mut out = Vec::with_capacity(others.len() + sorted_names.len() + inactionable.len());
202    // Non-content_block diffs keep their original relative position
203    // before the content_block group, mirroring `apply::run`'s per-kind
204    // iteration order. Inactionable blocks trail — apply order doesn't
205    // affect them and trailing keeps the dry-run plan readable.
206    out.extend(others);
207    for n in &sorted_names {
208        out.push(
209            by_name
210                .remove(n)
211                .expect("topo_sort returns names from input set"),
212        );
213    }
214    out.extend(inactionable);
215    Ok(out)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::diff::content_block::ContentBlockDiff;
222    use crate::resource::{ContentBlock, ContentBlockState};
223
224    fn cb(name: &str, body: &str) -> ContentBlock {
225        ContentBlock {
226            name: name.into(),
227            description: None,
228            content: body.into(),
229            tags: vec![],
230            state: ContentBlockState::Active,
231        }
232    }
233
234    fn added(name: &str, body: &str) -> ResourceDiff {
235        ResourceDiff::ContentBlock(ContentBlockDiff {
236            name: name.into(),
237            op: DiffOp::Added(cb(name, body)),
238            text_diff: None,
239            orphan: false,
240        })
241    }
242
243    fn unchanged(name: &str) -> ResourceDiff {
244        ResourceDiff::ContentBlock(ContentBlockDiff {
245            name: name.into(),
246            op: DiffOp::Unchanged,
247            text_diff: None,
248            orphan: false,
249        })
250    }
251
252    fn order(diffs: &[ResourceDiff]) -> Vec<&str> {
253        diffs
254            .iter()
255            .filter_map(|d| match d {
256                ResourceDiff::ContentBlock(cb) => Some(cb.name.as_str()),
257                _ => None,
258            })
259            .collect()
260    }
261
262    #[test]
263    fn extracts_a_single_reference() {
264        let body = "Hello {{content_blocks.${other_block} | id: 'cb1'}}!";
265        assert_eq!(extract_block_references(body), vec!["other_block"]);
266    }
267
268    #[test]
269    fn extracts_references_with_whitespace_variations() {
270        let body = "
271            {{content_blocks.${a}}}
272            {{ content_blocks.${ b } }}
273            {{content_blocks.${c} | id: 'x' }}
274            {{ content_blocks.${d}  | id: 'y'}}
275        ";
276        assert_eq!(extract_block_references(body), vec!["a", "b", "c", "d"]);
277    }
278
279    #[test]
280    fn extracts_multiple_references_in_one_body() {
281        let body = "head {{content_blocks.${one}}} mid {{content_blocks.${two}}} tail";
282        assert_eq!(extract_block_references(body), vec!["one", "two"]);
283    }
284
285    #[test]
286    fn ignores_unrelated_liquid() {
287        let body = "{{ user.${first_name} }} {{ campaign.${id} }}";
288        assert!(extract_block_references(body).is_empty());
289    }
290
291    #[test]
292    fn topo_sort_emits_targets_before_referrers() {
293        // A → B (A references B). B must come first.
294        let nodes = vec!["a".to_string(), "b".to_string()];
295        let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
296        edges.insert("a".into(), vec!["b".into()]);
297        let out = topo_sort(&nodes, &edges).unwrap();
298        assert_eq!(out, vec!["b".to_string(), "a".to_string()]);
299    }
300
301    #[test]
302    fn topo_sort_drops_edges_to_unknown_targets() {
303        // A → B, but B isn't in the input set. A is treated as a leaf.
304        let nodes = vec!["a".to_string()];
305        let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
306        edges.insert("a".into(), vec!["b".into()]);
307        let out = topo_sort(&nodes, &edges).unwrap();
308        assert_eq!(out, vec!["a".to_string()]);
309    }
310
311    #[test]
312    fn topo_sort_detects_cycle_and_names_it() {
313        let nodes = vec!["a".to_string(), "b".to_string(), "c".to_string()];
314        let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
315        edges.insert("a".into(), vec!["b".into()]);
316        edges.insert("b".into(), vec!["c".into()]);
317        edges.insert("c".into(), vec!["a".into()]);
318        let err = topo_sort(&nodes, &edges).unwrap_err();
319        // Cycle path closes on itself.
320        assert_eq!(err.path.first(), err.path.last());
321        let s: BTreeSet<&str> = err.path.iter().map(String::as_str).collect();
322        assert!(s.contains("a") && s.contains("b") && s.contains("c"));
323    }
324
325    #[test]
326    fn reorder_puts_dependency_target_before_referrer() {
327        // Spec example: A < B alphabetically, A references B → after
328        // reorder, B is emitted before A.
329        let diffs = vec![
330            added("a_referrer", "see {{content_blocks.${b_target}}}"),
331            added("b_target", "leaf"),
332        ];
333        let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
334        assert_eq!(order(&out), vec!["b_target", "a_referrer"]);
335    }
336
337    #[test]
338    fn reorder_keeps_independent_blocks_in_input_order() {
339        let diffs = vec![
340            added("alpha", "no refs"),
341            added("bravo", "no refs"),
342            added("charlie", "no refs"),
343        ];
344        let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
345        assert_eq!(order(&out), vec!["alpha", "bravo", "charlie"]);
346    }
347
348    #[test]
349    fn reorder_treats_reference_to_unchanged_block_as_leaf() {
350        // The target is already in Braze (Unchanged here stands in for
351        // "remote-known"), so the referrer can be emitted in its
352        // natural slot — no error, no reorder needed.
353        let diffs = vec![
354            added("referrer", "see {{content_blocks.${already_there}}}"),
355            unchanged("already_there"),
356        ];
357        let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
358        let names = order(&out);
359        // Actionable group first, non-actionable after.
360        assert_eq!(names, vec!["referrer", "already_there"]);
361    }
362
363    #[test]
364    fn reorder_reports_cycle_with_block_names() {
365        let diffs = vec![
366            added("a", "{{content_blocks.${b}}}"),
367            added("b", "{{content_blocks.${a}}}"),
368        ];
369        let err = reorder_content_block_diffs_by_dependency(diffs).unwrap_err();
370        let s: BTreeSet<&str> = err.path.iter().map(String::as_str).collect();
371        assert!(s.contains("a") && s.contains("b"));
372    }
373
374    #[test]
375    fn reorder_drops_self_reference_silently() {
376        // A self-reference is a Braze-side problem (the body refers to
377        // itself), not an apply-order problem. Don't surface it as a
378        // false-positive cycle here.
379        let diffs = vec![added("loner", "{{content_blocks.${loner}}}")];
380        let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
381        assert_eq!(order(&out), vec!["loner"]);
382    }
383
384    #[test]
385    fn reorder_handles_diamond_correctly() {
386        //   a
387        //  / \
388        // b   c
389        //  \ /
390        //   d
391        // d must come first; b and c before a.
392        let diffs = vec![
393            added("a", "{{content_blocks.${b}}} and {{content_blocks.${c}}}"),
394            added("b", "{{content_blocks.${d}}}"),
395            added("c", "{{content_blocks.${d}}}"),
396            added("d", "leaf"),
397        ];
398        let out = reorder_content_block_diffs_by_dependency(diffs).unwrap();
399        let names = order(&out);
400        let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
401        assert!(pos("d") < pos("b"));
402        assert!(pos("d") < pos("c"));
403        assert!(pos("b") < pos("a"));
404        assert!(pos("c") < pos("a"));
405    }
406}