Skip to main content

braze_sync/values/
exporter.rs

1//! Per-export-run values updates (RFC §2.5 "既存リソース" path).
2//!
3//! Inputs:
4//! - the LOCAL template body, which carries the source of truth for
5//!   placeholder positions and (via existing values entries) URL /
6//!   `${NAME}` correlation anchors;
7//! - the REMOTE body just fetched from Braze, which carries the
8//!   fresh lid / cb_id values to write back.
9//!
10//! Output: in-place updates to a [`ValuesFile`], plus a structured
11//! report of how many entries were touched and any warnings the
12//! operator should see.
13
14use std::collections::{BTreeMap, BTreeSet, VecDeque};
15
16use crate::resource::{ContentBlock, EmailTemplate};
17use crate::values::correlation::{
18    extract_cb_id_values, extract_html_lid_values, extract_plaintext_lid_values, normalize_url,
19    LidCorrelation,
20};
21use crate::values::placeholder::{extract_placeholders, PlaceholderType};
22use crate::values::schema::{ContentBlockValues, EmailTemplateValues, FieldValues, ValuesFile};
23
24/// Per-resource summary of an export run.
25#[derive(Debug, Default, Clone)]
26pub struct ExportUpdates {
27    pub lid_updates: usize,
28    pub cb_id_updates: usize,
29    /// values entries whose `__BRAZESYNC.*__` placeholder is no longer
30    /// present in the local template (RFC §2.5 step 6).
31    pub orphan_warnings: Vec<String>,
32    /// Placeholders referenced in the local template that have no
33    /// matching entry in the values file. Symmetric to `orphan_warnings`
34    /// (which is the inverse direction). Kept separate so operators can
35    /// distinguish "add to values" from "remove from values".
36    pub missing_entry_warnings: Vec<String>,
37    /// values entries whose URL anchor could not be matched in the
38    /// remote body (URL deleted in dashboard, multiple matches, etc).
39    pub ambiguity_warnings: Vec<String>,
40}
41
42impl ExportUpdates {
43    pub fn merge(&mut self, other: ExportUpdates) {
44        self.lid_updates += other.lid_updates;
45        self.cb_id_updates += other.cb_id_updates;
46        self.orphan_warnings.extend(other.orphan_warnings);
47        self.missing_entry_warnings
48            .extend(other.missing_entry_warnings);
49        self.ambiguity_warnings.extend(other.ambiguity_warnings);
50    }
51}
52
53/// Refresh `values.content_block.<local.name>` entries from `remote`,
54/// using `local` to determine placeholder positions and orphan status.
55///
56/// Returns the update summary. Does nothing (returns an empty summary)
57/// if `local` has no placeholders — that path keeps the existing
58/// verbatim-export behavior.
59pub fn refresh_content_block_values(
60    local: &ContentBlock,
61    remote: &ContentBlock,
62    values: &mut ValuesFile,
63) -> ExportUpdates {
64    let mut report = ExportUpdates::default();
65    let referenced = referenced_keys(&local.content);
66    if referenced.is_empty() {
67        return report;
68    }
69
70    let cb_entry = values.content_block.entry(local.name.clone()).or_default();
71
72    let html_pairs = extract_html_lid_values(&remote.content);
73    refresh_lid_entries(
74        &mut cb_entry.lid,
75        &html_pairs,
76        &local.content,
77        &referenced.lid,
78        &format!("content_block '{}' lid", local.name),
79        &mut report,
80    );
81
82    let cb_id_pairs = extract_cb_id_values(&remote.content);
83    refresh_cb_id_entries(
84        &mut cb_entry.cb_id,
85        &cb_id_pairs,
86        &referenced.cb_id,
87        &format!("content_block '{}' cb_id", local.name),
88        &mut report,
89    );
90
91    flag_orphans(cb_entry, &referenced, &local.name, &mut report);
92    flag_missing_entries(
93        cb_entry.lid.keys(),
94        cb_entry.cb_id.keys(),
95        &referenced,
96        &format!("content_block '{}'", local.name),
97        &mut report,
98    );
99    report
100}
101
102/// Refresh `values.email_template.<local.name>` entries field by field.
103pub fn refresh_email_template_values(
104    local: &EmailTemplate,
105    remote: &EmailTemplate,
106    values: &mut ValuesFile,
107) -> ExportUpdates {
108    let mut report = ExportUpdates::default();
109
110    let subject_refs = referenced_keys(&local.subject);
111    let body_html_refs = referenced_keys(&local.body_html);
112    let body_plain_refs = referenced_keys(&local.body_plaintext);
113    let preheader_refs = referenced_keys(local.preheader.as_deref().unwrap_or(""));
114
115    let any_refs = !(subject_refs.is_empty()
116        && body_html_refs.is_empty()
117        && body_plain_refs.is_empty()
118        && preheader_refs.is_empty());
119    if !any_refs {
120        return report;
121    }
122
123    let et_entry = values.email_template.entry(local.name.clone()).or_default();
124
125    refresh_field(
126        &mut et_entry.body_html,
127        &extract_html_lid_values(&remote.body_html),
128        &extract_cb_id_values(&remote.body_html),
129        &local.body_html,
130        &body_html_refs,
131        &local.name,
132        "body_html",
133        &mut report,
134    );
135    refresh_field(
136        &mut et_entry.body_plaintext,
137        &extract_plaintext_lid_values(&remote.body_plaintext),
138        &extract_cb_id_values(&remote.body_plaintext),
139        &local.body_plaintext,
140        &body_plain_refs,
141        &local.name,
142        "body_plaintext",
143        &mut report,
144    );
145    // subject / preheader: anchor-based lid match isn't covered in this
146    // first cut, so we deliberately skip refresh_lid_entries here —
147    // calling it with empty pairs would emit "url not found" warnings
148    // for every existing entry. cb_id refresh is still performed so
149    // users who template a {{content_blocks.${…}}} include get correct
150    // values. preheader is refreshed even when remote.preheader is None
151    // so any existing cb_id entry referenced by the local placeholder
152    // surfaces a "no token found" warning instead of silently keeping a
153    // stale value.
154    refresh_cb_id_entries(
155        &mut et_entry.subject.cb_id,
156        &extract_cb_id_values(&remote.subject),
157        &subject_refs.cb_id,
158        &format!("email_template '{}' (subject) cb_id", local.name),
159        &mut report,
160    );
161    let preheader_body = remote.preheader.as_deref().unwrap_or("");
162    refresh_cb_id_entries(
163        &mut et_entry.preheader.cb_id,
164        &extract_cb_id_values(preheader_body),
165        &preheader_refs.cb_id,
166        &format!("email_template '{}' (preheader) cb_id", local.name),
167        &mut report,
168    );
169
170    flag_email_template_orphans(
171        et_entry,
172        &subject_refs,
173        &preheader_refs,
174        &body_html_refs,
175        &body_plain_refs,
176        &local.name,
177        &mut report,
178    );
179    flag_email_template_missing_entries(
180        et_entry,
181        &subject_refs,
182        &preheader_refs,
183        &body_html_refs,
184        &body_plain_refs,
185        &local.name,
186        &mut report,
187    );
188    report
189}
190
191fn referenced_keys(body: &str) -> ReferencedKeys {
192    let mut out = ReferencedKeys::default();
193    for ph in extract_placeholders(body) {
194        match ph.ty {
195            PlaceholderType::Lid => {
196                out.lid.insert(ph.key);
197            }
198            PlaceholderType::CbId => {
199                out.cb_id.insert(ph.key);
200            }
201            PlaceholderType::Custom | PlaceholderType::Global => {}
202        }
203    }
204    out
205}
206
207#[derive(Debug, Default)]
208struct ReferencedKeys {
209    lid: BTreeSet<String>,
210    cb_id: BTreeSet<String>,
211}
212
213impl ReferencedKeys {
214    fn is_empty(&self) -> bool {
215        self.lid.is_empty() && self.cb_id.is_empty()
216    }
217}
218
219fn refresh_lid_entries(
220    entries: &mut BTreeMap<String, crate::values::schema::LidEntry>,
221    remote_pairs: &[LidCorrelation],
222    local_body: &str,
223    referenced: &BTreeSet<String>,
224    scope_label: &str,
225    report: &mut ExportUpdates,
226) {
227    // Group remote matches by normalized URL so multiple local entries
228    // sharing one URL each consume a distinct remote occurrence.
229    let mut by_url: BTreeMap<String, VecDeque<&LidCorrelation>> = BTreeMap::new();
230    let mut remote_count: BTreeMap<String, usize> = BTreeMap::new();
231    for p in remote_pairs {
232        by_url.entry(p.url.clone()).or_default().push_back(p);
233        *remote_count.entry(p.url.clone()).or_default() += 1;
234    }
235    // Only referenced entries contribute to demand: an orphan sharing a
236    // URL with a referenced sibling must not inflate `local_n` into the
237    // "positional fallback" warning, nor consume a remote occurrence.
238    let mut local_demand: BTreeMap<String, usize> = BTreeMap::new();
239    for (key, entry) in entries.iter() {
240        if !referenced.contains(key) {
241            continue;
242        }
243        if let Some(url) = &entry.url {
244            *local_demand.entry(normalize_url(url)).or_default() += 1;
245        }
246    }
247
248    // Assignment order = first appearance of each `lid` placeholder
249    // in `local_body`. BTreeMap (alphabetical) order would swap values
250    // whenever alphabetical order differs from source order. Only
251    // referenced keys participate so orphans cannot pop_front the URL
252    // bucket and steal a value from a referenced anchor-only sibling.
253    let mut order: Vec<String> = Vec::with_capacity(referenced.len());
254    let mut seen: BTreeSet<String> = BTreeSet::new();
255    for ph in extract_placeholders(local_body) {
256        if !matches!(ph.ty, PlaceholderType::Lid) {
257            continue;
258        }
259        if entries.contains_key(&ph.key)
260            && referenced.contains(&ph.key)
261            && seen.insert(ph.key.clone())
262        {
263            order.push(ph.key);
264        }
265    }
266    // Referenced keys missing from the placeholder scan still need to
267    // be visited so the "url not found" warning fires.
268    for key in referenced {
269        if entries.contains_key(key) && !seen.contains(key) {
270            order.push(key.clone());
271        }
272    }
273
274    for key in order {
275        let entry = entries
276            .get_mut(&key)
277            .expect("order keys are derived from entries");
278        let Some(url) = entry.url.clone() else {
279            // Anchor-only correlation is out of scope; warn so the
280            // operator knows the stale value persisted.
281            report.ambiguity_warnings.push(format!(
282                "{scope_label}.{key}: entry has no `url` anchor — anchor-only correlation \
283                 is not implemented; keeping existing value"
284            ));
285            continue;
286        };
287        let needle = normalize_url(&url);
288        let remote_n = *remote_count.get(&needle).unwrap_or(&0);
289        let local_n = *local_demand.get(&needle).unwrap_or(&1);
290        let pick = by_url
291            .get_mut(&needle)
292            .and_then(|bucket| bucket.pop_front());
293        let Some(pick) = pick else {
294            report.ambiguity_warnings.push(format!(
295                "{scope_label}.{key}: url '{needle}' not found in remote body \
296                 (expected {local_n}, got {remote_n}) — keeping existing value"
297            ));
298            continue;
299        };
300        // Note ambiguity only when assignment was non-trivial: either
301        // multiple local entries share this URL (positional fallback),
302        // or remote has more occurrences than local demands (extras
303        // discarded).
304        if local_n > 1 || remote_n > local_n {
305            report.ambiguity_warnings.push(format!(
306                "{scope_label}.{key}: url '{needle}' matched {remote_n} time(s) in remote body \
307                 across {local_n} local entry(ies) — applied positional (by local source order); review"
308            ));
309        }
310        if entry.value.as_deref() != Some(pick.value.as_str()) {
311            entry.value = Some(pick.value.clone());
312            report.lid_updates += 1;
313        }
314    }
315}
316
317fn refresh_cb_id_entries(
318    entries: &mut BTreeMap<String, crate::values::schema::CbIdEntry>,
319    remote_pairs: &[crate::values::correlation::CbIdCorrelation],
320    referenced: &BTreeSet<String>,
321    scope_label: &str,
322    report: &mut ExportUpdates,
323) {
324    for (key, entry) in entries.iter_mut() {
325        // Orphans must not be rewritten even when a remote ${NAME}
326        // happens to slug-match the key — `flag_orphans` owns them.
327        if !referenced.contains(key) {
328            continue;
329        }
330        let matches: Vec<&crate::values::correlation::CbIdCorrelation> =
331            remote_pairs.iter().filter(|p| p.key == *key).collect();
332        match matches.len() {
333            0 => {
334                report.ambiguity_warnings.push(format!(
335                    "{scope_label}.{key}: no `{{{{content_blocks.${{NAME}} | id: …}}}}` \
336                     include resolving to key '{key}' found in remote body — \
337                     keeping existing value"
338                ));
339            }
340            1 => {
341                let new_value = matches[0].value.clone();
342                if entry.value.as_deref() != Some(new_value.as_str()) {
343                    entry.value = Some(new_value);
344                    report.cb_id_updates += 1;
345                }
346            }
347            _ => {
348                report.ambiguity_warnings.push(format!(
349                    "{scope_label}.{key}: matched {} times in remote body — applied positional (first); review",
350                    matches.len()
351                ));
352                let new_value = matches[0].value.clone();
353                if entry.value.as_deref() != Some(new_value.as_str()) {
354                    entry.value = Some(new_value);
355                    report.cb_id_updates += 1;
356                }
357            }
358        }
359    }
360}
361
362#[allow(clippy::too_many_arguments)]
363fn refresh_field(
364    field: &mut FieldValues,
365    html_pairs: &[LidCorrelation],
366    cb_id_pairs: &[crate::values::correlation::CbIdCorrelation],
367    local_body: &str,
368    refs: &ReferencedKeys,
369    resource: &str,
370    field_name: &str,
371    report: &mut ExportUpdates,
372) {
373    refresh_lid_entries(
374        &mut field.lid,
375        html_pairs,
376        local_body,
377        &refs.lid,
378        &format!("email_template '{}' ({}) lid", resource, field_name),
379        report,
380    );
381    refresh_cb_id_entries(
382        &mut field.cb_id,
383        cb_id_pairs,
384        &refs.cb_id,
385        &format!("email_template '{}' ({}) cb_id", resource, field_name),
386        report,
387    );
388}
389
390fn flag_orphans(
391    cb_entry: &ContentBlockValues,
392    referenced: &ReferencedKeys,
393    name: &str,
394    report: &mut ExportUpdates,
395) {
396    for key in cb_entry.lid.keys() {
397        if !referenced.lid.contains(key) {
398            report.orphan_warnings.push(format!(
399                "content_block '{name}' values has orphan lid key '{key}' \
400                 (no placeholder references it). Remove manually if intended."
401            ));
402        }
403    }
404    for key in cb_entry.cb_id.keys() {
405        if !referenced.cb_id.contains(key) {
406            report.orphan_warnings.push(format!(
407                "content_block '{name}' values has orphan cb_id key '{key}' \
408                 (no placeholder references it). Remove manually if intended."
409            ));
410        }
411    }
412}
413
414#[allow(clippy::too_many_arguments)]
415fn flag_email_template_orphans(
416    et_entry: &EmailTemplateValues,
417    subject_refs: &ReferencedKeys,
418    preheader_refs: &ReferencedKeys,
419    body_html_refs: &ReferencedKeys,
420    body_plain_refs: &ReferencedKeys,
421    name: &str,
422    report: &mut ExportUpdates,
423) {
424    for (field_name, field, refs) in [
425        ("subject", &et_entry.subject, subject_refs),
426        ("preheader", &et_entry.preheader, preheader_refs),
427        ("body_html", &et_entry.body_html, body_html_refs),
428        ("body_plaintext", &et_entry.body_plaintext, body_plain_refs),
429    ] {
430        for key in field.lid.keys() {
431            if !refs.lid.contains(key) {
432                report.orphan_warnings.push(format!(
433                    "email_template '{name}' ({field_name}) values has orphan lid key '{key}' \
434                     (no placeholder references it). Remove manually if intended."
435                ));
436            }
437        }
438        for key in field.cb_id.keys() {
439            if !refs.cb_id.contains(key) {
440                report.orphan_warnings.push(format!(
441                    "email_template '{name}' ({field_name}) values has orphan cb_id key '{key}' \
442                     (no placeholder references it). Remove manually if intended."
443                ));
444            }
445        }
446    }
447}
448
449/// Symmetric to [`flag_orphans`]: warn for placeholder references in
450/// the local template that have no corresponding values entry. apply /
451/// diff pre-flight would fail on these anyway, but surfacing them at
452/// export time tells the operator what to add before they run apply.
453fn flag_missing_entries<'a>(
454    lid_keys: impl Iterator<Item = &'a String>,
455    cb_id_keys: impl Iterator<Item = &'a String>,
456    referenced: &ReferencedKeys,
457    scope_label: &str,
458    report: &mut ExportUpdates,
459) {
460    let lid_present: BTreeSet<&String> = lid_keys.collect();
461    for key in &referenced.lid {
462        if !lid_present.contains(key) {
463            report.missing_entry_warnings.push(format!(
464                "{scope_label}: placeholder __BRAZESYNC.lid.{key}__ has no entry in values \
465                 (add `lid.{key}: {{ value: …, url: … }}` or run apply pre-flight will fail)"
466            ));
467        }
468    }
469    let cb_id_present: BTreeSet<&String> = cb_id_keys.collect();
470    for key in &referenced.cb_id {
471        if !cb_id_present.contains(key) {
472            report.missing_entry_warnings.push(format!(
473                "{scope_label}: placeholder __BRAZESYNC.cb_id.{key}__ has no entry in values \
474                 (add `cb_id.{key}: {{ value: … }}` or run apply pre-flight will fail)"
475            ));
476        }
477    }
478}
479
480#[allow(clippy::too_many_arguments)]
481fn flag_email_template_missing_entries(
482    et_entry: &EmailTemplateValues,
483    subject_refs: &ReferencedKeys,
484    preheader_refs: &ReferencedKeys,
485    body_html_refs: &ReferencedKeys,
486    body_plain_refs: &ReferencedKeys,
487    name: &str,
488    report: &mut ExportUpdates,
489) {
490    for (field_name, field, refs) in [
491        ("subject", &et_entry.subject, subject_refs),
492        ("preheader", &et_entry.preheader, preheader_refs),
493        ("body_html", &et_entry.body_html, body_html_refs),
494        ("body_plaintext", &et_entry.body_plaintext, body_plain_refs),
495    ] {
496        flag_missing_entries(
497            field.lid.keys(),
498            field.cb_id.keys(),
499            refs,
500            &format!("email_template '{name}' ({field_name})"),
501            report,
502        );
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::resource::content_block::ContentBlockState;
510    use crate::values::schema::{CbIdEntry, LidEntry};
511
512    fn cb(name: &str, body: &str) -> ContentBlock {
513        ContentBlock {
514            name: name.into(),
515            description: None,
516            content: body.into(),
517            tags: Vec::new(),
518            state: ContentBlockState::Active,
519        }
520    }
521
522    fn et(name: &str) -> EmailTemplate {
523        EmailTemplate {
524            name: name.into(),
525            subject: String::new(),
526            body_html: String::new(),
527            body_plaintext: String::new(),
528            description: None,
529            preheader: None,
530            should_inline_css: None,
531            tags: Vec::new(),
532        }
533    }
534
535    #[test]
536    fn refreshes_lid_value_from_remote_via_url_anchor() {
537        let local = cb(
538            "promo",
539            r#"<a href="https://example.com/cta">{{ x | lid: '__BRAZESYNC.lid.cta__' }}go</a>"#,
540        );
541        let remote = cb(
542            "promo",
543            r#"<a href="https://example.com/cta">{{ x | lid: 'newlidvalue1' }}go</a>"#,
544        );
545        let mut values = ValuesFile {
546            version: 1,
547            ..Default::default()
548        };
549        values.content_block.insert(
550            "promo".into(),
551            ContentBlockValues {
552                lid: [(
553                    "cta".to_string(),
554                    LidEntry {
555                        value: Some("oldlidvalue1".into()),
556                        url: Some("https://example.com/cta".into()),
557                        anchor: None,
558                    },
559                )]
560                .into_iter()
561                .collect(),
562                ..Default::default()
563            },
564        );
565        let r = refresh_content_block_values(&local, &remote, &mut values);
566        assert_eq!(r.lid_updates, 1);
567        assert_eq!(
568            values.content_block["promo"].lid["cta"].value.as_deref(),
569            Some("newlidvalue1")
570        );
571    }
572
573    #[test]
574    fn returns_no_updates_when_local_has_no_placeholders() {
575        let local = cb("plain", "<p>Hello</p>");
576        let remote = cb(
577            "plain",
578            r#"<a href="https://example.com/x">{{ y | lid: 'somelidvalue' }}</a>"#,
579        );
580        let mut values = ValuesFile {
581            version: 1,
582            ..Default::default()
583        };
584        let r = refresh_content_block_values(&local, &remote, &mut values);
585        assert_eq!(r.lid_updates, 0);
586        assert!(!values.content_block.contains_key("plain"));
587    }
588
589    #[test]
590    fn flags_orphan_keys() {
591        let local = cb("promo", "<p>__BRAZESYNC.lid.cta__</p>");
592        let remote = cb("promo", "<p>somelidvalue1</p>");
593        let mut values = ValuesFile {
594            version: 1,
595            ..Default::default()
596        };
597        values.content_block.insert(
598            "promo".into(),
599            ContentBlockValues {
600                lid: [
601                    (
602                        "cta".to_string(),
603                        LidEntry {
604                            value: Some("somelidvalue1".into()),
605                            url: Some("https://example.com/cta".into()),
606                            anchor: None,
607                        },
608                    ),
609                    (
610                        "stale_key".to_string(),
611                        LidEntry {
612                            value: Some("staaaalee1".into()),
613                            url: Some("https://example.com/stale".into()),
614                            anchor: None,
615                        },
616                    ),
617                ]
618                .into_iter()
619                .collect(),
620                ..Default::default()
621            },
622        );
623        let r = refresh_content_block_values(&local, &remote, &mut values);
624        assert!(r.orphan_warnings.iter().any(|w| w.contains("stale_key")));
625    }
626
627    #[test]
628    fn refreshes_cb_id_via_name_slug() {
629        let local = cb(
630            "page",
631            "{{content_blocks.${promo_banner} | id: '__BRAZESYNC.cb_id.promo_banner__'}}",
632        );
633        let remote = cb("page", "{{content_blocks.${promo_banner} | id: 'cb99'}}");
634        let mut values = ValuesFile {
635            version: 1,
636            ..Default::default()
637        };
638        values.content_block.insert(
639            "page".into(),
640            ContentBlockValues {
641                cb_id: [(
642                    "promo_banner".to_string(),
643                    CbIdEntry {
644                        value: Some("cb1".into()),
645                    },
646                )]
647                .into_iter()
648                .collect(),
649                ..Default::default()
650            },
651        );
652        let r = refresh_content_block_values(&local, &remote, &mut values);
653        assert_eq!(r.cb_id_updates, 1);
654        assert_eq!(
655            values.content_block["page"].cb_id["promo_banner"]
656                .value
657                .as_deref(),
658            Some("cb99")
659        );
660    }
661
662    #[test]
663    fn warns_when_url_not_in_remote() {
664        let local = cb("promo", "<a>__BRAZESYNC.lid.cta__</a>");
665        let remote = cb("promo", "<p>no anchor here</p>");
666        let mut values = ValuesFile {
667            version: 1,
668            ..Default::default()
669        };
670        values.content_block.insert(
671            "promo".into(),
672            ContentBlockValues {
673                lid: [(
674                    "cta".to_string(),
675                    LidEntry {
676                        value: Some("oldvalueeeee".into()),
677                        url: Some("https://example.com/cta".into()),
678                        anchor: None,
679                    },
680                )]
681                .into_iter()
682                .collect(),
683                ..Default::default()
684            },
685        );
686        let r = refresh_content_block_values(&local, &remote, &mut values);
687        assert_eq!(r.lid_updates, 0);
688        assert!(r.ambiguity_warnings.iter().any(|w| w.contains("not found")));
689    }
690
691    #[test]
692    fn email_template_refreshes_per_field() {
693        let mut local = et("welcome");
694        local.subject = "__BRAZESYNC.cb_id.shared_block__".into();
695        local.body_html = r#"<a href="https://example.com/cta">__BRAZESYNC.lid.cta__</a>"#.into();
696
697        let mut remote = et("welcome");
698        remote.subject = "{{content_blocks.${shared_block} | id: 'cb7'}}".into();
699        remote.body_html =
700            r#"<a href="https://example.com/cta">{{ x | lid: 'newhtmllidx' }}</a>"#.into();
701
702        let mut values = ValuesFile {
703            version: 1,
704            ..Default::default()
705        };
706        values.email_template.insert(
707            "welcome".into(),
708            EmailTemplateValues {
709                subject: FieldValues {
710                    cb_id: [(
711                        "shared_block".to_string(),
712                        CbIdEntry {
713                            value: Some("cb1".into()),
714                        },
715                    )]
716                    .into_iter()
717                    .collect(),
718                    ..Default::default()
719                },
720                body_html: FieldValues {
721                    lid: [(
722                        "cta".to_string(),
723                        LidEntry {
724                            value: Some("oldhtmllidx".into()),
725                            url: Some("https://example.com/cta".into()),
726                            anchor: None,
727                        },
728                    )]
729                    .into_iter()
730                    .collect(),
731                    ..Default::default()
732                },
733                ..Default::default()
734            },
735        );
736
737        let r = refresh_email_template_values(&local, &remote, &mut values);
738        assert_eq!(r.lid_updates, 1);
739        assert_eq!(r.cb_id_updates, 1);
740        assert_eq!(
741            values.email_template["welcome"].body_html.lid["cta"]
742                .value
743                .as_deref(),
744            Some("newhtmllidx")
745        );
746        assert_eq!(
747            values.email_template["welcome"].subject.cb_id["shared_block"]
748                .value
749                .as_deref(),
750            Some("cb7")
751        );
752    }
753
754    #[test]
755    fn lid_entry_without_url_anchor_emits_warning_and_is_skipped() {
756        // Regression: anchor-only LidEntries (url=None) used to be
757        // silently skipped, hiding stale values from the operator.
758        let local = cb("promo", "<a>__BRAZESYNC.lid.cta__</a>");
759        let remote = cb(
760            "promo",
761            r#"<a href="https://example.com/cta">{{ x | lid: 'newlidvalue1' }}</a>"#,
762        );
763        let mut values = ValuesFile {
764            version: 1,
765            ..Default::default()
766        };
767        values.content_block.insert(
768            "promo".into(),
769            ContentBlockValues {
770                lid: [(
771                    "cta".to_string(),
772                    LidEntry {
773                        value: Some("oldvalueeeee".into()),
774                        url: None,
775                        anchor: Some("anchor-only".into()),
776                    },
777                )]
778                .into_iter()
779                .collect(),
780                ..Default::default()
781            },
782        );
783        let r = refresh_content_block_values(&local, &remote, &mut values);
784        assert_eq!(r.lid_updates, 0);
785        assert!(
786            r.ambiguity_warnings
787                .iter()
788                .any(|w| w.contains("no `url` anchor")),
789            "expected anchor-only warning, got: {:?}",
790            r.ambiguity_warnings
791        );
792    }
793
794    #[test]
795    fn distinct_local_entries_sharing_url_get_distinct_remote_values() {
796        // Regression: two local placeholder keys with the same anchor
797        // URL used to both receive matches[0]; with positional
798        // assignment they should each consume a distinct remote
799        // occurrence (in BTreeMap key order).
800        let local = cb(
801            "promo",
802            r#"<a href="https://example.com/cta">__BRAZESYNC.lid.cta_a__</a>
803               <a href="https://example.com/cta">__BRAZESYNC.lid.cta_b__</a>"#,
804        );
805        let remote = cb(
806            "promo",
807            r#"<a href="https://example.com/cta">{{ x | lid: 'lidaaaaaaa1' }}</a>
808               <a href="https://example.com/cta">{{ x | lid: 'lidbbbbbbb2' }}</a>"#,
809        );
810        let mut values = ValuesFile {
811            version: 1,
812            ..Default::default()
813        };
814        values.content_block.insert(
815            "promo".into(),
816            ContentBlockValues {
817                lid: [
818                    (
819                        "cta_a".to_string(),
820                        LidEntry {
821                            value: Some("oldoldoldold".into()),
822                            url: Some("https://example.com/cta".into()),
823                            anchor: None,
824                        },
825                    ),
826                    (
827                        "cta_b".to_string(),
828                        LidEntry {
829                            value: Some("oldoldoldolb".into()),
830                            url: Some("https://example.com/cta".into()),
831                            anchor: None,
832                        },
833                    ),
834                ]
835                .into_iter()
836                .collect(),
837                ..Default::default()
838            },
839        );
840        let r = refresh_content_block_values(&local, &remote, &mut values);
841        assert_eq!(r.lid_updates, 2);
842        let cta_a = &values.content_block["promo"].lid["cta_a"];
843        let cta_b = &values.content_block["promo"].lid["cta_b"];
844        assert_eq!(cta_a.value.as_deref(), Some("lidaaaaaaa1"));
845        assert_eq!(cta_b.value.as_deref(), Some("lidbbbbbbb2"));
846        assert!(
847            r.ambiguity_warnings
848                .iter()
849                .any(|w| w.contains("positional")),
850            "expected positional warning, got: {:?}",
851            r.ambiguity_warnings
852        );
853    }
854
855    #[test]
856    fn referenced_placeholder_without_values_entry_emits_warning() {
857        // Regression: a brand-new __BRAZESYNC.lid.new_cta__ in the
858        // local template with no matching values entry used to be
859        // silently ignored at export time.
860        let local = cb(
861            "promo",
862            r#"<a href="https://example.com/x">__BRAZESYNC.lid.new_cta__</a>"#,
863        );
864        let remote = cb(
865            "promo",
866            r#"<a href="https://example.com/x">{{ x | lid: 'somelidval1' }}</a>"#,
867        );
868        let mut values = ValuesFile {
869            version: 1,
870            ..Default::default()
871        };
872        let r = refresh_content_block_values(&local, &remote, &mut values);
873        assert!(
874            r.missing_entry_warnings
875                .iter()
876                .any(|w| w.contains("__BRAZESYNC.lid.new_cta__")),
877            "expected missing-entry warning, got: {:?}",
878            r.missing_entry_warnings
879        );
880        // Inverse-direction warnings must NOT leak into the
881        // values-without-placeholder bucket.
882        assert!(
883            r.orphan_warnings.is_empty(),
884            "missing-entry warning should not appear in orphan_warnings, got: {:?}",
885            r.orphan_warnings
886        );
887    }
888
889    #[test]
890    fn subject_with_existing_lid_entry_does_not_warn_about_missing_url() {
891        // Regression: subject's refresh_field used to be called with
892        // an empty html_pairs slice, which made refresh_lid_entries
893        // emit a spurious "url ... not found in remote body" warning
894        // for every existing subject.lid entry. subject lid refresh
895        // is intentionally out of scope, so neither update nor warn.
896        let mut local = et("welcome");
897        local.subject = "{{ x | lid: '__BRAZESYNC.lid.s_lid__' }}Hi".into();
898
899        let mut remote = et("welcome");
900        remote.subject = "{{ x | lid: 'somelidnew1' }}Hi".into();
901
902        let mut values = ValuesFile {
903            version: 1,
904            ..Default::default()
905        };
906        values.email_template.insert(
907            "welcome".into(),
908            EmailTemplateValues {
909                subject: FieldValues {
910                    lid: [(
911                        "s_lid".to_string(),
912                        LidEntry {
913                            value: Some("oldlidvalu1".into()),
914                            url: Some("https://example.com/s".into()),
915                            anchor: None,
916                        },
917                    )]
918                    .into_iter()
919                    .collect(),
920                    ..Default::default()
921                },
922                ..Default::default()
923            },
924        );
925
926        let r = refresh_email_template_values(&local, &remote, &mut values);
927        assert!(
928            !r.ambiguity_warnings
929                .iter()
930                .any(|w| w.contains("not found in remote body")),
931            "subject lid refresh is unsupported; should not warn 'not found' for it, got: {:?}",
932            r.ambiguity_warnings
933        );
934    }
935
936    #[test]
937    fn preheader_none_in_remote_warns_about_missing_cb_id_token() {
938        // Regression: when remote.preheader was None the whole
939        // refresh_field call was skipped, so an existing preheader
940        // cb_id entry was silently left stale with no warning.
941        let mut local = et("welcome");
942        local.preheader = Some("__BRAZESYNC.cb_id.shared__".into());
943
944        let mut remote = et("welcome");
945        remote.preheader = None;
946
947        let mut values = ValuesFile {
948            version: 1,
949            ..Default::default()
950        };
951        values.email_template.insert(
952            "welcome".into(),
953            EmailTemplateValues {
954                preheader: FieldValues {
955                    cb_id: [(
956                        "shared".to_string(),
957                        CbIdEntry {
958                            value: Some("cb1".into()),
959                        },
960                    )]
961                    .into_iter()
962                    .collect(),
963                    ..Default::default()
964                },
965                ..Default::default()
966            },
967        );
968
969        let r = refresh_email_template_values(&local, &remote, &mut values);
970        assert!(
971            r.ambiguity_warnings
972                .iter()
973                .any(|w| w.contains("preheader") && w.contains("key 'shared'")),
974            "expected preheader missing-token warning, got: {:?}",
975            r.ambiguity_warnings
976        );
977    }
978
979    #[test]
980    fn lid_assignment_uses_local_source_order_not_alphabetical_key_order() {
981        // Keys whose alphabetical order is the OPPOSITE of their source
982        // order, so a key-order assignment would silently swap values.
983        let local = cb(
984            "promo",
985            r#"<a href="https://example.com/cta">__BRAZESYNC.lid.zebra__</a>
986               <a href="https://example.com/cta">__BRAZESYNC.lid.apple__</a>"#,
987        );
988        let remote = cb(
989            "promo",
990            r#"<a href="https://example.com/cta">{{ x | lid: 'firstvalu1a' }}</a>
991               <a href="https://example.com/cta">{{ x | lid: 'secondval2b' }}</a>"#,
992        );
993        let mut values = ValuesFile {
994            version: 1,
995            ..Default::default()
996        };
997        values.content_block.insert(
998            "promo".into(),
999            ContentBlockValues {
1000                lid: [
1001                    (
1002                        "apple".to_string(),
1003                        LidEntry {
1004                            value: Some("oldoldoldold".into()),
1005                            url: Some("https://example.com/cta".into()),
1006                            anchor: None,
1007                        },
1008                    ),
1009                    (
1010                        "zebra".to_string(),
1011                        LidEntry {
1012                            value: Some("oldoldoldolb".into()),
1013                            url: Some("https://example.com/cta".into()),
1014                            anchor: None,
1015                        },
1016                    ),
1017                ]
1018                .into_iter()
1019                .collect(),
1020                ..Default::default()
1021            },
1022        );
1023        let r = refresh_content_block_values(&local, &remote, &mut values);
1024        assert_eq!(r.lid_updates, 2);
1025        let apple = &values.content_block["promo"].lid["apple"];
1026        let zebra = &values.content_block["promo"].lid["zebra"];
1027        assert_eq!(zebra.value.as_deref(), Some("firstvalu1a"));
1028        assert_eq!(apple.value.as_deref(), Some("secondval2b"));
1029    }
1030
1031    #[test]
1032    fn orphan_cb_id_entry_does_not_emit_token_not_found_warning() {
1033        // Needs at least one placeholder somewhere in local so the
1034        // refresh runs at all; the cb_id key itself stays orphan.
1035        let mut values = ValuesFile {
1036            version: 1,
1037            ..Default::default()
1038        };
1039        values.content_block.insert(
1040            "page".into(),
1041            ContentBlockValues {
1042                cb_id: [(
1043                    "stale_block".to_string(),
1044                    CbIdEntry {
1045                        value: Some("cb9".into()),
1046                    },
1047                )]
1048                .into_iter()
1049                .collect(),
1050                ..Default::default()
1051            },
1052        );
1053        let local = cb(
1054            "page",
1055            r#"<a href="https://example.com/x">__BRAZESYNC.lid.cta__</a>"#,
1056        );
1057        let remote = cb(
1058            "page",
1059            r#"<a href="https://example.com/x">{{ x | lid: 'lidvalueab1' }}</a>"#,
1060        );
1061        values.content_block.get_mut("page").unwrap().lid.insert(
1062            "cta".to_string(),
1063            LidEntry {
1064                value: Some("oldlidvalu1".into()),
1065                url: Some("https://example.com/x".into()),
1066                anchor: None,
1067            },
1068        );
1069        let r = refresh_content_block_values(&local, &remote, &mut values);
1070        assert!(
1071            !r.ambiguity_warnings
1072                .iter()
1073                .any(|w| w.contains("stale_block") && w.contains("include resolving")),
1074            "orphan cb_id should not produce a 'token not found' warning, got: {:?}",
1075            r.ambiguity_warnings
1076        );
1077        assert!(
1078            r.orphan_warnings.iter().any(|w| w.contains("stale_block")),
1079            "expected orphan warning for stale_block, got: {:?}",
1080            r.orphan_warnings
1081        );
1082    }
1083
1084    #[test]
1085    fn orphan_lid_entry_value_is_not_mutated_even_when_url_matches_remote() {
1086        let local = cb(
1087            "promo",
1088            r#"<a href="https://example.com/cta">{{ x | lid: '__BRAZESYNC.lid.cta__' }}go</a>"#,
1089        );
1090        let remote = cb(
1091            "promo",
1092            r#"<a href="https://example.com/cta">{{ x | lid: 'newlidvalue1' }}go</a>
1093               <a href="https://example.com/stale">{{ x | lid: 'unwantedval1' }}x</a>"#,
1094        );
1095        let mut values = ValuesFile {
1096            version: 1,
1097            ..Default::default()
1098        };
1099        values.content_block.insert(
1100            "promo".into(),
1101            ContentBlockValues {
1102                lid: [
1103                    (
1104                        "cta".to_string(),
1105                        LidEntry {
1106                            value: Some("oldlidvalue1".into()),
1107                            url: Some("https://example.com/cta".into()),
1108                            anchor: None,
1109                        },
1110                    ),
1111                    (
1112                        "legacy".to_string(),
1113                        LidEntry {
1114                            value: Some("preservedv1".into()),
1115                            url: Some("https://example.com/stale".into()),
1116                            anchor: None,
1117                        },
1118                    ),
1119                ]
1120                .into_iter()
1121                .collect(),
1122                ..Default::default()
1123            },
1124        );
1125        let r = refresh_content_block_values(&local, &remote, &mut values);
1126        assert_eq!(r.lid_updates, 1, "only the referenced entry should update");
1127        assert_eq!(
1128            values.content_block["promo"].lid["cta"].value.as_deref(),
1129            Some("newlidvalue1")
1130        );
1131        assert_eq!(
1132            values.content_block["promo"].lid["legacy"].value.as_deref(),
1133            Some("preservedv1"),
1134            "orphan lid value must be preserved"
1135        );
1136        assert!(
1137            r.orphan_warnings.iter().any(|w| w.contains("legacy")),
1138            "expected orphan warning for legacy, got: {:?}",
1139            r.orphan_warnings
1140        );
1141    }
1142
1143    #[test]
1144    fn orphan_cb_id_entry_value_is_not_mutated_even_when_remote_includes_name() {
1145        let local = cb("page", "<p>__BRAZESYNC.lid.cta__</p>");
1146        let remote = cb(
1147            "page",
1148            r#"<a href="https://example.com/x">{{ x | lid: 'lidvalueab1' }}</a>
1149               {{content_blocks.${stale_block} | id: 'cb42'}}"#,
1150        );
1151        let mut values = ValuesFile {
1152            version: 1,
1153            ..Default::default()
1154        };
1155        values.content_block.insert(
1156            "page".into(),
1157            ContentBlockValues {
1158                lid: [(
1159                    "cta".to_string(),
1160                    LidEntry {
1161                        value: Some("oldlidvalu1".into()),
1162                        url: Some("https://example.com/x".into()),
1163                        anchor: None,
1164                    },
1165                )]
1166                .into_iter()
1167                .collect(),
1168                cb_id: [(
1169                    "stale_block".to_string(),
1170                    CbIdEntry {
1171                        value: Some("cb9".into()),
1172                    },
1173                )]
1174                .into_iter()
1175                .collect(),
1176                ..Default::default()
1177            },
1178        );
1179        let r = refresh_content_block_values(&local, &remote, &mut values);
1180        assert_eq!(
1181            r.cb_id_updates, 0,
1182            "orphan cb_id must not count as an update"
1183        );
1184        assert_eq!(
1185            values.content_block["page"].cb_id["stale_block"]
1186                .value
1187                .as_deref(),
1188            Some("cb9"),
1189            "orphan cb_id value must be preserved"
1190        );
1191        assert!(
1192            r.orphan_warnings.iter().any(|w| w.contains("stale_block")),
1193            "expected orphan warning, got: {:?}",
1194            r.orphan_warnings
1195        );
1196    }
1197
1198    #[test]
1199    fn duplicate_url_orphan_does_not_trigger_positional_warning() {
1200        let local = cb(
1201            "promo",
1202            r#"<a href="https://example.com/cta">{{ x | lid: '__BRAZESYNC.lid.cta__' }}go</a>"#,
1203        );
1204        let remote = cb(
1205            "promo",
1206            r#"<a href="https://example.com/cta">{{ x | lid: 'newlidvalue1' }}go</a>"#,
1207        );
1208        let mut values = ValuesFile {
1209            version: 1,
1210            ..Default::default()
1211        };
1212        values.content_block.insert(
1213            "promo".into(),
1214            ContentBlockValues {
1215                lid: [
1216                    (
1217                        "cta".to_string(),
1218                        LidEntry {
1219                            value: Some("oldlidvalue1".into()),
1220                            url: Some("https://example.com/cta".into()),
1221                            anchor: None,
1222                        },
1223                    ),
1224                    (
1225                        "stale".to_string(),
1226                        LidEntry {
1227                            value: Some("staaaalee1".into()),
1228                            url: Some("https://example.com/cta".into()),
1229                            anchor: None,
1230                        },
1231                    ),
1232                ]
1233                .into_iter()
1234                .collect(),
1235                ..Default::default()
1236            },
1237        );
1238        let r = refresh_content_block_values(&local, &remote, &mut values);
1239        assert!(
1240            !r.ambiguity_warnings
1241                .iter()
1242                .any(|w| w.contains("cta") && w.contains("positional")),
1243            "no positional warning expected, got: {:?}",
1244            r.ambiguity_warnings
1245        );
1246        assert_eq!(r.lid_updates, 1);
1247    }
1248
1249    #[test]
1250    fn merge_combines_reports() {
1251        let mut a = ExportUpdates {
1252            lid_updates: 1,
1253            cb_id_updates: 0,
1254            orphan_warnings: vec!["o1".into()],
1255            missing_entry_warnings: vec!["m1".into()],
1256            ambiguity_warnings: vec![],
1257        };
1258        let b = ExportUpdates {
1259            lid_updates: 2,
1260            cb_id_updates: 1,
1261            orphan_warnings: vec![],
1262            missing_entry_warnings: vec!["m2".into()],
1263            ambiguity_warnings: vec!["a1".into()],
1264        };
1265        a.merge(b);
1266        assert_eq!(a.lid_updates, 3);
1267        assert_eq!(a.cb_id_updates, 1);
1268        assert_eq!(a.orphan_warnings, vec!["o1".to_string()]);
1269        assert_eq!(
1270            a.missing_entry_warnings,
1271            vec!["m1".to_string(), "m2".to_string()]
1272        );
1273        assert_eq!(a.ambiguity_warnings, vec!["a1".to_string()]);
1274    }
1275}