Skip to main content

braze_sync/values/
integration.rs

1//! Wiring layer between [`crate::values`] (Phase 1) and the diff /
2//! apply pipeline.
3//!
4//! Responsibilities:
5//! - Resolve the per-env values file path from config (`values_file`
6//!   override or default `values/<env>.yaml`).
7//! - Build the per-resource flat lookup table from the structured
8//!   [`ValuesFile`] per RFC §2.2 namespace rules.
9//! - Aggregate placeholder failures across resources so the caller can
10//!   abort with a single grouped report (RFC §2.4 / §3 Q7).
11
12use 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
23/// Where to look for the per-env values file. `values_file` config
24/// override wins; otherwise default `values/<env>.yaml`.
25pub 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
37/// Load the per-env values file, tolerating absence.
38///
39/// Missing file → `Ok(None)`. Unresolved placeholders later (against an
40/// empty fallback) will surface as `ResolutionFailure`s with a hint to
41/// create the file (see [`format_failures`]).
42///
43/// Present-but-malformed → propagates `Error::YamlParse` /
44/// `Error::InvalidFormat` (RFC §5 Edge cases — abort early, before any
45/// API call).
46pub 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/// One resource's worth of placeholder failures, ready to be folded into
58/// a top-level error message.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ResolutionFailure {
61    pub resource_kind: &'static str,
62    pub resource_name: String,
63    /// `Some(field)` for email_template fields, `None` for content_block.
64    pub field: Option<&'static str>,
65    pub errors: Vec<ResolutionError>,
66}
67
68/// Resolve every `__BRAZESYNC.*__` in `cb.content` against `values`.
69/// content_block bodies are single-field, so all `lid` / `cb_id` /
70/// `custom` namespaces live at the resource root (RFC §2.2).
71pub 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
93/// Resolve placeholders across every Liquid-bearing field of `et`. Each
94/// field has its own per-field `lid` / `cb_id` namespace; `custom` is
95/// resource-scoped (RFC §2.2). Failures are aggregated *per field* so a
96/// single email_template can surface multiple `ResolutionFailure`s.
97pub fn resolve_email_template_in_place(
98    et: &mut EmailTemplate,
99    values: Option<&ValuesFile>,
100) -> std::result::Result<(), Vec<ResolutionFailure>> {
101    // Snapshot field refs so the borrow of `et` doesn't conflict with
102    // the per-field mut writes below. The macro keeps it readable.
103    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
155/// Quick filter to avoid building a lookup map for bodies that have no
156/// placeholders. The strict extractor would also be empty in that case
157/// but a single substring scan is cheaper than parsing tokens.
158fn body_has_placeholders(body: &str) -> bool {
159    body.contains("__BRAZESYNC.")
160}
161
162/// Build the `(type, key) -> value` lookup for one content_block. Pulls
163/// from `globals.custom` for `global`, and from `content_block.<name>.*`
164/// for the other three namespaces.
165fn 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
194/// Build the lookup for one email_template field. `custom` is taken
195/// from the resource root (shared across fields); `lid` / `cb_id` from
196/// the field-specific namespace (RFC §2.2).
197fn 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        // Other fields can't contain placeholders by construction.
221        _ => 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
236/// RFC §2.3: surface envelope-shaped tokens that don't match the strict
237/// placeholder grammar as warnings, so typos like `__BRAZSYNC.lid.foo__`
238/// (missing E) or unknown types like `__BRAZESYNC.url.foo__` don't pass
239/// through silently. These wouldn't trigger the unresolved-key abort
240/// because the strict extractor returns nothing for them.
241fn 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
265/// Bundle of inputs the pre-flight needs from the entry layer. Kept as
266/// a struct (rather than 10 positional args) to keep clippy happy and
267/// to make future additions (e.g. a new kind that grows placeholders)
268/// non-breaking at call sites.
269pub 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
281/// Walk every selected kind's local files and validate that all
282/// `__BRAZESYNC.*__` placeholders resolve against the loaded values
283/// file. Returns the loaded `ValuesFile` (or `None` if absent and no
284/// placeholders are used) on success; on any failure across any kind,
285/// aborts with an aggregated [`format_failures`] error.
286///
287/// This is the "pre-flight" gate the apply / diff entry points call
288/// before any Braze API request (RFC §2.4 / §3 Q7).
289pub fn preflight_values(args: PreflightArgs<'_>) -> Result<Option<ValuesFile>> {
290    use crate::resource::ResourceKind;
291
292    // Skip values-file IO entirely when no placeholder-bearing kind is
293    // selected. Otherwise a malformed `values/<env>.yaml` would abort
294    // unrelated commands like `apply --resource tag` even though tag /
295    // catalog_schema / custom_attribute never consume the values file.
296    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
366/// Compute per-resource "consumed values" hashes for plan-lock
367/// integrity checking (RFC §4 Phase 6).
368///
369/// For each placeholder-bearing local resource we extract the
370/// `(type, key)` pairs from the templated body, look up each in the
371/// values file using the same namespace rules as
372/// `resolve_*_in_place`, build a stable `BTreeMap<String, String>`
373/// of consumed entries, serialize via `serde_json`, and blake3-hash
374/// the resulting bytes.
375///
376/// Returned map keys are `"<kind>/<name>"` so the plan file's JSON
377/// representation has stable, grep-able keys; values are blake3 hex
378/// digests (64 chars).
379///
380/// blake3 vs the RFC's "SHA-256": both are 32-byte cryptographic
381/// digests, both deterministic, both already collision-resistant for
382/// this scale. blake3 is already a transitive dep (used by catalog
383/// items diffing), so we keep the dep surface minimal and document
384/// the choice here.
385pub 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    // Field-prefix the key so two fields that reference the same
462    // (type, key) but resolve to different per-field values don't
463    // collapse to a single BTreeMap entry. The hash must distinguish
464    // them — apply later resolves field-scoped, so plan and apply
465    // need the same view.
466    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    // BTreeMap iteration is sorted, so serde_json::to_vec produces a
492    // stable byte stream. We accept the unwrap because BTreeMap of
493    // primitives can't fail serialization.
494    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
499/// Format aggregated failures into a single human-readable error.
500/// Adds the "values file missing" hint when `values_loaded == false` so
501/// the operator immediately knows where to look.
502pub 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        // subject.lid.s_lid is declared but body_html references the
672        // same key — should fail because field-scoped namespace
673        // doesn't cross.
674        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}