Skip to main content

braze_sync/values/
integration.rs

1//! Wiring layer between [`crate::values`] and the diff / apply pipeline.
2
3use crate::resource::{ContentBlock, EmailTemplate};
4use crate::values::braze_managed::prepare_field;
5use crate::values::placeholder::ResolutionError;
6use crate::values::templatize::FieldKind;
7
8/// One resource's worth of placeholder failures, ready to be folded
9/// into a top-level error message.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ResolutionFailure {
12    pub resource_kind: &'static str,
13    pub resource_name: String,
14    /// `Some(field)` for email_template fields, `None` for content_block.
15    pub field: Option<&'static str>,
16    pub errors: Vec<ResolutionError>,
17}
18
19/// Resolve every `__BRAZESYNC__` in `cb.content`.
20///
21/// `remote` provides the live lid / cb_id values; pass `None` for new
22/// resources (lid → URL slug, cb_id filter stripped — see
23/// [`prepare_field`]).
24pub fn resolve_content_block_with_remote(
25    cb: &mut ContentBlock,
26    remote: Option<&ContentBlock>,
27) -> std::result::Result<(), ResolutionFailure> {
28    if !needs_resolve(&cb.content) {
29        return Ok(());
30    }
31    let prep = prepare_field(
32        &cb.content,
33        remote.map(|r| r.content.as_str()),
34        FieldKind::ContentBlock,
35    );
36    emit_prep_warnings("content_block", &cb.name, None, &prep.warnings);
37    if !prep.errors.is_empty() {
38        return Err(ResolutionFailure {
39            resource_kind: "content_block",
40            resource_name: cb.name.clone(),
41            field: None,
42            errors: prep.errors,
43        });
44    }
45    cb.content = prep.body;
46    Ok(())
47}
48
49/// Resolve placeholders across every Liquid-bearing field of `et`.
50pub fn resolve_email_template_with_remote(
51    et: &mut EmailTemplate,
52    remote: Option<&EmailTemplate>,
53) -> std::result::Result<(), Vec<ResolutionFailure>> {
54    let mut failures: Vec<ResolutionFailure> = Vec::new();
55
56    macro_rules! resolve_field {
57        ($field_name:expr, $field_kind:expr, $accessor:expr, $remote_accessor:expr) => {{
58            let body: &str = $accessor;
59            if needs_resolve(body) {
60                let prep = prepare_field(body, $remote_accessor, $field_kind);
61                emit_prep_warnings(
62                    "email_template",
63                    &et.name,
64                    Some($field_name),
65                    &prep.warnings,
66                );
67                if !prep.errors.is_empty() {
68                    failures.push(ResolutionFailure {
69                        resource_kind: "email_template",
70                        resource_name: et.name.clone(),
71                        field: Some($field_name),
72                        errors: prep.errors,
73                    });
74                    None
75                } else {
76                    Some(prep.body)
77                }
78            } else {
79                None
80            }
81        }};
82    }
83
84    let new_subject = resolve_field!(
85        "subject",
86        FieldKind::EmailSubject,
87        et.subject.as_str(),
88        remote.map(|r| r.subject.as_str())
89    );
90    let new_body_html = resolve_field!(
91        "body_html",
92        FieldKind::EmailHtmlBody,
93        et.body_html.as_str(),
94        remote.map(|r| r.body_html.as_str())
95    );
96    let new_body_plaintext = resolve_field!(
97        "body_plaintext",
98        FieldKind::EmailPlainBody,
99        et.body_plaintext.as_str(),
100        remote.map(|r| r.body_plaintext.as_str())
101    );
102    let new_preheader = match et.preheader.as_deref() {
103        Some(s) => resolve_field!(
104            "preheader",
105            FieldKind::EmailPreheader,
106            s,
107            remote.and_then(|r| r.preheader.as_deref())
108        ),
109        None => None,
110    };
111
112    if !failures.is_empty() {
113        return Err(failures);
114    }
115
116    if let Some(v) = new_subject {
117        et.subject = v;
118    }
119    if let Some(v) = new_body_html {
120        et.body_html = v;
121    }
122    if let Some(v) = new_body_plaintext {
123        et.body_plaintext = v;
124    }
125    if let Some(v) = new_preheader {
126        et.preheader = Some(v);
127    }
128    Ok(())
129}
130
131/// Cheap pre-filter: a body needs resolution if it carries the strict
132/// token *or* a retired-namespace token we need to surface as an error.
133fn needs_resolve(body: &str) -> bool {
134    body.contains("__BRAZESYNC") || body.contains("__BRAZSYNC")
135}
136
137fn emit_prep_warnings(
138    kind: &'static str,
139    name: &str,
140    field: Option<&'static str>,
141    warnings: &[String],
142) {
143    if warnings.is_empty() {
144        return;
145    }
146    let scope = match field {
147        Some(f) => format!("{kind} '{name}' ({f})"),
148        None => format!("{kind} '{name}'"),
149    };
150    for w in warnings {
151        eprintln!("warning: {scope}: {w}");
152    }
153}
154
155/// Format aggregated failures into a single human-readable error.
156pub fn format_failures(failures: &[ResolutionFailure]) -> crate::error::Error {
157    let mut msg = String::new();
158    msg.push_str(&format!(
159        "Cannot continue: {} placeholder resolution failure(s)\n",
160        failures.iter().map(|f| f.errors.len()).sum::<usize>(),
161    ));
162    for f in failures {
163        let scope = match f.field {
164            Some(field) => format!("  {} '{}' ({}):", f.resource_kind, f.resource_name, field),
165            None => format!("  {} '{}':", f.resource_kind, f.resource_name),
166        };
167        msg.push_str(&scope);
168        msg.push('\n');
169        for e in &f.errors {
170            match e {
171                ResolutionError::UnresolvedLid { start, anchor } => {
172                    let where_ = anchor
173                        .as_deref()
174                        .map(|u| format!("URL '{u}'"))
175                        .unwrap_or_else(|| "no URL anchor".to_string());
176                    msg.push_str(&format!(
177                        "    - offset {start}: lid `__BRAZESYNC__` ({where_}) — no anchor match in remote body\n",
178                    ));
179                }
180                ResolutionError::UnresolvedCbId { start, name } => {
181                    let n = name.as_deref().unwrap_or("<unknown>");
182                    msg.push_str(&format!(
183                        "    - offset {start}: cb_id `__BRAZESYNC__` (`${{{n}}}`) — no `${{{n}}}` include in remote body\n",
184                    ));
185                }
186                ResolutionError::UnknownContext { start } => {
187                    msg.push_str(&format!(
188                        "    - offset {start}: `__BRAZESYNC__` outside `| lid:` / `| id:` argument — cannot infer type\n",
189                    ));
190                }
191                ResolutionError::RetiredNamespace { token } => {
192                    msg.push_str(&format!(
193                        "    - {token}: retired placeholder syntax \
194                         (v0.15 `__BRAZESYNC.<type>.<key>__` was removed in v0.16; \
195                         re-run `braze-sync templatize` to regenerate)\n",
196                    ));
197                }
198            }
199        }
200    }
201    crate::error::Error::Config(msg)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::resource::content_block::ContentBlockState;
208
209    fn cb(name: &str, content: &str) -> ContentBlock {
210        ContentBlock {
211            name: name.into(),
212            description: None,
213            content: content.into(),
214            tags: Vec::new(),
215            state: ContentBlockState::Active,
216        }
217    }
218
219    fn et(name: &str) -> EmailTemplate {
220        EmailTemplate {
221            name: name.into(),
222            subject: String::new(),
223            body_html: String::new(),
224            body_plaintext: String::new(),
225            description: None,
226            preheader: None,
227            should_inline_css: None,
228            tags: Vec::new(),
229        }
230    }
231
232    #[test]
233    fn no_placeholders_skips_resolution() {
234        let mut block = cb("plain", "<p>hi there</p>");
235        resolve_content_block_with_remote(&mut block, None).unwrap();
236        assert_eq!(block.content, "<p>hi there</p>");
237    }
238
239    #[test]
240    fn content_block_resolves_lid_from_remote() {
241        let mut block = cb(
242            "promo",
243            r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
244        );
245        let remote = cb(
246            "promo",
247            r#"<a href="https://x.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#,
248        );
249        resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap();
250        assert!(block.content.contains("'newlidvalue1'"));
251    }
252
253    #[test]
254    fn new_resource_lid_uses_url_slug() {
255        let mut block = cb(
256            "promo",
257            r#"<a href="https://x.com/spring-sale">{{x | lid: '__BRAZESYNC__'}}</a>"#,
258        );
259        resolve_content_block_with_remote(&mut block, None).unwrap();
260        assert!(
261            block.content.contains("'spring_sale'"),
262            "got: {}",
263            block.content
264        );
265    }
266
267    #[test]
268    fn new_resource_cb_id_filter_is_stripped() {
269        let mut block = cb("page", "{{content_blocks.${promo} | id: '__BRAZESYNC__'}}");
270        resolve_content_block_with_remote(&mut block, None).unwrap();
271        assert_eq!(block.content, "{{content_blocks.${promo}}}");
272    }
273
274    #[test]
275    fn email_template_resolves_per_field() {
276        let mut t = et("welcome");
277        t.body_html = r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#.into();
278        let mut remote = et("welcome");
279        remote.body_html = r#"<a href="https://x.com/cta">{{x | lid: 'newhtmllidx'}}</a>"#.into();
280        resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
281        assert!(t.body_html.contains("'newhtmllidx'"));
282    }
283
284    #[test]
285    fn missing_remote_anchor_surfaces_as_failure() {
286        let mut block = cb(
287            "promo",
288            r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#,
289        );
290        let remote = cb("promo", "<p>no anchor here</p>");
291        let err = resolve_content_block_with_remote(&mut block, Some(&remote)).unwrap_err();
292        assert_eq!(err.errors.len(), 1);
293        assert!(matches!(
294            err.errors[0],
295            ResolutionError::UnresolvedLid { .. }
296        ));
297    }
298
299    #[test]
300    fn subject_lid_resolves_positionally_from_remote() {
301        let mut t = et("promo");
302        t.subject = "Spring sale {{x | lid: '__BRAZESYNC__'}}".into();
303        let mut remote = et("promo");
304        remote.subject = "Spring sale {{x | lid: 'subjectlid1'}}".into();
305        resolve_email_template_with_remote(&mut t, Some(&remote)).unwrap();
306        assert!(t.subject.contains("'subjectlid1'"));
307    }
308
309    #[test]
310    fn retired_v015_envelope_is_fatal() {
311        let mut block = cb("legacy", "hello __BRAZESYNC.lid.foo__ world");
312        let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
313        assert!(err
314            .errors
315            .iter()
316            .any(|e| matches!(e, ResolutionError::RetiredNamespace { .. })));
317    }
318
319    #[test]
320    fn typo_suffixed_token_is_detected() {
321        let mut block = cb("typo", "hello __BRAZESYNCTEST__ world");
322        let err = resolve_content_block_with_remote(&mut block, None).unwrap_err();
323        assert!(
324            !err.errors.is_empty(),
325            "typo-suffixed token must not pass silently"
326        );
327    }
328}