Skip to main content

braze_sync/values/
templatize.rs

1//! Migration pass: raw-lid / raw-cb_id bodies → templated bodies + values.
2//!
3//! Powers `braze-sync templatize` (RFC §2.7). All functions in this
4//! module are pure — they take a body string + field kind, and return
5//! the rewritten body together with the per-occurrence detection
6//! metadata the CLI orchestrator uses to populate values files.
7
8use regex_lite::Regex;
9use std::collections::BTreeMap;
10use std::sync::OnceLock;
11
12use crate::values::correlation::{normalize_url, slug_for_cb_id, slug_for_lid};
13
14/// Which Liquid context the body belongs to. Determines:
15/// - what kind of URL anchor lid detection should look for (HTML vs raw)
16/// - whether lid detection without a URL anchor should produce a
17///   sequential `link_N` key (deferred for subject/preheader v0.14)
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FieldKind {
20    ContentBlock,
21    EmailHtmlBody,
22    EmailPlainBody,
23    EmailSubject,
24    EmailPreheader,
25}
26
27impl FieldKind {
28    pub fn supports_html_anchor(self) -> bool {
29        matches!(self, FieldKind::ContentBlock | FieldKind::EmailHtmlBody)
30    }
31    pub fn supports_plaintext_anchor(self) -> bool {
32        matches!(self, FieldKind::EmailPlainBody)
33    }
34}
35
36/// One placeholder produced by templatization, with the metadata the
37/// caller needs to update `values/<env>.yaml`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum DetectedEntry {
40    Lid {
41        key: String,
42        value: String,
43        /// Normalized URL anchor, when this field has one. `None` for
44        /// subject/preheader where lid auto-detection currently falls
45        /// back to a sequential `link_N` key (no URL to anchor on).
46        url: Option<String>,
47    },
48    CbId {
49        key: String,
50        value: String,
51        /// The original Liquid `${NAME}` identifier; recorded for
52        /// debugging and so the slug round-trips back.
53        name: String,
54    },
55}
56
57impl DetectedEntry {
58    pub fn key(&self) -> &str {
59        match self {
60            DetectedEntry::Lid { key, .. } | DetectedEntry::CbId { key, .. } => key,
61        }
62    }
63}
64
65/// Result of templatizing one body field.
66#[derive(Debug, Clone)]
67pub struct TemplatizedField {
68    pub new_body: String,
69    pub entries: Vec<DetectedEntry>,
70    /// Warnings the CLI should surface (e.g. lid in subject/preheader
71    /// where we don't have a robust anchor).
72    pub warnings: Vec<String>,
73}
74
75/// Detect every `| lid: '<value>'` and `{{content_blocks.${NAME} | id: 'cbN'}}`
76/// in `body`, rewrite to `__BRAZESYNC.<type>.<key>__` placeholders,
77/// and return the rewritten body together with the per-occurrence
78/// detection metadata. Idempotent by construction: detection regexes
79/// require raw lid (`[a-z0-9]{8,}`) / cb_id (`cb[0-9]+`) literals, so
80/// already-templated `__BRAZESYNC.*__` placeholders never re-match.
81/// This means a partially-templatized body (existing placeholders
82/// alongside remaining raw values) still gets the raw values picked up,
83/// instead of being silently skipped.
84pub fn templatize_body(body: &str, field: FieldKind) -> TemplatizedField {
85    let mut spans: Vec<DetectionSpan> = Vec::new();
86    // Order matters per RFC §3 Q3 connumber fallback: detect lids in
87    // appearance order, dedup keys by sequential suffix.
88    let mut used_lid_keys: BTreeMap<String, usize> = BTreeMap::new();
89    let mut used_cb_id_keys: BTreeMap<String, usize> = BTreeMap::new();
90    // Repeated `${NAME}` cb_id references must reuse the same key so
91    // export refresh (which correlates by NAME) can match every
92    // occurrence. Without this, the second `${promo}` would slug to
93    // `promo_2` and refresh would never find a remote match.
94    let mut cb_id_name_to_key: BTreeMap<String, String> = BTreeMap::new();
95    let mut warnings: Vec<String> = Vec::new();
96
97    // --- lid detection ---
98    for m in lid_match_re().captures_iter(body) {
99        let whole = m.get(0).expect("group 0 always present");
100        let value = m
101            .get(1)
102            .or(m.get(2))
103            .map(|g| g.as_str().to_string())
104            .expect("one of the value alternates matches");
105
106        let (url, key) = name_lid_for_field(body, whole.start(), field, &mut used_lid_keys);
107        if url.is_none() && !matches!(field, FieldKind::EmailSubject | FieldKind::EmailPreheader) {
108            warnings.push(format!(
109                "lid '{value}' at byte {} has no URL anchor; using sequential key '{key}'",
110                whole.start()
111            ));
112        }
113        if matches!(field, FieldKind::EmailSubject | FieldKind::EmailPreheader) {
114            // Phase 3 export does NOT refresh subject/preheader lid
115            // entries (see exporter.rs refresh path). Skeleton files
116            // produced for other envs will therefore stay `value: null`
117            // until manually edited. Surface this once per detection so
118            // the operator knows the canonical/skeleton gap exists.
119            warnings.push(format!(
120                "lid '{value}' detected in subject/preheader (key '{key}'); \
121                 `export` does not refresh these — non-canonical env \
122                 values files must be edited manually"
123            ));
124        }
125        spans.push(DetectionSpan {
126            range: whole.range(),
127            replacement: format!("| lid: '__BRAZESYNC.lid.{key}__'"),
128            entry: DetectedEntry::Lid { key, value, url },
129        });
130    }
131
132    // --- cb_id detection ---
133    for m in cb_id_match_re().captures_iter(body) {
134        let whole = m.get(0).expect("group 0 always present");
135        let name = m.get(1).expect("name capture present").as_str().to_string();
136        let value = m
137            .get(2)
138            .or(m.get(3))
139            .map(|g| g.as_str().to_string())
140            .expect("cbN capture present");
141        // Same `${NAME}` referenced twice in one body → reuse the
142        // first key so export refresh matches every occurrence.
143        let key = match cb_id_name_to_key.get(&name) {
144            Some(prior) => prior.clone(),
145            None => {
146                let k = unique_key(slug_for_cb_id(&name), &mut used_cb_id_keys);
147                cb_id_name_to_key.insert(name.clone(), k.clone());
148                k
149            }
150        };
151        // Preserve the original `${NAME}` form so cb_id correlation in
152        // export keeps working.
153        let replacement =
154            format!("{{{{content_blocks.${{{name}}} | id: '__BRAZESYNC.cb_id.{key}__'}}}}");
155        spans.push(DetectionSpan {
156            range: whole.range(),
157            replacement,
158            entry: DetectedEntry::CbId { key, value, name },
159        });
160    }
161
162    // Apply spans back-to-front so earlier byte offsets remain valid.
163    spans.sort_by_key(|s| s.range.start);
164    let mut new_body = body.to_string();
165    let mut entries_in_order: Vec<DetectedEntry> = Vec::with_capacity(spans.len());
166    for s in &spans {
167        entries_in_order.push(s.entry.clone());
168    }
169    for s in spans.into_iter().rev() {
170        new_body.replace_range(s.range, &s.replacement);
171    }
172
173    TemplatizedField {
174        new_body,
175        entries: entries_in_order,
176        warnings,
177    }
178}
179
180struct DetectionSpan {
181    range: std::ops::Range<usize>,
182    replacement: String,
183    entry: DetectedEntry,
184}
185
186fn lid_match_re() -> &'static Regex {
187    static RE: OnceLock<Regex> = OnceLock::new();
188    RE.get_or_init(|| {
189        // RFC §2.7 step 2: pipe-anchored, dual-quote, min length 8.
190        Regex::new(r#"\|\s*lid:\s*(?:"([a-z0-9]{8,})"|'([a-z0-9]{8,})')"#)
191            .expect("lid match regex is valid")
192    })
193}
194
195fn cb_id_match_re() -> &'static Regex {
196    static RE: OnceLock<Regex> = OnceLock::new();
197    RE.get_or_init(|| {
198        Regex::new(
199            r#"\{\{\s*content_blocks\.\$\{\s*([^\s}|]+)\s*\}\s*\|\s*id:\s*(?:"(cb[0-9]+)"|'(cb[0-9]+)')\s*\}\}"#,
200        )
201        .expect("cb_id match regex is valid")
202    })
203}
204
205fn href_re() -> &'static Regex {
206    static RE: OnceLock<Regex> = OnceLock::new();
207    RE.get_or_init(|| {
208        Regex::new(r#"(?i)<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')"#)
209            .expect("href regex is valid")
210    })
211}
212
213fn plaintext_url_re() -> &'static Regex {
214    static RE: OnceLock<Regex> = OnceLock::new();
215    RE.get_or_init(|| Regex::new(r#"https?://[^\s<>"']+"#).expect("plaintext URL regex is valid"))
216}
217
218fn name_lid_for_field(
219    body: &str,
220    lid_token_offset: usize,
221    field: FieldKind,
222    used: &mut BTreeMap<String, usize>,
223) -> (Option<String>, String) {
224    let url = preceding_url(body, lid_token_offset, field);
225    let key_source: String = match &url {
226        Some(u) => url_path_tail(u).to_string(),
227        None => String::new(),
228    };
229    let slug = slug_for_lid(&key_source);
230    let key = unique_key(slug, used);
231    (url, key)
232}
233
234fn preceding_url(body: &str, lid_token_offset: usize, field: FieldKind) -> Option<String> {
235    let raw = if field.supports_html_anchor() {
236        // Braze's common case puts `{{ ... | lid: '…' }}` *inside* the
237        // href attribute value (e.g. `href="…?lid={{…|lid:'X'}}"`).
238        // Try the enclosing `<a …>` open tag first; only when the lid
239        // lives outside any open tag do we fall back to the last
240        // `<a href>` before the token (legacy pattern where lid sits
241        // between the `<a>` and `</a>` as link text).
242        enclosing_anchor_href(body, lid_token_offset).or_else(|| {
243            let prefix = &body[..lid_token_offset];
244            href_re()
245                .captures_iter(prefix)
246                .last()
247                .and_then(|cap| cap.get(1).or(cap.get(2)))
248                .map(|m| m.as_str().to_string())
249        })
250    } else if field.supports_plaintext_anchor() {
251        let prefix = &body[..lid_token_offset];
252        plaintext_url_re()
253            .find_iter(prefix)
254            .last()
255            .map(|m| m.as_str().to_string())
256    } else {
257        None
258    };
259    raw.map(|r| normalize_url(&r))
260}
261
262/// Find an `<a …>` open tag that *contains* `lid_token_offset` and
263/// return its `href` attribute value (unnormalized). Returns `None`
264/// when the offset isn't inside any open tag or the enclosing tag has
265/// no href.
266///
267/// Open tags are detected by `<a\b … >` with `>` being the first
268/// unmatched `>` after `<a` — same trust assumption as `href_re` (no
269/// raw `>` inside attribute values).
270fn enclosing_anchor_href(body: &str, lid_token_offset: usize) -> Option<String> {
271    let re = anchor_open_tag_re();
272    for m in re.find_iter(body) {
273        if m.start() > lid_token_offset {
274            break;
275        }
276        if m.end() > lid_token_offset {
277            let tag = &body[m.start()..m.end()];
278            return href_re()
279                .captures(tag)
280                .and_then(|cap| cap.get(1).or(cap.get(2)))
281                .map(|x| x.as_str().to_string());
282        }
283    }
284    None
285}
286
287fn anchor_open_tag_re() -> &'static Regex {
288    static RE: OnceLock<Regex> = OnceLock::new();
289    RE.get_or_init(|| Regex::new(r#"(?i)<a\b[^>]*>"#).expect("anchor open tag regex is valid"))
290}
291
292fn url_path_tail(url: &str) -> String {
293    // Strip scheme://host and any leading slashes; take the last
294    // non-empty path component. `https://example.com/promo/spring-sale`
295    // → `spring-sale`. Bare host or trailing slash → empty (caller
296    // applies the `link_` fallback via slug_for_lid).
297    let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
298    let path_start = after_scheme
299        .find('/')
300        .map(|i| i + 1)
301        .unwrap_or(after_scheme.len());
302    let path = &after_scheme[path_start..];
303    path.rsplit('/')
304        .find(|s| !s.is_empty())
305        .unwrap_or("")
306        .to_string()
307}
308
309fn unique_key(base: String, used: &mut BTreeMap<String, usize>) -> String {
310    let count = used.entry(base.clone()).or_insert(0);
311    *count += 1;
312    if *count == 1 {
313        base
314    } else {
315        format!("{base}_{count}")
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn idempotent_on_already_templatized_body() {
325        let body = "<p>__BRAZESYNC.lid.cta__ kept verbatim</p>";
326        let r = templatize_body(body, FieldKind::ContentBlock);
327        assert_eq!(r.new_body, body);
328        assert!(r.entries.is_empty());
329    }
330
331    #[test]
332    fn rewrites_html_lid_with_url_anchor() {
333        let body = r#"<a href="https://example.com/spring-sale">{{x | lid: 'ai8kexrxcp03'}}</a>"#;
334        let r = templatize_body(body, FieldKind::ContentBlock);
335        assert!(r.new_body.contains("__BRAZESYNC.lid.spring_sale__"));
336        assert_eq!(r.entries.len(), 1);
337        match &r.entries[0] {
338            DetectedEntry::Lid { key, value, url } => {
339                assert_eq!(key, "spring_sale");
340                assert_eq!(value, "ai8kexrxcp03");
341                assert_eq!(url.as_deref(), Some("https://example.com/spring-sale"));
342            }
343            _ => panic!("expected Lid"),
344        }
345    }
346
347    #[test]
348    fn rewrites_cb_id_include() {
349        let body = "{{content_blocks.${promo_banner} | id: 'cb42'}}";
350        let r = templatize_body(body, FieldKind::ContentBlock);
351        assert!(r.new_body.contains("__BRAZESYNC.cb_id.promo_banner__"));
352        // Preserves ${NAME} so export correlation still works.
353        assert!(r.new_body.contains("${promo_banner}"));
354        assert_eq!(r.entries.len(), 1);
355    }
356
357    #[test]
358    fn dedupes_duplicate_url_with_sequential_suffix() {
359        let body = r#"
360<a href="https://example.com/cta">{{x | lid: 'ai8kexrxcp03'}}A</a>
361<a href="https://example.com/cta">{{x | lid: 'bj9lfsysxq14'}}B</a>"#;
362        let r = templatize_body(body, FieldKind::ContentBlock);
363        let keys: Vec<&str> = r.entries.iter().map(DetectedEntry::key).collect();
364        assert_eq!(keys, ["cta", "cta_2"]);
365    }
366
367    #[test]
368    fn plaintext_url_anchor_works() {
369        let body = "Click https://example.com/promo {{x | lid: 'ai8kexrxcp03'}} now.";
370        let r = templatize_body(body, FieldKind::EmailPlainBody);
371        match &r.entries[0] {
372            DetectedEntry::Lid { key, url, .. } => {
373                assert_eq!(key, "promo");
374                assert_eq!(url.as_deref(), Some("https://example.com/promo"));
375            }
376            _ => panic!(),
377        }
378    }
379
380    #[test]
381    fn subject_lid_warns_about_export_refresh_gap() {
382        // subject has no URL anchor — slug falls back to `link_`. The
383        // CLI must surface that `export` won't refresh this entry for
384        // other envs so the operator knows to maintain values manually.
385        let body = "Hello {{x | lid: 'ai8kexrxcp03'}} world";
386        let r = templatize_body(body, FieldKind::EmailSubject);
387        assert!(
388            r.warnings
389                .iter()
390                .any(|w| w.contains("export") && w.contains("subject")),
391            "expected manual-maintenance warning, got: {:?}",
392            r.warnings
393        );
394        match &r.entries[0] {
395            DetectedEntry::Lid { key, url, .. } => {
396                assert_eq!(key, "link_");
397                assert!(url.is_none());
398            }
399            _ => panic!(),
400        }
401    }
402
403    #[test]
404    fn repeated_cb_id_name_reuses_key() {
405        // RFC: same `${NAME}` resolves to the same content_block. The
406        // values file must have ONE entry for it, not `name` + `name_2`,
407        // otherwise export refresh can never populate the duplicates.
408        let body = "{{content_blocks.${promo} | id: 'cb10'}} ... \
409                    {{content_blocks.${promo} | id: 'cb10'}}";
410        let r = templatize_body(body, FieldKind::ContentBlock);
411        assert_eq!(r.entries.len(), 2, "both occurrences detected");
412        assert_eq!(r.entries[0].key(), "promo");
413        assert_eq!(
414            r.entries[1].key(),
415            "promo",
416            "same ${{NAME}} must reuse the key"
417        );
418    }
419
420    #[test]
421    fn partially_templatized_body_picks_up_remaining_raw_lid() {
422        // Mixed state: one lid already templated, another still raw.
423        // The raw one MUST be detected (no early-return short-circuit).
424        let body = r#"
425<a href="https://example.com/cta">{{ x | lid: '__BRAZESYNC.lid.cta__' }}A</a>
426<a href="https://example.com/promo">{{ x | lid: 'rawvalue1234' }}B</a>"#;
427        let r = templatize_body(body, FieldKind::ContentBlock);
428        assert_eq!(r.entries.len(), 1, "the raw lid must be detected");
429        match &r.entries[0] {
430            DetectedEntry::Lid { key, value, .. } => {
431                assert_eq!(key, "promo");
432                assert_eq!(value, "rawvalue1234");
433            }
434            _ => panic!("expected Lid"),
435        }
436    }
437
438    #[test]
439    fn html_lid_without_anchor_warns() {
440        // HTML body but the lid has no preceding <a href> — RFC says
441        // this should still produce a key but flag it for the operator.
442        let body = "{{x | lid: 'ai8kexrxcp03'}} just floating";
443        let r = templatize_body(body, FieldKind::EmailHtmlBody);
444        assert_eq!(r.entries.len(), 1);
445        assert!(!r.warnings.is_empty());
446    }
447
448    #[test]
449    fn lid_inside_href_attribute_value_uses_enclosing_anchor() {
450        // Braze's typical HTML output puts the lid *inside* the href:
451        //   <a href="https://…/path/?lid={{${cblid} | lid: 'X'}}">
452        // The prefix-only scan can't see the closing quote and was
453        // falling back to a sequential `link_` key for ~all anchors.
454        let body = r#"<a href="https://med.example.com/product/jaypirca/50mg/?lid={{${cblid} | lid: 'ai8kexrxcp03'}}"><img src="x"/></a>"#;
455        let r = templatize_body(body, FieldKind::ContentBlock);
456        assert_eq!(r.entries.len(), 1);
457        match &r.entries[0] {
458            DetectedEntry::Lid { key, url, .. } => {
459                assert_eq!(key, "link_50mg");
460                assert_eq!(
461                    url.as_deref(),
462                    Some("https://med.example.com/product/jaypirca/50mg/")
463                );
464            }
465            _ => panic!("expected Lid"),
466        }
467        assert!(
468            r.warnings.is_empty(),
469            "no-anchor warning should not fire when href encloses the lid"
470        );
471    }
472
473    #[test]
474    fn enclosing_anchor_takes_precedence_over_earlier_unrelated_href() {
475        // Even if an earlier, fully-closed <a href> exists, the lid
476        // that lives inside a *different* later <a …> tag should use
477        // that later tag's href, not the prior one.
478        let body = r#"<a href="https://example.com/old">old</a> then <a href="https://example.com/new/path/?lid={{x | lid: 'ai8kexrxcp03'}}">new</a>"#;
479        let r = templatize_body(body, FieldKind::ContentBlock);
480        match &r.entries[0] {
481            DetectedEntry::Lid { url, .. } => {
482                assert_eq!(url.as_deref(), Some("https://example.com/new/path/"));
483            }
484            _ => panic!(),
485        }
486    }
487
488    #[test]
489    fn enclosing_anchor_without_href_falls_back_to_prior_href() {
490        // The lid lives inside an `<a>` open tag that has no `href`
491        // (e.g. `<a name="…">`). `enclosing_anchor_href` finds the
492        // enclosing tag but returns None because there's no href to
493        // extract — the legacy prefix scan must still pick up the
494        // earlier `<a href>` so we don't regress that pattern.
495        let body = r#"<a href="https://example.com/earlier/path">x</a> <a name="anchor">text {{x | lid: 'ai8kexrxcp03'}}</a>"#;
496        let r = templatize_body(body, FieldKind::ContentBlock);
497        assert_eq!(r.entries.len(), 1);
498        match &r.entries[0] {
499            DetectedEntry::Lid { url, .. } => {
500                assert_eq!(url.as_deref(), Some("https://example.com/earlier/path"));
501            }
502            _ => panic!("expected Lid"),
503        }
504    }
505
506    #[test]
507    fn url_path_tail_uses_last_nonempty_segment() {
508        assert_eq!(
509            url_path_tail("https://example.com/promo/spring-sale"),
510            "spring-sale"
511        );
512        assert_eq!(url_path_tail("https://example.com/"), "");
513        assert_eq!(url_path_tail("https://example.com"), "");
514    }
515}