1use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use crate::config::ResolvedConfig;
16use crate::error::{Error, Result};
17use crate::resource::{ContentBlock, EmailTemplate};
18use crate::values::placeholder::{
19 find_suspicious_placeholders, resolve_placeholders, LookupKey, PlaceholderType, ResolutionError,
20};
21use crate::values::schema::{default_values_path, ValuesFile};
22
23pub fn values_file_path(config_dir: &Path, resolved: &ResolvedConfig) -> PathBuf {
26 if let Some(custom) = &resolved.values_file {
27 if custom.is_absolute() {
28 custom.clone()
29 } else {
30 config_dir.join(custom)
31 }
32 } else {
33 default_values_path(config_dir, &resolved.environment_name)
34 }
35}
36
37pub fn load_values_for_env(
47 config_dir: &Path,
48 resolved: &ResolvedConfig,
49) -> Result<Option<ValuesFile>> {
50 let path = values_file_path(config_dir, resolved);
51 if !path.exists() {
52 return Ok(None);
53 }
54 ValuesFile::load(&path).map(Some)
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ResolutionFailure {
61 pub resource_kind: &'static str,
62 pub resource_name: String,
63 pub field: Option<&'static str>,
65 pub errors: Vec<ResolutionError>,
66}
67
68pub fn resolve_content_block_in_place(
72 cb: &mut ContentBlock,
73 values: Option<&ValuesFile>,
74) -> std::result::Result<(), ResolutionFailure> {
75 if !body_has_placeholders(&cb.content) {
76 return Ok(());
77 }
78 let lookup = build_content_block_lookup(&cb.name, values);
79 match resolve_placeholders(&cb.content, &lookup) {
80 Ok(resolved) => {
81 cb.content = resolved;
82 Ok(())
83 }
84 Err(errors) => Err(ResolutionFailure {
85 resource_kind: "content_block",
86 resource_name: cb.name.clone(),
87 field: None,
88 errors,
89 }),
90 }
91}
92
93pub fn resolve_email_template_in_place(
98 et: &mut EmailTemplate,
99 values: Option<&ValuesFile>,
100) -> std::result::Result<(), Vec<ResolutionFailure>> {
101 let mut failures: Vec<ResolutionFailure> = Vec::new();
104
105 macro_rules! resolve_field {
106 ($field_name:expr, $accessor:expr) => {{
107 let body: &str = $accessor;
108 if body_has_placeholders(body) {
109 let lookup = build_email_template_lookup(&et.name, $field_name, values);
110 match resolve_placeholders(body, &lookup) {
111 Ok(resolved) => Some(resolved),
112 Err(errors) => {
113 failures.push(ResolutionFailure {
114 resource_kind: "email_template",
115 resource_name: et.name.clone(),
116 field: Some($field_name),
117 errors,
118 });
119 None
120 }
121 }
122 } else {
123 None
124 }
125 }};
126 }
127
128 let new_subject = resolve_field!("subject", et.subject.as_str());
129 let new_body_html = resolve_field!("body_html", et.body_html.as_str());
130 let new_body_plaintext = resolve_field!("body_plaintext", et.body_plaintext.as_str());
131 let new_preheader = match et.preheader.as_deref() {
132 Some(s) => resolve_field!("preheader", s),
133 None => None,
134 };
135
136 if !failures.is_empty() {
137 return Err(failures);
138 }
139
140 if let Some(v) = new_subject {
141 et.subject = v;
142 }
143 if let Some(v) = new_body_html {
144 et.body_html = v;
145 }
146 if let Some(v) = new_body_plaintext {
147 et.body_plaintext = v;
148 }
149 if let Some(v) = new_preheader {
150 et.preheader = Some(v);
151 }
152 Ok(())
153}
154
155fn body_has_placeholders(body: &str) -> bool {
159 body.contains("__BRAZESYNC.")
160}
161
162fn build_content_block_lookup(
166 name: &str,
167 values: Option<&ValuesFile>,
168) -> BTreeMap<LookupKey, String> {
169 let mut out = BTreeMap::new();
170 let Some(vf) = values else {
171 return out;
172 };
173 insert_globals(&mut out, vf);
174 if let Some(cb) = vf.content_block.get(name) {
175 for (k, e) in &cb.lid {
176 if let Some(v) = &e.value {
177 out.insert((PlaceholderType::Lid, k.clone()), v.clone());
178 }
179 }
180 for (k, e) in &cb.cb_id {
181 if let Some(v) = &e.value {
182 out.insert((PlaceholderType::CbId, k.clone()), v.clone());
183 }
184 }
185 for (k, e) in &cb.custom {
186 if let Some(v) = &e.value {
187 out.insert((PlaceholderType::Custom, k.clone()), v.clone());
188 }
189 }
190 }
191 out
192}
193
194fn build_email_template_lookup(
198 name: &str,
199 field: &str,
200 values: Option<&ValuesFile>,
201) -> BTreeMap<LookupKey, String> {
202 let mut out = BTreeMap::new();
203 let Some(vf) = values else {
204 return out;
205 };
206 insert_globals(&mut out, vf);
207 let Some(et) = vf.email_template.get(name) else {
208 return out;
209 };
210 for (k, e) in &et.custom {
211 if let Some(v) = &e.value {
212 out.insert((PlaceholderType::Custom, k.clone()), v.clone());
213 }
214 }
215 let field_values = match field {
216 "subject" => &et.subject,
217 "preheader" => &et.preheader,
218 "body_html" => &et.body_html,
219 "body_plaintext" => &et.body_plaintext,
220 _ => return out,
222 };
223 for (k, e) in &field_values.lid {
224 if let Some(v) = &e.value {
225 out.insert((PlaceholderType::Lid, k.clone()), v.clone());
226 }
227 }
228 for (k, e) in &field_values.cb_id {
229 if let Some(v) = &e.value {
230 out.insert((PlaceholderType::CbId, k.clone()), v.clone());
231 }
232 }
233 out
234}
235
236fn warn_suspicious(kind: &str, name: &str, field: Option<&str>, suspicious: Vec<String>) {
242 if suspicious.is_empty() {
243 return;
244 }
245 let scope = match field {
246 Some(f) => format!("{kind} '{name}' ({f})"),
247 None => format!("{kind} '{name}'"),
248 };
249 for s in suspicious {
250 eprintln!(
251 "WARN: {scope}: suspicious placeholder `{s}` — strict form is \
252 __BRAZESYNC.<lid|cb_id|custom|global>.<key>__"
253 );
254 }
255}
256
257fn insert_globals(out: &mut BTreeMap<LookupKey, String>, vf: &ValuesFile) {
258 for (k, e) in &vf.globals.custom {
259 if let Some(v) = &e.value {
260 out.insert((PlaceholderType::Global, k.clone()), v.clone());
261 }
262 }
263}
264
265pub struct PreflightArgs<'a> {
270 pub config_dir: &'a Path,
271 pub resolved: &'a ResolvedConfig,
272 pub content_blocks_root: &'a Path,
273 pub email_templates_root: &'a Path,
274 pub kinds: &'a [crate::resource::ResourceKind],
275 pub cb_name_filter: Option<&'a str>,
276 pub et_name_filter: Option<&'a str>,
277 pub cb_excludes: &'a [regex_lite::Regex],
278 pub et_excludes: &'a [regex_lite::Regex],
279}
280
281pub fn preflight_values(args: PreflightArgs<'_>) -> Result<Option<ValuesFile>> {
290 use crate::resource::ResourceKind;
291
292 let has_cb = args.kinds.contains(&ResourceKind::ContentBlock);
297 let has_et = args.kinds.contains(&ResourceKind::EmailTemplate);
298 if !has_cb && !has_et {
299 return Ok(None);
300 }
301
302 let values_path = values_file_path(args.config_dir, args.resolved);
303 let values = load_values_for_env(args.config_dir, args.resolved)?;
304 let values_loaded = values.is_some();
305
306 let mut failures: Vec<ResolutionFailure> = Vec::new();
307
308 if has_cb && args.content_blocks_root.exists() {
309 let mut locals =
310 crate::fs::content_block_io::load_all_content_blocks(args.content_blocks_root)
311 .map_err(|e| Error::Config(format!("loading content_block locals: {e}")))?;
312 if let Some(name) = args.cb_name_filter {
313 locals.retain(|c| c.name == name);
314 }
315 locals.retain(|c| !crate::config::is_excluded(&c.name, args.cb_excludes));
316 for mut cb in locals {
317 warn_suspicious(
318 "content_block",
319 &cb.name,
320 None,
321 find_suspicious_placeholders(&cb.content),
322 );
323 if let Err(f) = resolve_content_block_in_place(&mut cb, values.as_ref()) {
324 failures.push(f);
325 }
326 }
327 }
328
329 if has_et && args.email_templates_root.exists() {
330 let mut locals =
331 crate::fs::email_template_io::load_all_email_templates(args.email_templates_root)
332 .map_err(|e| Error::Config(format!("loading email_template locals: {e}")))?;
333 if let Some(name) = args.et_name_filter {
334 locals.retain(|t| t.name == name);
335 }
336 locals.retain(|t| !crate::config::is_excluded(&t.name, args.et_excludes));
337 for mut t in locals {
338 for (field, body) in [
339 ("subject", t.subject.as_str()),
340 ("body_html", t.body_html.as_str()),
341 ("body_plaintext", t.body_plaintext.as_str()),
342 ("preheader", t.preheader.as_deref().unwrap_or("")),
343 ] {
344 warn_suspicious(
345 "email_template",
346 &t.name,
347 Some(field),
348 find_suspicious_placeholders(body),
349 );
350 }
351 if let Err(per_field_failures) =
352 resolve_email_template_in_place(&mut t, values.as_ref())
353 {
354 failures.extend(per_field_failures);
355 }
356 }
357 }
358
359 if !failures.is_empty() {
360 return Err(format_failures(&failures, &values_path, values_loaded));
361 }
362
363 Ok(values)
364}
365
366pub fn compute_values_input_hashes(
386 args: PreflightArgs<'_>,
387 values: Option<&ValuesFile>,
388) -> Result<BTreeMap<String, String>> {
389 use crate::resource::ResourceKind;
390
391 let has_cb = args.kinds.contains(&ResourceKind::ContentBlock);
392 let has_et = args.kinds.contains(&ResourceKind::EmailTemplate);
393 if !has_cb && !has_et {
394 return Ok(BTreeMap::new());
395 }
396
397 let mut hashes: BTreeMap<String, String> = BTreeMap::new();
398
399 if has_cb && args.content_blocks_root.exists() {
400 let mut locals =
401 crate::fs::content_block_io::load_all_content_blocks(args.content_blocks_root)
402 .map_err(|e| Error::Config(format!("loading content_block locals: {e}")))?;
403 if let Some(name) = args.cb_name_filter {
404 locals.retain(|c| c.name == name);
405 }
406 locals.retain(|c| !crate::config::is_excluded(&c.name, args.cb_excludes));
407 for cb in locals {
408 if !body_has_placeholders(&cb.content) {
409 continue;
410 }
411 let consumed = consumed_for_content_block(&cb, values);
412 let key = format!("content_block/{}", cb.name);
413 hashes.insert(key, hash_consumed_map(&consumed));
414 }
415 }
416
417 if has_et && args.email_templates_root.exists() {
418 let mut locals =
419 crate::fs::email_template_io::load_all_email_templates(args.email_templates_root)
420 .map_err(|e| Error::Config(format!("loading email_template locals: {e}")))?;
421 if let Some(name) = args.et_name_filter {
422 locals.retain(|t| t.name == name);
423 }
424 locals.retain(|t| !crate::config::is_excluded(&t.name, args.et_excludes));
425 for et in locals {
426 let any_ph = body_has_placeholders(&et.subject)
427 || body_has_placeholders(&et.body_html)
428 || body_has_placeholders(&et.body_plaintext)
429 || et.preheader.as_deref().is_some_and(body_has_placeholders);
430 if !any_ph {
431 continue;
432 }
433 let consumed = consumed_for_email_template(&et, values);
434 let key = format!("email_template/{}", et.name);
435 hashes.insert(key, hash_consumed_map(&consumed));
436 }
437 }
438
439 Ok(hashes)
440}
441
442fn consumed_for_content_block(
443 cb: &crate::resource::ContentBlock,
444 values: Option<&ValuesFile>,
445) -> BTreeMap<String, String> {
446 let lookup = build_content_block_lookup(&cb.name, values);
447 let mut consumed: BTreeMap<String, String> = BTreeMap::new();
448 for ph in crate::values::placeholder::extract_placeholders(&cb.content) {
449 let lk = (ph.ty, ph.key.clone());
450 if let Some(v) = lookup.get(&lk) {
451 consumed.insert(format!("{}.{}", ph.ty.as_str(), ph.key), v.clone());
452 }
453 }
454 consumed
455}
456
457fn consumed_for_email_template(
458 et: &crate::resource::EmailTemplate,
459 values: Option<&ValuesFile>,
460) -> BTreeMap<String, String> {
461 let mut consumed: BTreeMap<String, String> = BTreeMap::new();
467 for (field_name, body) in [
468 ("subject", et.subject.as_str()),
469 ("body_html", et.body_html.as_str()),
470 ("body_plaintext", et.body_plaintext.as_str()),
471 ("preheader", et.preheader.as_deref().unwrap_or("")),
472 ] {
473 if !body_has_placeholders(body) {
474 continue;
475 }
476 let lookup = build_email_template_lookup(&et.name, field_name, values);
477 for ph in crate::values::placeholder::extract_placeholders(body) {
478 let lk = (ph.ty, ph.key.clone());
479 if let Some(v) = lookup.get(&lk) {
480 consumed.insert(
481 format!("{field_name}.{}.{}", ph.ty.as_str(), ph.key),
482 v.clone(),
483 );
484 }
485 }
486 }
487 consumed
488}
489
490fn hash_consumed_map(consumed: &BTreeMap<String, String>) -> String {
491 let bytes =
495 serde_json::to_vec(consumed).expect("BTreeMap<String, String> serialization is infallible");
496 blake3::hash(&bytes).to_hex().to_string()
497}
498
499pub fn format_failures(
503 failures: &[ResolutionFailure],
504 values_path: &Path,
505 values_loaded: bool,
506) -> Error {
507 let mut msg = String::new();
508 msg.push_str(&format!(
509 "Cannot continue: {} placeholder resolution failure(s)\n",
510 failures.iter().map(|f| f.errors.len()).sum::<usize>(),
511 ));
512 for f in failures {
513 let scope = match f.field {
514 Some(field) => format!(" {} '{}' ({}):", f.resource_kind, f.resource_name, field),
515 None => format!(" {} '{}':", f.resource_kind, f.resource_name),
516 };
517 msg.push_str(&scope);
518 msg.push('\n');
519 for e in &f.errors {
520 match e {
521 ResolutionError::UnknownKey { ty, key, start } => {
522 msg.push_str(&format!(
523 " - offset {}: __BRAZESYNC.{}.{}__ (key not in values)\n",
524 start,
525 ty.as_str(),
526 key,
527 ));
528 }
529 ResolutionError::DuplicateLidKey { key, occurrences } => {
530 let offsets = occurrences
531 .iter()
532 .map(|o| o.to_string())
533 .collect::<Vec<_>>()
534 .join(", ");
535 msg.push_str(&format!(
536 " - __BRAZESYNC.lid.{key}__ referenced {} times (offsets {offsets}); \
537 lid IDs are per-click-context — use a distinct key per occurrence\n",
538 occurrences.len(),
539 ));
540 }
541 }
542 }
543 }
544 if values_loaded {
545 msg.push_str(&format!(
546 "\nResolve by adding the missing keys to {} or running `braze-sync export --env=<env>`.",
547 values_path.display(),
548 ));
549 } else {
550 msg.push_str(&format!(
551 "\nNo values file was loaded at {}. Create it (or set environments.<env>.values_file in your config), \
552 then add the missing keys or run `braze-sync export --env=<env>` to populate them.",
553 values_path.display(),
554 ));
555 }
556 Error::Config(msg)
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::resource::content_block::ContentBlockState;
563
564 fn cb(name: &str, content: &str) -> ContentBlock {
565 ContentBlock {
566 name: name.into(),
567 description: None,
568 content: content.into(),
569 tags: Vec::new(),
570 state: ContentBlockState::Active,
571 }
572 }
573
574 fn et(name: &str) -> EmailTemplate {
575 EmailTemplate {
576 name: name.into(),
577 subject: String::new(),
578 body_html: String::new(),
579 body_plaintext: String::new(),
580 description: None,
581 preheader: None,
582 should_inline_css: None,
583 tags: Vec::new(),
584 }
585 }
586
587 fn values_yaml(s: &str) -> ValuesFile {
588 serde_norway::from_str(s).expect("test yaml parses")
589 }
590
591 #[test]
592 fn no_placeholders_skips_resolution_even_without_values() {
593 let mut block = cb("plain", "<p>hi there</p>");
594 resolve_content_block_in_place(&mut block, None).unwrap();
595 assert_eq!(block.content, "<p>hi there</p>");
596 }
597
598 #[test]
599 fn resolves_content_block_lid_custom_global() {
600 let v = values_yaml(
601 r#"
602version: 1
603globals:
604 custom:
605 host:
606 value: api-prod.example.com
607content_block:
608 promo:
609 lid:
610 cta:
611 value: ai8kexrxcp03
612 url: https://example.com/cta
613 custom:
614 variant:
615 value: A
616"#,
617 );
618 let mut block = cb(
619 "promo",
620 "host=__BRAZESYNC.global.host__ variant=__BRAZESYNC.custom.variant__ \
621 lid=__BRAZESYNC.lid.cta__",
622 );
623 resolve_content_block_in_place(&mut block, Some(&v)).unwrap();
624 assert_eq!(
625 block.content,
626 "host=api-prod.example.com variant=A lid=ai8kexrxcp03"
627 );
628 }
629
630 #[test]
631 fn missing_values_file_aggregates_failures() {
632 let mut block = cb("promo", "__BRAZESYNC.lid.cta__");
633 let err = resolve_content_block_in_place(&mut block, None).unwrap_err();
634 assert_eq!(err.resource_kind, "content_block");
635 assert_eq!(err.resource_name, "promo");
636 assert_eq!(err.errors.len(), 1);
637 }
638
639 #[test]
640 fn email_template_field_scoped_lid_namespaces() {
641 let v = values_yaml(
642 r#"
643version: 1
644email_template:
645 welcome:
646 custom:
647 seg:
648 value: seg_prod
649 subject:
650 lid:
651 s_lid:
652 value: lidsubject01
653 anchor: "{{promo}}"
654 body_html:
655 lid:
656 h_lid:
657 value: lidhtml01001
658 url: https://example.com/cta
659"#,
660 );
661 let mut t = et("welcome");
662 t.subject = "x=__BRAZESYNC.lid.s_lid__ seg=__BRAZESYNC.custom.seg__".into();
663 t.body_html = "<a>__BRAZESYNC.lid.h_lid__</a>".into();
664 resolve_email_template_in_place(&mut t, Some(&v)).unwrap();
665 assert_eq!(t.subject, "x=lidsubject01 seg=seg_prod");
666 assert_eq!(t.body_html, "<a>lidhtml01001</a>");
667 }
668
669 #[test]
670 fn email_template_lid_in_wrong_field_fails() {
671 let v = values_yaml(
675 r#"
676version: 1
677email_template:
678 welcome:
679 subject:
680 lid:
681 s_lid:
682 value: lidsubject01
683 anchor: "{{promo}}"
684"#,
685 );
686 let mut t = et("welcome");
687 t.body_html = "__BRAZESYNC.lid.s_lid__".into();
688 let err = resolve_email_template_in_place(&mut t, Some(&v)).unwrap_err();
689 assert_eq!(err.len(), 1);
690 assert_eq!(err[0].field, Some("body_html"));
691 }
692
693 #[test]
694 fn email_template_aggregates_failures_across_fields() {
695 let mut t = et("welcome");
696 t.subject = "__BRAZESYNC.lid.x__".into();
697 t.body_html = "__BRAZESYNC.lid.y__".into();
698 let err = resolve_email_template_in_place(&mut t, None).unwrap_err();
699 assert_eq!(err.len(), 2);
700 let fields: Vec<_> = err.iter().filter_map(|f| f.field).collect();
701 assert!(fields.contains(&"subject"));
702 assert!(fields.contains(&"body_html"));
703 }
704
705 #[test]
706 fn format_failures_mentions_values_path_when_missing() {
707 let failures = vec![ResolutionFailure {
708 resource_kind: "content_block",
709 resource_name: "promo".into(),
710 field: None,
711 errors: vec![ResolutionError::UnknownKey {
712 ty: PlaceholderType::Lid,
713 key: "cta".into(),
714 start: 0,
715 }],
716 }];
717 let err = format_failures(&failures, Path::new("/x/values/prod.yaml"), false);
718 let msg = err.to_string();
719 assert!(msg.contains("content_block 'promo'"));
720 assert!(msg.contains("__BRAZESYNC.lid.cta__"));
721 assert!(msg.contains("No values file was loaded"));
722 }
723
724 #[test]
725 fn format_failures_omits_missing_hint_when_loaded() {
726 let failures = vec![ResolutionFailure {
727 resource_kind: "content_block",
728 resource_name: "promo".into(),
729 field: None,
730 errors: vec![ResolutionError::UnknownKey {
731 ty: PlaceholderType::Lid,
732 key: "cta".into(),
733 start: 0,
734 }],
735 }];
736 let err = format_failures(&failures, Path::new("/x/values/prod.yaml"), true);
737 let msg = err.to_string();
738 assert!(msg.contains("Resolve by adding"));
739 assert!(!msg.contains("No values file was loaded"));
740 }
741}