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
205/// Match `<a … href="…">` openings only — used by the legacy
206/// prefix-scan fallback for the "lid sits between `<a>` and `</a>` as
207/// link text" pattern. The enclosing-tag path uses [`url_attr_re`] to
208/// handle any element (VML, SVG, …).
209fn anchor_href_re() -> &'static Regex {
210    static RE: OnceLock<Regex> = OnceLock::new();
211    RE.get_or_init(|| {
212        Regex::new(r#"(?i)<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')"#)
213            .expect("anchor href regex is valid")
214    })
215}
216
217/// Match a URL-bearing attribute (`href`, `src`, `action`) — with an
218/// optional namespace prefix like `xlink:href` or `v:href` — and
219/// capture its quoted value. Used to extract the URL anchor from the
220/// open tag enclosing a lid token, regardless of element name.
221///
222/// Leading `\s` (not `\b`) is required so that hyphen-prefixed custom
223/// attributes (`data-href`, `aria-*`, …) don't tail-match as `href`.
224fn url_attr_re() -> &'static Regex {
225    static RE: OnceLock<Regex> = OnceLock::new();
226    RE.get_or_init(|| {
227        Regex::new(
228            r#"(?i)\s(?:[a-z][a-z0-9_-]*:)?(?:href|src|action)\s*=\s*(?:"([^"]*)"|'([^']*)')"#,
229        )
230        .expect("url attr regex is valid")
231    })
232}
233
234fn plaintext_url_re() -> &'static Regex {
235    static RE: OnceLock<Regex> = OnceLock::new();
236    RE.get_or_init(|| Regex::new(r#"https?://[^\s<>"']+"#).expect("plaintext URL regex is valid"))
237}
238
239fn name_lid_for_field(
240    body: &str,
241    lid_token_offset: usize,
242    field: FieldKind,
243    used: &mut BTreeMap<String, usize>,
244) -> (Option<String>, String) {
245    let url = preceding_url(body, lid_token_offset, field);
246    let key_source: String = match &url {
247        Some(u) => url_path_tail(u).to_string(),
248        None => String::new(),
249    };
250    let slug = slug_for_lid(&key_source);
251    let key = unique_key(slug, used);
252    (url, key)
253}
254
255fn preceding_url(body: &str, lid_token_offset: usize, field: FieldKind) -> Option<String> {
256    let raw = if field.supports_html_anchor() {
257        // If the lid sits inside an open tag, ONLY that tag's URL
258        // attribute counts — falling through to an earlier `<a href>`
259        // would misattribute lids that live in a non-URL attribute of
260        // some other element. Outside any open tag, the legacy
261        // `<a>…lid…</a>` link-text pattern is the only signal we have.
262        match enclosing_open_tag(body, lid_token_offset) {
263            Some(tag) => url_attr_re()
264                .captures(tag)
265                .and_then(|cap| cap.get(1).or(cap.get(2)))
266                .map(|x| x.as_str().to_string()),
267            None => {
268                let prefix = &body[..lid_token_offset];
269                anchor_href_re()
270                    .captures_iter(prefix)
271                    .last()
272                    .and_then(|cap| cap.get(1).or(cap.get(2)))
273                    .map(|m| m.as_str().to_string())
274            }
275        }
276    } else if field.supports_plaintext_anchor() {
277        let prefix = &body[..lid_token_offset];
278        plaintext_url_re()
279            .find_iter(prefix)
280            .last()
281            .map(|m| m.as_str().to_string())
282    } else {
283        None
284    };
285    raw.map(|r| normalize_url(&r))
286}
287
288/// Return the open tag (any element) whose `<…>` span contains
289/// `lid_token_offset` — i.e. the lid is inside an attribute area, not
290/// in element text. Trusts that attribute values never contain a raw
291/// `>`. Excludes `</…>`, `<!--…-->`, `<?…?>` via the leading-letter
292/// constraint in [`element_open_tag_re`].
293fn enclosing_open_tag(body: &str, lid_token_offset: usize) -> Option<&str> {
294    let re = element_open_tag_re();
295    for m in re.find_iter(body) {
296        if m.start() > lid_token_offset {
297            break;
298        }
299        if m.end() > lid_token_offset {
300            return Some(&body[m.start()..m.end()]);
301        }
302    }
303    None
304}
305
306fn element_open_tag_re() -> &'static Regex {
307    static RE: OnceLock<Regex> = OnceLock::new();
308    // `<NAME …>` where NAME starts with a letter and may include
309    // namespace prefix (`v:roundrect`, `svg:a`), digits, `_`, `-`,
310    // or `.`. Excludes `</…>`, `<!--…-->`, `<?…?>`.
311    RE.get_or_init(|| {
312        Regex::new(r#"(?i)<[a-z][a-z0-9_.:-]*\b[^>]*>"#).expect("element open tag regex is valid")
313    })
314}
315
316fn url_path_tail(url: &str) -> String {
317    // Strip scheme://host and any leading slashes; take the last
318    // non-empty path component. `https://example.com/promo/spring-sale`
319    // → `spring-sale`. Bare host or trailing slash → empty (caller
320    // applies the `link_` fallback via slug_for_lid).
321    let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
322    let path_start = after_scheme
323        .find('/')
324        .map(|i| i + 1)
325        .unwrap_or(after_scheme.len());
326    let path = &after_scheme[path_start..];
327    path.rsplit('/')
328        .find(|s| !s.is_empty())
329        .unwrap_or("")
330        .to_string()
331}
332
333fn unique_key(base: String, used: &mut BTreeMap<String, usize>) -> String {
334    let count = used.entry(base.clone()).or_insert(0);
335    *count += 1;
336    if *count == 1 {
337        base
338    } else {
339        format!("{base}_{count}")
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn idempotent_on_already_templatized_body() {
349        let body = "<p>__BRAZESYNC.lid.cta__ kept verbatim</p>";
350        let r = templatize_body(body, FieldKind::ContentBlock);
351        assert_eq!(r.new_body, body);
352        assert!(r.entries.is_empty());
353    }
354
355    #[test]
356    fn rewrites_html_lid_with_url_anchor() {
357        let body = r#"<a href="https://example.com/spring-sale">{{x | lid: 'ai8kexrxcp03'}}</a>"#;
358        let r = templatize_body(body, FieldKind::ContentBlock);
359        assert!(r.new_body.contains("__BRAZESYNC.lid.spring_sale__"));
360        assert_eq!(r.entries.len(), 1);
361        match &r.entries[0] {
362            DetectedEntry::Lid { key, value, url } => {
363                assert_eq!(key, "spring_sale");
364                assert_eq!(value, "ai8kexrxcp03");
365                assert_eq!(url.as_deref(), Some("https://example.com/spring-sale"));
366            }
367            _ => panic!("expected Lid"),
368        }
369    }
370
371    #[test]
372    fn rewrites_cb_id_include() {
373        let body = "{{content_blocks.${promo_banner} | id: 'cb42'}}";
374        let r = templatize_body(body, FieldKind::ContentBlock);
375        assert!(r.new_body.contains("__BRAZESYNC.cb_id.promo_banner__"));
376        // Preserves ${NAME} so export correlation still works.
377        assert!(r.new_body.contains("${promo_banner}"));
378        assert_eq!(r.entries.len(), 1);
379    }
380
381    #[test]
382    fn dedupes_duplicate_url_with_sequential_suffix() {
383        let body = r#"
384<a href="https://example.com/cta">{{x | lid: 'ai8kexrxcp03'}}A</a>
385<a href="https://example.com/cta">{{x | lid: 'bj9lfsysxq14'}}B</a>"#;
386        let r = templatize_body(body, FieldKind::ContentBlock);
387        let keys: Vec<&str> = r.entries.iter().map(DetectedEntry::key).collect();
388        assert_eq!(keys, ["cta", "cta_2"]);
389    }
390
391    #[test]
392    fn plaintext_url_anchor_works() {
393        let body = "Click https://example.com/promo {{x | lid: 'ai8kexrxcp03'}} now.";
394        let r = templatize_body(body, FieldKind::EmailPlainBody);
395        match &r.entries[0] {
396            DetectedEntry::Lid { key, url, .. } => {
397                assert_eq!(key, "promo");
398                assert_eq!(url.as_deref(), Some("https://example.com/promo"));
399            }
400            _ => panic!(),
401        }
402    }
403
404    #[test]
405    fn subject_lid_warns_about_export_refresh_gap() {
406        // subject has no URL anchor — slug falls back to `link_`. The
407        // CLI must surface that `export` won't refresh this entry for
408        // other envs so the operator knows to maintain values manually.
409        let body = "Hello {{x | lid: 'ai8kexrxcp03'}} world";
410        let r = templatize_body(body, FieldKind::EmailSubject);
411        assert!(
412            r.warnings
413                .iter()
414                .any(|w| w.contains("export") && w.contains("subject")),
415            "expected manual-maintenance warning, got: {:?}",
416            r.warnings
417        );
418        match &r.entries[0] {
419            DetectedEntry::Lid { key, url, .. } => {
420                assert_eq!(key, "link_");
421                assert!(url.is_none());
422            }
423            _ => panic!(),
424        }
425    }
426
427    #[test]
428    fn repeated_cb_id_name_reuses_key() {
429        // RFC: same `${NAME}` resolves to the same content_block. The
430        // values file must have ONE entry for it, not `name` + `name_2`,
431        // otherwise export refresh can never populate the duplicates.
432        let body = "{{content_blocks.${promo} | id: 'cb10'}} ... \
433                    {{content_blocks.${promo} | id: 'cb10'}}";
434        let r = templatize_body(body, FieldKind::ContentBlock);
435        assert_eq!(r.entries.len(), 2, "both occurrences detected");
436        assert_eq!(r.entries[0].key(), "promo");
437        assert_eq!(
438            r.entries[1].key(),
439            "promo",
440            "same ${{NAME}} must reuse the key"
441        );
442    }
443
444    #[test]
445    fn partially_templatized_body_picks_up_remaining_raw_lid() {
446        // Mixed state: one lid already templated, another still raw.
447        // The raw one MUST be detected (no early-return short-circuit).
448        let body = r#"
449<a href="https://example.com/cta">{{ x | lid: '__BRAZESYNC.lid.cta__' }}A</a>
450<a href="https://example.com/promo">{{ x | lid: 'rawvalue1234' }}B</a>"#;
451        let r = templatize_body(body, FieldKind::ContentBlock);
452        assert_eq!(r.entries.len(), 1, "the raw lid must be detected");
453        match &r.entries[0] {
454            DetectedEntry::Lid { key, value, .. } => {
455                assert_eq!(key, "promo");
456                assert_eq!(value, "rawvalue1234");
457            }
458            _ => panic!("expected Lid"),
459        }
460    }
461
462    #[test]
463    fn html_lid_without_anchor_warns() {
464        // HTML body but the lid has no preceding <a href> — RFC says
465        // this should still produce a key but flag it for the operator.
466        let body = "{{x | lid: 'ai8kexrxcp03'}} just floating";
467        let r = templatize_body(body, FieldKind::EmailHtmlBody);
468        assert_eq!(r.entries.len(), 1);
469        assert!(!r.warnings.is_empty());
470    }
471
472    #[test]
473    fn lid_inside_href_attribute_value_uses_enclosing_anchor() {
474        // Braze's typical HTML output puts the lid *inside* the href:
475        //   <a href="https://…/path/?lid={{${cblid} | lid: 'X'}}">
476        // The prefix-only scan can't see the closing quote and was
477        // falling back to a sequential `link_` key for ~all anchors.
478        let body = r#"<a href="https://med.example.com/product/jaypirca/50mg/?lid={{${cblid} | lid: 'ai8kexrxcp03'}}"><img src="x"/></a>"#;
479        let r = templatize_body(body, FieldKind::ContentBlock);
480        assert_eq!(r.entries.len(), 1);
481        match &r.entries[0] {
482            DetectedEntry::Lid { key, url, .. } => {
483                assert_eq!(key, "link_50mg");
484                assert_eq!(
485                    url.as_deref(),
486                    Some("https://med.example.com/product/jaypirca/50mg/")
487                );
488            }
489            _ => panic!("expected Lid"),
490        }
491        assert!(
492            r.warnings.is_empty(),
493            "no-anchor warning should not fire when href encloses the lid"
494        );
495    }
496
497    #[test]
498    fn enclosing_anchor_takes_precedence_over_earlier_unrelated_href() {
499        // Even if an earlier, fully-closed <a href> exists, the lid
500        // that lives inside a *different* later <a …> tag should use
501        // that later tag's href, not the prior one.
502        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>"#;
503        let r = templatize_body(body, FieldKind::ContentBlock);
504        match &r.entries[0] {
505            DetectedEntry::Lid { url, .. } => {
506                assert_eq!(url.as_deref(), Some("https://example.com/new/path/"));
507            }
508            _ => panic!(),
509        }
510    }
511
512    #[test]
513    fn enclosing_anchor_without_href_falls_back_to_prior_href() {
514        // The lid lives inside an `<a>` open tag that has no `href`
515        // (e.g. `<a name="…">`). `enclosing_anchor_href` finds the
516        // enclosing tag but returns None because there's no href to
517        // extract — the legacy prefix scan must still pick up the
518        // earlier `<a href>` so we don't regress that pattern.
519        let body = r#"<a href="https://example.com/earlier/path">x</a> <a name="anchor">text {{x | lid: 'ai8kexrxcp03'}}</a>"#;
520        let r = templatize_body(body, FieldKind::ContentBlock);
521        assert_eq!(r.entries.len(), 1);
522        match &r.entries[0] {
523            DetectedEntry::Lid { url, .. } => {
524                assert_eq!(url.as_deref(), Some("https://example.com/earlier/path"));
525            }
526            _ => panic!("expected Lid"),
527        }
528    }
529
530    #[test]
531    fn vml_roundrect_href_anchors_lid() {
532        // Outlook-compatible email content blocks wrap CTAs in VML
533        // (`<v:roundrect href="…">`). The lid lives inside the VML
534        // tag's `href`, NOT inside any `<a>` — pre-fix this fell back
535        // to a sequential `link_` key.
536        let body = r#"<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="https://hokto.example.com/page/?lid={{${cblid} | lid: 'ulab324mjv2a'}}" style="…"></v:roundrect>"#;
537        let r = templatize_body(body, FieldKind::ContentBlock);
538        assert_eq!(r.entries.len(), 1);
539        match &r.entries[0] {
540            DetectedEntry::Lid { key, url, value } => {
541                assert_eq!(value, "ulab324mjv2a");
542                assert_eq!(url.as_deref(), Some("https://hokto.example.com/page/"));
543                assert_eq!(key, "page");
544            }
545            _ => panic!("expected Lid"),
546        }
547        assert!(
548            r.warnings.is_empty(),
549            "VML href should not trigger no-anchor warning, got: {:?}",
550            r.warnings
551        );
552    }
553
554    #[test]
555    fn svg_anchor_xlink_href_anchors_lid() {
556        // SVG anchors use `xlink:href` (namespace-prefixed attribute).
557        let body = r#"<svg:a xlink:href="https://example.com/svg/path/?lid={{x | lid: 'ai8kexrxcp03'}}"><svg:rect/></svg:a>"#;
558        let r = templatize_body(body, FieldKind::ContentBlock);
559        assert_eq!(r.entries.len(), 1);
560        match &r.entries[0] {
561            DetectedEntry::Lid { key, url, .. } => {
562                assert_eq!(key, "path");
563                assert_eq!(url.as_deref(), Some("https://example.com/svg/path/"));
564            }
565            _ => panic!("expected Lid"),
566        }
567    }
568
569    #[test]
570    fn vml_then_anchor_to_same_url_dedupes_with_suffix() {
571        // Real hokuto-braze pattern: a VML CTA followed by a fallback
572        // `<a>` to the same URL. Both should resolve to URL-derived
573        // keys (no sequential `link_` fallback), with the second
574        // getting the `_2` suffix from existing dedup logic.
575        let body = r#"
576<v:roundrect href="https://example.com/promo/?lid={{x | lid: 'aaaaaaaa1111'}}"></v:roundrect>
577<a href="https://example.com/promo/?lid={{x | lid: 'bbbbbbbb2222'}}">label</a>"#;
578        let r = templatize_body(body, FieldKind::ContentBlock);
579        assert_eq!(r.entries.len(), 2);
580        let keys: Vec<&str> = r.entries.iter().map(DetectedEntry::key).collect();
581        assert_eq!(keys, ["promo", "promo_2"]);
582        assert!(r.warnings.is_empty(), "no warnings expected");
583    }
584
585    #[test]
586    fn data_prefixed_attrs_are_not_treated_as_url_anchor() {
587        let body = r#"<button data-action="track" data-href="ignored">{{x | lid: 'ulab324mjv2a'}}</button>"#;
588        let r = templatize_body(body, FieldKind::ContentBlock);
589        assert_eq!(r.entries.len(), 1);
590        match &r.entries[0] {
591            DetectedEntry::Lid { key, url, value } => {
592                assert_eq!(value, "ulab324mjv2a");
593                assert!(
594                    url.is_none(),
595                    "data-* attributes must not be treated as URL anchors, got url={url:?}"
596                );
597                assert!(
598                    key.starts_with("link_"),
599                    "expected sequential link_ fallback, got key={key}"
600                );
601            }
602            _ => panic!("expected Lid"),
603        }
604    }
605
606    #[test]
607    fn lid_inside_non_url_attr_does_not_inherit_prior_anchor_href() {
608        // Regression: lid inside a non-URL attribute (`data-x`) must
609        // not fall through to the unrelated prior `<a href>`.
610        let body = r#"<a href="https://example.com/promo/">prev</a><custom data-x="{{x | lid: 'abcd0000zzzz'}}"></custom>"#;
611        let r = templatize_body(body, FieldKind::ContentBlock);
612        assert_eq!(r.entries.len(), 1);
613        match &r.entries[0] {
614            DetectedEntry::Lid { key, url, value } => {
615                assert_eq!(value, "abcd0000zzzz");
616                assert!(
617                    url.is_none(),
618                    "lid inside a non-URL attribute must not inherit a prior <a href>, got url={url:?}"
619                );
620                assert!(
621                    key.starts_with("link_"),
622                    "expected sequential link_ fallback, got key={key}"
623                );
624            }
625            _ => panic!("expected Lid"),
626        }
627    }
628
629    #[test]
630    fn url_path_tail_uses_last_nonempty_segment() {
631        assert_eq!(
632            url_path_tail("https://example.com/promo/spring-sale"),
633            "spring-sale"
634        );
635        assert_eq!(url_path_tail("https://example.com/"), "");
636        assert_eq!(url_path_tail("https://example.com"), "");
637    }
638}