1use 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#[derive(Debug, Clone)]
28pub struct PreparedTemplate {
29 pub body: String,
33 pub errors: Vec<ResolutionError>,
35 pub warnings: Vec<String>,
38 pub fallbacks: Vec<LidFallback>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LidFallback {
51 pub anchor: Option<String>,
52 pub value: String,
53}
54
55pub fn prepare_field(template: &str, remote: Option<&str>, field: FieldKind) -> PreparedTemplate {
60 let mut errors: Vec<ResolutionError> = Vec::new();
61
62 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 let placeholders = extract_placeholders(&body);
86 let mut resolved: Vec<(usize, usize, Option<String>)> = Vec::new();
87
88 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 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 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
164fn 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 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 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
264fn 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
290fn 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 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
339fn 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
382fn 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 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
432fn 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 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
470fn 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 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}