Skip to main content

braze_sync/values/
integration.rs

1//! Wiring layer between [`crate::values`] and the diff / apply pipeline.
2
3use std::collections::BTreeMap;
4
5use crate::resource::{ContentBlock, EmailTemplate};
6use crate::values::braze_managed::prepare_field;
7use crate::values::placeholder::{
8    find_suspicious_placeholders, resolve_placeholders, LookupKey, ResolutionError,
9};
10use crate::values::templatize::FieldKind;
11
12/// One resource's worth of placeholder failures, ready to be folded into
13/// a top-level error message.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ResolutionFailure {
16    pub resource_kind: &'static str,
17    pub resource_name: String,
18    /// `Some(field)` for email_template fields, `None` for content_block.
19    pub field: Option<&'static str>,
20    pub errors: Vec<ResolutionError>,
21}
22
23/// Resolve every `__BRAZESYNC.*__` in `cb.content`.
24///
25/// `remote` provides the live lid / cb_id values; pass `None` for new
26/// resources (lid falls back to the placeholder key, cb_id filter is
27/// stripped — see [`prepare_field`]).
28pub fn resolve_content_block_with_remote(
29    cb: &mut ContentBlock,
30    remote: Option<&ContentBlock>,
31) -> std::result::Result<(), ResolutionFailure> {
32    warn_suspicious("content_block", &cb.name, None, &cb.content)?;
33    if !body_has_placeholders(&cb.content) {
34        return Ok(());
35    }
36    let prep = prepare_field(
37        &cb.content,
38        remote.map(|r| r.content.as_str()),
39        FieldKind::ContentBlock,
40    );
41    emit_prep_warnings("content_block", &cb.name, None, &prep.warnings);
42    let lookup: BTreeMap<LookupKey, String> = prep.additions;
43    match resolve_placeholders(&prep.body, &lookup) {
44        Ok(resolved) => {
45            cb.content = resolved;
46            Ok(())
47        }
48        Err(errors) => Err(ResolutionFailure {
49            resource_kind: "content_block",
50            resource_name: cb.name.clone(),
51            field: None,
52            errors,
53        }),
54    }
55}
56
57/// Resolve placeholders across every Liquid-bearing field of `et`.
58pub fn resolve_email_template_with_remote(
59    et: &mut EmailTemplate,
60    remote: Option<&EmailTemplate>,
61) -> std::result::Result<(), Vec<ResolutionFailure>> {
62    let mut failures: Vec<ResolutionFailure> = Vec::new();
63
64    macro_rules! resolve_field {
65        ($field_name:expr, $field_kind:expr, $accessor:expr, $remote_accessor:expr) => {{
66            let body: &str = $accessor;
67            if let Err(f) = warn_suspicious("email_template", &et.name, Some($field_name), body) {
68                failures.push(f);
69            }
70            if body_has_placeholders(body) {
71                let prep = prepare_field(body, $remote_accessor, $field_kind);
72                emit_prep_warnings(
73                    "email_template",
74                    &et.name,
75                    Some($field_name),
76                    &prep.warnings,
77                );
78                match resolve_placeholders(&prep.body, &prep.additions) {
79                    Ok(resolved) => Some(resolved),
80                    Err(errors) => {
81                        failures.push(ResolutionFailure {
82                            resource_kind: "email_template",
83                            resource_name: et.name.clone(),
84                            field: Some($field_name),
85                            errors,
86                        });
87                        None
88                    }
89                }
90            } else {
91                None
92            }
93        }};
94    }
95
96    let new_subject = resolve_field!(
97        "subject",
98        FieldKind::EmailSubject,
99        et.subject.as_str(),
100        remote.map(|r| r.subject.as_str())
101    );
102    let new_body_html = resolve_field!(
103        "body_html",
104        FieldKind::EmailHtmlBody,
105        et.body_html.as_str(),
106        remote.map(|r| r.body_html.as_str())
107    );
108    let new_body_plaintext = resolve_field!(
109        "body_plaintext",
110        FieldKind::EmailPlainBody,
111        et.body_plaintext.as_str(),
112        remote.map(|r| r.body_plaintext.as_str())
113    );
114    let new_preheader = match et.preheader.as_deref() {
115        Some(s) => resolve_field!(
116            "preheader",
117            FieldKind::EmailPreheader,
118            s,
119            remote.and_then(|r| r.preheader.as_deref())
120        ),
121        None => None,
122    };
123
124    if !failures.is_empty() {
125        return Err(failures);
126    }
127
128    if let Some(v) = new_subject {
129        et.subject = v;
130    }
131    if let Some(v) = new_body_html {
132        et.body_html = v;
133    }
134    if let Some(v) = new_body_plaintext {
135        et.body_plaintext = v;
136    }
137    if let Some(v) = new_preheader {
138        et.preheader = Some(v);
139    }
140    Ok(())
141}
142
143fn body_has_placeholders(body: &str) -> bool {
144    body.contains("__BRAZESYNC.")
145}
146
147/// Surface diagnostic warnings collected by [`prepare_field`] to stderr
148/// with a resource-qualified scope. Silent dead-letter previously hid
149/// the most actionable info (which URL anchor failed, which subject lid
150/// fell back, which cb_id filter was stripped) — without this the user
151/// only sees a generic "unresolved placeholder" failure downstream.
152fn emit_prep_warnings(
153    kind: &'static str,
154    name: &str,
155    field: Option<&'static str>,
156    warnings: &[String],
157) {
158    if warnings.is_empty() {
159        return;
160    }
161    let scope = match field {
162        Some(f) => format!("{kind} '{name}' ({f})"),
163        None => format!("{kind} '{name}'"),
164    };
165    for w in warnings {
166        eprintln!("warning: {scope}: {w}");
167    }
168}
169
170fn warn_suspicious(
171    kind: &'static str,
172    name: &str,
173    field: Option<&'static str>,
174    body: &str,
175) -> Result<(), ResolutionFailure> {
176    if !body.contains("__") {
177        return Ok(());
178    }
179    let suspects = find_suspicious_placeholders(body);
180    if suspects.is_empty() {
181        return Ok(());
182    }
183    let scope = match field {
184        Some(f) => format!("{kind} '{name}' ({f})"),
185        None => format!("{kind} '{name}'"),
186    };
187    let mut retired: Vec<ResolutionError> = Vec::new();
188    for s in &suspects {
189        if s.starts_with("__BRAZESYNC.") {
190            retired.push(ResolutionError::RetiredNamespace { token: s.clone() });
191        } else {
192            eprintln!("warning: {scope}: suspicious placeholder-like token {s}");
193        }
194    }
195    if retired.is_empty() {
196        Ok(())
197    } else {
198        Err(ResolutionFailure {
199            resource_kind: kind,
200            resource_name: name.to_string(),
201            field,
202            errors: retired,
203        })
204    }
205}
206
207/// Format aggregated failures into a single human-readable error.
208pub fn format_failures(failures: &[ResolutionFailure]) -> crate::error::Error {
209    let mut msg = String::new();
210    msg.push_str(&format!(
211        "Cannot continue: {} placeholder resolution failure(s)\n",
212        failures.iter().map(|f| f.errors.len()).sum::<usize>(),
213    ));
214    for f in failures {
215        let scope = match f.field {
216            Some(field) => format!("  {} '{}' ({}):", f.resource_kind, f.resource_name, field),
217            None => format!("  {} '{}':", f.resource_kind, f.resource_name),
218        };
219        msg.push_str(&scope);
220        msg.push('\n');
221        for e in &f.errors {
222            match e {
223                ResolutionError::UnknownKey { ty, key, start } => {
224                    msg.push_str(&format!(
225                        "    - offset {}: __BRAZESYNC.{}.{}__ (no anchor match in remote body)\n",
226                        start,
227                        ty.as_str(),
228                        key,
229                    ));
230                }
231                ResolutionError::DuplicateLidKey { key, occurrences } => {
232                    let offsets = occurrences
233                        .iter()
234                        .map(|o| o.to_string())
235                        .collect::<Vec<_>>()
236                        .join(", ");
237                    msg.push_str(&format!(
238                        "    - __BRAZESYNC.lid.{key}__ referenced {} times (offsets {offsets}); \
239                         lid IDs are per-click-context — use a distinct key per occurrence\n",
240                        occurrences.len(),
241                    ));
242                }
243                ResolutionError::RetiredNamespace { token } => {
244                    msg.push_str(&format!(
245                        "    - {token}: retired placeholder namespace \
246                         (custom/global removed in v0.15; use literal values)\n",
247                    ));
248                }
249            }
250        }
251    }
252    crate::error::Error::Config(msg)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::resource::content_block::ContentBlockState;
259
260    fn cb(name: &str, content: &str) -> ContentBlock {
261        ContentBlock {
262            name: name.into(),
263            description: None,
264            content: content.into(),
265            tags: Vec::new(),
266            state: ContentBlockState::Active,
267        }
268    }
269
270    fn et(name: &str) -> EmailTemplate {
271        EmailTemplate {
272            name: name.into(),
273            subject: String::new(),
274            body_html: String::new(),
275            body_plaintext: String::new(),
276            description: None,
277            preheader: None,
278            should_inline_css: None,
279            tags: Vec::new(),
280        }
281    }
282
283    #[test]
284    fn no_placeholders_skips_resolution() {
285        let mut block = cb("plain", "<p>hi there</p>");
286        resolve_content_block_with_remote(&mut block, None).unwrap();
287        assert_eq!(block.content, "<p>hi there</p>");
288    }
289
290    #[test]
291    fn content_block_resolves_lid_from_remote() {
292        let mut block = cb(
293            "promo",
294            r#"<a href="https://x.com/cta">__BRAZESYNC.lid.cta__</a>"#,
295        );
296        let remote = cb(
297            "promo",
298            r#"<a href="https://x.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#,
299        );
300        resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap();
301        assert!(block.content.contains(">newlidvalue1<"));
302    }
303
304    #[test]
305    fn new_resource_lid_fallback_uses_placeholder_key() {
306        let mut block = cb(
307            "promo",
308            r#"<a href="https://x.com/spring">__BRAZESYNC.lid.spring_sale__</a>"#,
309        );
310        resolve_content_block_with_remote(&mut block, None).unwrap();
311        assert!(block.content.contains(">spring_sale<"));
312    }
313
314    #[test]
315    fn new_resource_cb_id_filter_is_stripped() {
316        let mut block = cb(
317            "page",
318            "{{content_blocks.${promo} | id: '__BRAZESYNC.cb_id.promo__'}}",
319        );
320        resolve_content_block_with_remote(&mut block, None).unwrap();
321        assert_eq!(block.content, "{{content_blocks.${promo}}}");
322    }
323
324    #[test]
325    fn email_template_resolves_per_field() {
326        let mut t = et("welcome");
327        t.body_html = r#"<a href="https://x.com/cta">__BRAZESYNC.lid.cta__</a>"#.into();
328        let mut remote = et("welcome");
329        remote.body_html = r#"<a href="https://x.com/cta">{{x | lid: 'newhtmllidx'}}</a>"#.into();
330        resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
331        assert!(t.body_html.contains(">newhtmllidx<"));
332    }
333
334    #[test]
335    fn missing_remote_anchor_surfaces_as_failure() {
336        let mut block = cb(
337            "promo",
338            r#"<a href="https://x.com/cta">__BRAZESYNC.lid.cta__</a>"#,
339        );
340        let remote = cb("promo", "<p>no anchor here</p>");
341        let err = resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap_err();
342        assert_eq!(err.errors.len(), 1);
343    }
344
345    #[test]
346    fn subject_lid_resolves_positionally_from_remote() {
347        let mut t = et("promo");
348        t.subject = "Spring sale {{x | lid: '__BRAZESYNC.lid.subject_lid__'}}".into();
349        let mut remote = et("promo");
350        remote.subject = "Spring sale {{x | lid: 'subjectlid1'}}".into();
351        resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
352        assert!(t.subject.contains("'subjectlid1'"));
353    }
354
355    #[test]
356    fn subject_lid_count_mismatch_still_resolves_overlap() {
357        let mut t = et("promo");
358        t.subject = "{{x | lid: '__BRAZESYNC.lid.a__'}} {{y | lid: '__BRAZESYNC.lid.b__'}}".into();
359        let mut remote = et("promo");
360        remote.subject = "{{x | lid: 'firstvalue'}}".into();
361        // First placeholder resolves positionally, second remains
362        // unresolved → fatal at resolve_placeholders.
363        let err = resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap_err();
364        assert_eq!(err.len(), 1);
365        assert_eq!(err[0].field, Some("subject"));
366    }
367
368    #[test]
369    fn retired_namespace_is_fatal() {
370        let mut block = cb("legacy", "hello __BRAZESYNC.custom.foo__ world");
371        let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
372        assert!(err.errors.iter().any(|e| matches!(
373            e,
374            ResolutionError::RetiredNamespace { token }
375                if token.contains("custom.foo")
376        )));
377    }
378
379    #[test]
380    fn typo_namespace_is_warning_not_fatal() {
381        let mut block = cb("typo", "hello __BRAZSYNC.lid.foo__ world");
382        resolve_content_block_with_remote(&mut block, None).unwrap();
383    }
384
385    #[test]
386    fn double_quoted_cb_id_filter_stripped_on_new_resource() {
387        let mut block = cb(
388            "page",
389            r#"{{content_blocks.${promo} | id: "__BRAZESYNC.cb_id.promo__"}}"#,
390        );
391        resolve_content_block_with_remote(&mut block, None).unwrap();
392        assert_eq!(block.content, "{{content_blocks.${promo}}}");
393    }
394}