Skip to main content

braze_sync/values/
braze_managed.rs

1//! Runtime resolution of anonymous `__BRAZESYNC__` placeholders.
2//!
3//! Resolved at apply/diff time from the remote body via URL / `${NAME}`
4//! anchor correlation. Type (`lid` vs `cb_id`) is inferred from the
5//! surrounding `| lid:` / `| id:` filter syntax.
6//!
7//! New-resource fallback (no remote):
8//! - **lid**: URL path tail slug used as the value; Braze reassigns on
9//!   first dashboard open.
10//! - **cb_id**: `| id: '…'` filter stripped; Braze derives internally.
11
12use std::collections::BTreeMap;
13use std::sync::OnceLock;
14
15use regex_lite::Regex;
16
17use crate::values::correlation::{
18    extract_cb_id_values, extract_html_lid_values, extract_lid_values_unanchored,
19    extract_plaintext_lid_values, normalize_url, slug_for_lid, CbIdCorrelation, LidCorrelation,
20};
21use crate::values::placeholder::{
22    extract_placeholders, find_suspicious_placeholders, PlaceholderType, ResolutionError, TOKEN,
23};
24use crate::values::templatize::FieldKind;
25
26/// Per-field result of resolving a templatized body.
27#[derive(Debug, Clone)]
28pub struct PreparedTemplate {
29    /// Fully-resolved body. When `errors` is non-empty some `__BRAZESYNC__`
30    /// tokens may still be present — the caller treats the field as
31    /// failed and surfaces the errors.
32    pub body: String,
33    /// Unresolved placeholders, retired-namespace tokens, etc.
34    pub errors: Vec<ResolutionError>,
35    /// Non-fatal warnings — ambiguous URL matches, count mismatches,
36    /// stripped cb_id filters on new resources.
37    pub warnings: Vec<String>,
38    /// Drift-fallback `lid` values: template placeholders that had no
39    /// matching remote anchor and were resolved with a generated slug.
40    /// Brand-new-resource fallbacks are *not* recorded here — they are
41    /// the expected path and would be noise. Populated only when a
42    /// remote body was provided but came up short.
43    pub fallbacks: Vec<LidFallback>,
44}
45
46/// A single drift-fallback assignment. `anchor` is the URL anchor when
47/// available (HTML / plaintext fields); `None` for positional contexts
48/// like subject / preheader where the placeholder has no URL anchor.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LidFallback {
51    pub anchor: Option<String>,
52    pub value: String,
53}
54
55/// Resolve every `__BRAZESYNC__` in `template` against `remote`.
56///
57/// `remote = None` means the resource does not yet exist in Braze
58/// (new-resource path: lid → slug fallback, cb_id → filter stripped).
59pub fn prepare_field(template: &str, remote: Option<&str>, field: FieldKind) -> PreparedTemplate {
60    let mut errors: Vec<ResolutionError> = Vec::new();
61
62    // Retired v0.15 envelope detection — fatal so operators re-run
63    // `templatize`.
64    for tok in find_suspicious_placeholders(template) {
65        errors.push(ResolutionError::RetiredNamespace { token: tok });
66    }
67
68    if !template.contains(TOKEN) {
69        return PreparedTemplate {
70            body: template.to_string(),
71            errors,
72            warnings: Vec::new(),
73            fallbacks: Vec::new(),
74        };
75    }
76
77    let (body, mut warnings) = match remote {
78        Some(_) => (template.to_string(), Vec::new()),
79        None => strip_cb_id_filters(template),
80    };
81    let mut fallbacks: Vec<LidFallback> = Vec::new();
82
83    // Map each `__BRAZESYNC__` occurrence in `body` to its resolved
84    // value. None entries are recorded as errors instead.
85    let placeholders = extract_placeholders(&body);
86    let mut resolved: Vec<(usize, usize, Option<String>)> = Vec::new();
87
88    // Collect lid placeholders so we can do the URL-bucket FIFO match in
89    // one pass.
90    let lid_indices: Vec<usize> = placeholders
91        .iter()
92        .enumerate()
93        .filter(|(_, p)| p.ty == Some(PlaceholderType::Lid))
94        .map(|(i, _)| i)
95        .collect();
96
97    let lid_values: Vec<Option<String>> = match remote {
98        Some(remote_body) => resolve_lid_batch(
99            &body,
100            &placeholders,
101            &lid_indices,
102            remote_body,
103            field,
104            &mut warnings,
105            &mut fallbacks,
106        ),
107        None => fallback_lid_batch(&body, &placeholders, &lid_indices, field),
108    };
109
110    // cb_id resolution map (offset → value or None).
111    let cb_id_resolved: BTreeMap<usize, Option<String>> = match remote {
112        Some(remote_body) => resolve_cb_id_batch(&body, &placeholders, remote_body, &mut warnings),
113        None => BTreeMap::new(),
114    };
115
116    let mut lid_iter = lid_values.into_iter();
117    for ph in &placeholders {
118        match ph.ty {
119            None => {
120                errors.push(ResolutionError::UnknownContext { start: ph.start });
121                resolved.push((ph.start, ph.end, None));
122            }
123            Some(PlaceholderType::Lid) => {
124                let v = lid_iter.next().flatten();
125                if v.is_none() {
126                    let anchor = lid_anchor_for(&body, ph.start, field);
127                    errors.push(ResolutionError::UnresolvedLid {
128                        start: ph.start,
129                        anchor,
130                    });
131                }
132                resolved.push((ph.start, ph.end, v));
133            }
134            Some(PlaceholderType::CbId) => {
135                let v = cb_id_resolved.get(&ph.start).cloned().flatten();
136                if v.is_none() {
137                    let name = cb_id_name_at(&body, ph.start);
138                    errors.push(ResolutionError::UnresolvedCbId {
139                        start: ph.start,
140                        name,
141                    });
142                }
143                resolved.push((ph.start, ph.end, v));
144            }
145        }
146    }
147
148    // Substitute back-to-front so byte offsets stay valid.
149    let mut out = body;
150    for (start, end, value) in resolved.into_iter().rev() {
151        if let Some(v) = value {
152            out.replace_range(start..end, &v);
153        }
154    }
155
156    PreparedTemplate {
157        body: out,
158        errors,
159        warnings,
160        fallbacks,
161    }
162}
163
164/// Resolve lid placeholders against `remote`. Returns one entry per
165/// lid placeholder in template-appearance order.
166fn resolve_lid_batch(
167    body: &str,
168    placeholders: &[crate::values::placeholder::Placeholder],
169    lid_indices: &[usize],
170    remote: &str,
171    field: FieldKind,
172    warnings: &mut Vec<String>,
173    fallbacks: &mut Vec<LidFallback>,
174) -> Vec<Option<String>> {
175    if lid_indices.is_empty() {
176        return Vec::new();
177    }
178    if !field.supports_html_anchor() && !field.supports_plaintext_anchor() {
179        return resolve_lid_positional(
180            placeholders,
181            lid_indices,
182            remote,
183            field,
184            warnings,
185            fallbacks,
186        );
187    }
188
189    let remote_pairs: Vec<LidCorrelation> = if field.supports_html_anchor() {
190        extract_html_lid_values(remote)
191    } else {
192        extract_plaintext_lid_values(remote)
193    };
194
195    // Each lid placeholder's anchor URL in template-appearance order.
196    let anchors: Vec<Option<String>> = lid_indices
197        .iter()
198        .map(|&i| lid_anchor_for(body, placeholders[i].start, field))
199        .collect();
200
201    let mut by_url: BTreeMap<String, std::collections::VecDeque<&LidCorrelation>> = BTreeMap::new();
202    for p in &remote_pairs {
203        by_url.entry(p.url.clone()).or_default().push_back(p);
204    }
205
206    let mut tmpl_per_url: BTreeMap<String, usize> = BTreeMap::new();
207    for u in anchors.iter().flatten() {
208        *tmpl_per_url.entry(u.clone()).or_insert(0) += 1;
209    }
210    for (url, bucket) in &by_url {
211        let tmpl_count = tmpl_per_url.get(url).copied().unwrap_or(0);
212        if bucket.len() > 1 || (tmpl_count > 0 && bucket.len() != tmpl_count) {
213            warnings.push(format!(
214                "URL '{url}' has {} remote lid occurrences and {tmpl_count} \
215                 template placeholders — using positional FIFO match. \
216                 If links were reordered in Braze, lid values may be assigned \
217                 to the wrong placeholder.",
218                bucket.len()
219            ));
220        }
221    }
222
223    let mut out = Vec::with_capacity(lid_indices.len());
224    // Seed `used` with every remote lid value so a fallback slug can
225    // never duplicate a value that is already in the POSTed body. Braze
226    // treats `lid` as a per-link identifier; duplicates corrupt link
227    // analytics.
228    let mut used: BTreeMap<String, usize> = BTreeMap::new();
229    for p in &remote_pairs {
230        used.entry(p.value.clone()).or_insert(1);
231    }
232    let mut seq = 0usize;
233    for anchor in anchors {
234        let Some(url) = anchor else {
235            warnings.push(
236                "lid placeholder has no URL anchor in template — \
237                 anchor-less correlation is not supported; resolve will fail"
238                    .to_string(),
239            );
240            out.push(None);
241            continue;
242        };
243        let pick = by_url.get_mut(&url).and_then(|b| b.pop_front());
244        match pick {
245            Some(p) => out.push(Some(p.value.clone())),
246            None => {
247                let fallback = fallback_lid_for_url(Some(&url), &mut used, &mut seq);
248                warnings.push(format!(
249                    "lid: URL anchor '{url}' not found in remote body — \
250                     using fallback value '{fallback}' (new link; Braze will \
251                     reassign on first dashboard save)"
252                ));
253                fallbacks.push(LidFallback {
254                    anchor: Some(url.clone()),
255                    value: fallback.clone(),
256                });
257                out.push(Some(fallback));
258            }
259        }
260    }
261    out
262}
263
264/// Slug fallback for a single lid placeholder. `used` is shared across
265/// the batch so collisions are disambiguated with `_2`, `_3`, ….
266fn fallback_lid_for_url(
267    url: Option<&str>,
268    used: &mut BTreeMap<String, usize>,
269    seq: &mut usize,
270) -> String {
271    let base = match url {
272        Some(u) => {
273            let tail = url_path_tail(u);
274            let slug = slug_for_lid(&tail);
275            if slug.is_empty() {
276                *seq += 1;
277                format!("lid_{seq}", seq = *seq)
278            } else {
279                slug
280            }
281        }
282        None => {
283            *seq += 1;
284            format!("lid_{seq}", seq = *seq)
285        }
286    };
287    unique(base, used)
288}
289
290/// Positional FIFO match for subject / preheader.
291fn resolve_lid_positional(
292    placeholders: &[crate::values::placeholder::Placeholder],
293    lid_indices: &[usize],
294    remote: &str,
295    field: FieldKind,
296    warnings: &mut Vec<String>,
297    fallbacks: &mut Vec<LidFallback>,
298) -> Vec<Option<String>> {
299    let remote_values = extract_lid_values_unanchored(remote);
300    let field_label = match field {
301        FieldKind::EmailSubject => "subject",
302        FieldKind::EmailPreheader => "preheader",
303        _ => "field",
304    };
305    if remote_values.len() != lid_indices.len() {
306        warnings.push(format!(
307            "{field_label} has {} lid placeholder(s) but remote body has {} lid value(s); \
308             positional match may misalign — review rendered output",
309            lid_indices.len(),
310            remote_values.len()
311        ));
312    }
313    let _ = placeholders;
314    let mut out = Vec::with_capacity(lid_indices.len());
315    // Seed `used` with every remote positional value so fallback
316    // slugs (`lid_1`, …) can never collide with a real remote value.
317    let mut used: BTreeMap<String, usize> = BTreeMap::new();
318    for v in &remote_values {
319        used.entry(v.clone()).or_insert(1);
320    }
321    let mut iter = remote_values.into_iter();
322    let mut seq = 0usize;
323    for _ in lid_indices {
324        match iter.next() {
325            Some(v) => out.push(Some(v)),
326            None => {
327                let v = fallback_lid_for_url(None, &mut used, &mut seq);
328                fallbacks.push(LidFallback {
329                    anchor: None,
330                    value: v.clone(),
331                });
332                out.push(Some(v));
333            }
334        }
335    }
336    out
337}
338
339/// Pair cb_id placeholders with remote `${NAME} | id: 'cbN'` matches by
340/// `${NAME}`. Returns an offset → value map.
341fn resolve_cb_id_batch(
342    body: &str,
343    placeholders: &[crate::values::placeholder::Placeholder],
344    remote: &str,
345    warnings: &mut Vec<String>,
346) -> BTreeMap<usize, Option<String>> {
347    let remote_pairs = extract_cb_id_values(remote);
348    let remote_by_name: BTreeMap<&str, &CbIdCorrelation> =
349        remote_pairs.iter().map(|p| (p.name.as_str(), p)).collect();
350
351    let mut out: BTreeMap<usize, Option<String>> = BTreeMap::new();
352    for ph in placeholders {
353        if ph.ty != Some(PlaceholderType::CbId) {
354            continue;
355        }
356        let name = match cb_id_name_at(body, ph.start) {
357            Some(n) => n,
358            None => {
359                warnings.push(format!(
360                    "cb_id: `__BRAZESYNC__` at byte {} not inside `{{{{content_blocks.${{NAME}} | id: '…'}}}}` — cannot correlate",
361                    ph.start
362                ));
363                out.insert(ph.start, None);
364                continue;
365            }
366        };
367        match remote_by_name.get(name.as_str()) {
368            Some(pick) => {
369                out.insert(ph.start, Some(pick.value.clone()));
370            }
371            None => {
372                warnings.push(format!(
373                    "cb_id: `${{{name}}}` include not found in remote body"
374                ));
375                out.insert(ph.start, None);
376            }
377        }
378    }
379    out
380}
381
382/// Generate fallback lid values for the new-resource path. Uses URL
383/// path tail slug; collisions are disambiguated with `_2`, `_3`, ….
384fn fallback_lid_batch(
385    body: &str,
386    placeholders: &[crate::values::placeholder::Placeholder],
387    lid_indices: &[usize],
388    field: FieldKind,
389) -> Vec<Option<String>> {
390    let mut used: BTreeMap<String, usize> = BTreeMap::new();
391    let mut seq = 0usize;
392    let mut out = Vec::with_capacity(lid_indices.len());
393    for &i in lid_indices {
394        let anchor = lid_anchor_for(body, placeholders[i].start, field);
395        out.push(Some(fallback_lid_for_url(
396            anchor.as_deref(),
397            &mut used,
398            &mut seq,
399        )));
400    }
401    out
402}
403
404fn unique(base: String, used: &mut BTreeMap<String, usize>) -> String {
405    let count = used.entry(base.clone()).or_insert(0);
406    *count += 1;
407    if *count == 1 {
408        base
409    } else {
410        format!("{base}_{count}")
411    }
412}
413
414fn url_path_tail(url: &str) -> String {
415    let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
416    let path_start = after_scheme
417        .find('/')
418        .map(|i| i + 1)
419        .unwrap_or(after_scheme.len());
420    // Strip query / fragment (normalize_url already does this for the
421    // main call-path, but be safe if the function is reused).
422    let path = after_scheme[path_start..]
423        .split(['?', '#'])
424        .next()
425        .unwrap_or("");
426    path.rsplit('/')
427        .find(|s| !s.is_empty())
428        .unwrap_or("")
429        .to_string()
430}
431
432/// Strip `| id: '__BRAZESYNC__'` filters from a template body. Used
433/// for the new-resource fallback so we POST the documented
434/// `{{content_blocks.${NAME}}}` form.
435fn strip_cb_id_filters(body: &str) -> (String, Vec<String>) {
436    let re = cb_id_filter_re();
437    let mut warnings: Vec<String> = Vec::new();
438    let mut spans: Vec<(std::ops::Range<usize>, String)> = Vec::new();
439    for cap in re.captures_iter(body) {
440        let whole = cap.get(0).expect("group 0 always present");
441        let name = cap
442            .get(1)
443            .map(|m| m.as_str().to_string())
444            .unwrap_or_default();
445        warnings.push(format!(
446            "cb_id `${{{name}}}`: new resource — stripping `| id: '…'` filter; \
447             Braze will assign a cb_id on first save"
448        ));
449        spans.push((whole.range(), format!("{{{{content_blocks.${{{name}}}}}}}")));
450    }
451    let mut out = body.to_string();
452    for (range, replacement) in spans.into_iter().rev() {
453        out.replace_range(range, &replacement);
454    }
455    (out, warnings)
456}
457
458fn cb_id_filter_re() -> &'static Regex {
459    static RE: OnceLock<Regex> = OnceLock::new();
460    RE.get_or_init(|| {
461        // Captures `${NAME}` so we can re-emit the documented form
462        // (`{{content_blocks.${NAME}}}`) without the cb_id filter.
463        Regex::new(
464            r#"\{\{\s*content_blocks\.\$\{\s*([^\s}|]+)\s*\}\s*\|\s*id:\s*['"]__BRAZESYNC__['"]\s*\}\}"#,
465        )
466        .expect("cb_id filter regex is valid")
467    })
468}
469
470/// Look up the `${NAME}` enclosing a cb_id `__BRAZESYNC__` token.
471fn cb_id_name_at(body: &str, offset: usize) -> Option<String> {
472    let re = cb_id_template_re();
473    for cap in re.captures_iter(body) {
474        let whole = cap.get(0)?;
475        if whole.start() <= offset && offset < whole.end() {
476            return cap.get(1).map(|m| m.as_str().to_string());
477        }
478    }
479    None
480}
481
482fn cb_id_template_re() -> &'static Regex {
483    static RE: OnceLock<Regex> = OnceLock::new();
484    RE.get_or_init(|| {
485        Regex::new(
486            r#"\{\{\s*content_blocks\.\$\{\s*([^\s}|]+)\s*\}\s*\|\s*id:\s*['"]__BRAZESYNC__['"]\s*\}\}"#,
487        )
488        .expect("cb_id template regex is valid")
489    })
490}
491
492fn lid_anchor_for(body: &str, offset: usize, field: FieldKind) -> Option<String> {
493    if field.supports_html_anchor() {
494        if let Some(tag) = enclosing_open_tag(body, offset) {
495            if let Some(url) = url_attr_re()
496                .captures(tag)
497                .and_then(|c| c.get(1).or(c.get(2)))
498            {
499                return Some(normalize_url(url.as_str()));
500            }
501            return None;
502        }
503        let prefix = &body[..offset];
504        anchor_href_re()
505            .captures_iter(prefix)
506            .last()
507            .and_then(|cap| cap.get(1).or(cap.get(2)))
508            .map(|m| normalize_url(m.as_str()))
509    } else if field.supports_plaintext_anchor() {
510        let prefix = &body[..offset];
511        plaintext_url_re()
512            .find_iter(prefix)
513            .last()
514            .map(|m| normalize_url(m.as_str()))
515    } else {
516        None
517    }
518}
519
520fn enclosing_open_tag(body: &str, offset: usize) -> Option<&str> {
521    for m in element_open_tag_re().find_iter(body) {
522        if m.start() > offset {
523            break;
524        }
525        if m.end() > offset {
526            return Some(&body[m.start()..m.end()]);
527        }
528    }
529    None
530}
531
532fn anchor_href_re() -> &'static Regex {
533    static RE: OnceLock<Regex> = OnceLock::new();
534    RE.get_or_init(|| {
535        Regex::new(r#"(?i)<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')"#)
536            .expect("anchor href regex is valid")
537    })
538}
539
540fn url_attr_re() -> &'static Regex {
541    static RE: OnceLock<Regex> = OnceLock::new();
542    RE.get_or_init(|| {
543        Regex::new(
544            r#"(?i)\s(?:[a-z][a-z0-9_-]*:)?(?:href|src|action)\s*=\s*(?:"([^"]*)"|'([^']*)')"#,
545        )
546        .expect("url attr regex is valid")
547    })
548}
549
550fn plaintext_url_re() -> &'static Regex {
551    static RE: OnceLock<Regex> = OnceLock::new();
552    RE.get_or_init(|| Regex::new(r#"https?://[^\s<>"']+"#).expect("plaintext URL regex is valid"))
553}
554
555fn element_open_tag_re() -> &'static Regex {
556    static RE: OnceLock<Regex> = OnceLock::new();
557    RE.get_or_init(|| {
558        Regex::new(r#"(?i)<[a-z][a-z0-9_.:-]*\b[^>]*>"#).expect("element open tag regex is valid")
559    })
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    #[test]
567    fn no_placeholders_returns_body_verbatim() {
568        let p = prepare_field("<p>hi</p>", Some("<p>hi</p>"), FieldKind::ContentBlock);
569        assert_eq!(p.body, "<p>hi</p>");
570        assert!(p.errors.is_empty());
571    }
572
573    #[test]
574    fn html_lid_resolved_via_url_anchor() {
575        let template = r#"<a href="https://example.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#;
576        let remote = r#"<a href="https://example.com/cta">{{x | lid: 'newlidvalue1'}}</a>"#;
577        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
578        assert!(p.errors.is_empty(), "{:?}", p.errors);
579        assert!(p.body.contains("'newlidvalue1'"));
580    }
581
582    #[test]
583    fn two_lid_placeholders_sharing_one_url_consume_distinct_remote_values() {
584        let template = r#"<a href="https://x.com/a">{{x | lid: '__BRAZESYNC__'}}</a>
585<a href="https://x.com/a">{{x | lid: '__BRAZESYNC__'}}</a>"#;
586        let remote = r#"<a href="https://x.com/a">{{x | lid: 'firstvalu1a'}}</a>
587<a href="https://x.com/a">{{x | lid: 'secondval2b'}}</a>"#;
588        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
589        assert!(p.errors.is_empty(), "{:?}", p.errors);
590        assert!(p.body.contains("'firstvalu1a'"));
591        assert!(p.body.contains("'secondval2b'"));
592    }
593
594    #[test]
595    fn cb_id_resolved_via_name() {
596        let template = "{{content_blocks.${promo_banner} | id: '__BRAZESYNC__'}}";
597        let remote = "{{content_blocks.${promo_banner} | id: 'cb99'}}";
598        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
599        assert!(p.errors.is_empty());
600        assert!(p.body.contains("'cb99'"));
601    }
602
603    #[test]
604    fn new_resource_lid_uses_url_slug_fallback() {
605        let template = r#"<a href="https://x.com/spring-sale">{{x | lid: '__BRAZESYNC__'}}</a>"#;
606        let p = prepare_field(template, None, FieldKind::ContentBlock);
607        assert!(p.errors.is_empty());
608        assert!(p.body.contains("'spring_sale'"), "got: {}", p.body);
609    }
610
611    #[test]
612    fn new_resource_lid_without_anchor_uses_sequential() {
613        let template = "no anchor {{x | lid: '__BRAZESYNC__'}} mid {{x | lid: '__BRAZESYNC__'}}";
614        let p = prepare_field(template, None, FieldKind::EmailSubject);
615        assert!(p.body.contains("'lid_1'"));
616        assert!(p.body.contains("'lid_2'"));
617    }
618
619    #[test]
620    fn new_resource_strips_cb_id_filter() {
621        let template = "before {{content_blocks.${promo} | id: '__BRAZESYNC__'}} after";
622        let p = prepare_field(template, None, FieldKind::ContentBlock);
623        assert_eq!(p.body, "before {{content_blocks.${promo}}} after");
624        assert!(p.warnings.iter().any(|w| w.contains("promo")));
625    }
626
627    #[test]
628    fn lid_without_remote_match_falls_back_to_slug() {
629        let template = r#"<a href="https://x.com/cta">{{x | lid: '__BRAZESYNC__'}}</a>"#;
630        let remote = r#"<p>no anchor</p>"#;
631        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
632        assert!(p.errors.is_empty(), "{:?}", p.errors);
633        assert!(p.body.contains("'cta'"), "got: {}", p.body);
634        assert!(p
635            .warnings
636            .iter()
637            .any(|w| w.contains("not found in remote body")));
638    }
639
640    #[test]
641    fn template_with_more_lids_than_remote_resolves_extras_via_fallback() {
642        let template = r#"<a href="https://x.com/a">{{x | lid: '__BRAZESYNC__'}}</a>
643<a href="https://x.com/b">{{x | lid: '__BRAZESYNC__'}}</a>
644<a href="https://x.com/c">{{x | lid: '__BRAZESYNC__'}}</a>"#;
645        let remote = r#"<a href="https://x.com/a">{{x | lid: 'remoteval1a'}}</a>"#;
646        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
647        assert!(p.errors.is_empty(), "{:?}", p.errors);
648        assert!(p.body.contains("'remoteval1a'"));
649        assert!(p.body.contains("'b'"), "got: {}", p.body);
650        assert!(p.body.contains("'c'"), "got: {}", p.body);
651    }
652
653    #[test]
654    fn subject_with_more_lids_than_remote_falls_back() {
655        let template = "{{x | lid: '__BRAZESYNC__'}} A {{y | lid: '__BRAZESYNC__'}}";
656        let remote = "{{x | lid: 'firstval123'}} A";
657        let p = prepare_field(template, Some(remote), FieldKind::EmailSubject);
658        assert!(p.errors.is_empty(), "{:?}", p.errors);
659        assert!(p.body.contains("'firstval123'"));
660        assert!(p.body.contains("'lid_1'"), "got: {}", p.body);
661    }
662
663    #[test]
664    fn retired_envelope_is_fatal() {
665        let template = "stuff __BRAZESYNC.lid.foo__ stuff";
666        let p = prepare_field(template, None, FieldKind::ContentBlock);
667        assert!(p
668            .errors
669            .iter()
670            .any(|e| matches!(e, ResolutionError::RetiredNamespace { .. })));
671    }
672
673    #[test]
674    fn unknown_context_is_fatal() {
675        let template = "bare __BRAZESYNC__ token";
676        let p = prepare_field(template, Some(""), FieldKind::ContentBlock);
677        assert!(p
678            .errors
679            .iter()
680            .any(|e| matches!(e, ResolutionError::UnknownContext { .. })));
681    }
682
683    #[test]
684    fn vml_href_anchors_lid() {
685        let template = r#"<v:roundrect href="https://x.com/page/?lid={{x | lid: '__BRAZESYNC__'}}">label</v:roundrect>"#;
686        let remote = r#"<v:roundrect href="https://x.com/page/?lid={{x | lid: 'liveeeeeeee1'}}">label</v:roundrect>"#;
687        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
688        assert!(p.errors.is_empty(), "{:?}", p.errors);
689        assert!(p.body.contains("'liveeeeeeee1'"));
690    }
691
692    #[test]
693    fn plaintext_url_anchor_matches() {
694        let template = "Visit https://x.com/cta {{x | lid: '__BRAZESYNC__'}} now";
695        let remote = "Visit https://x.com/cta {{x | lid: 'liveeeeeeee1'}} now";
696        let p = prepare_field(template, Some(remote), FieldKind::EmailPlainBody);
697        assert!(p.errors.is_empty());
698        assert!(p.body.contains("'liveeeeeeee1'"));
699    }
700
701    #[test]
702    fn subject_lid_resolves_positionally() {
703        let template = "{{x | lid: '__BRAZESYNC__'}} A {{y | lid: '__BRAZESYNC__'}}";
704        let remote = "{{x | lid: 'firstval123'}} A {{y | lid: 'secondval2b'}}";
705        let p = prepare_field(template, Some(remote), FieldKind::EmailSubject);
706        assert!(p.errors.is_empty(), "{:?}", p.errors);
707        assert!(p.body.contains("'firstval123'"));
708        assert!(p.body.contains("'secondval2b'"));
709    }
710
711    #[test]
712    fn new_resource_plaintext_lid_uses_url_slug() {
713        let template = "Visit https://x.com/spring-sale {{x | lid: '__BRAZESYNC__'}} now";
714        let p = prepare_field(template, None, FieldKind::EmailPlainBody);
715        assert!(p.errors.is_empty(), "{:?}", p.errors);
716        assert!(
717            p.body.contains("'spring_sale'"),
718            "plaintext URL slug must be used, got: {}",
719            p.body
720        );
721    }
722
723    #[test]
724    fn url_fallback_disambiguates_against_remote_slug_collision() {
725        // The /checkout URL's natural slug 'checkout' also happens to
726        // appear as a remote lid value (for an unrelated /a anchor).
727        // The seeded `used` map must force the fallback to 'checkout_2'.
728        let template = r#"<a href="https://x.com/a">{{x | lid: '__BRAZESYNC__'}}</a>
729<a href="https://x.com/checkout">{{x | lid: '__BRAZESYNC__'}}</a>"#;
730        let remote = r#"<a href="https://x.com/a">{{x | lid: 'checkout'}}</a>"#;
731        let p = prepare_field(template, Some(remote), FieldKind::ContentBlock);
732        assert!(p.errors.is_empty(), "{:?}", p.errors);
733        let count = p.body.matches("'checkout'").count();
734        assert_eq!(
735            count, 1,
736            "remote lid must appear exactly once, got: {}",
737            p.body
738        );
739        assert!(p.body.contains("'checkout_2'"), "got: {}", p.body);
740    }
741
742    #[test]
743    fn url_path_tail_strips_query_and_fragment() {
744        assert_eq!(url_path_tail("https://x.com/page/?utm=1"), "page");
745        assert_eq!(url_path_tail("https://x.com/page/#section"), "page");
746        assert_eq!(url_path_tail("https://x.com/page/?a=1#b"), "page");
747        assert_eq!(url_path_tail("https://x.com/"), "");
748        assert_eq!(url_path_tail("https://x.com/sale"), "sale");
749    }
750}