1use 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#[derive(Debug, Default, Clone)]
26pub struct ExportUpdates {
27 pub lid_updates: usize,
28 pub cb_id_updates: usize,
29 pub orphan_warnings: Vec<String>,
32 pub missing_entry_warnings: Vec<String>,
37 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
53pub 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
102pub 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 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 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 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 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 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 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 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 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
449fn 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 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 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 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 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 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 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 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 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}