Skip to main content

braze_sync/values/
schema.rs

1//! YAML schema types for `values/<env>.yaml` per RFC §2.2.
2//!
3//! Phase 1 scope: deserialize the file and validate built-in shapes
4//! (lid: `[a-z0-9]{8,}`, cb_id: `cb[0-9]+`). Resolution wiring per
5//! resource/field comes in Phase 2 / Phase 3.
6//!
7//! Forward-compat policy: `#[serde(deny_unknown_fields)]` is intentionally
8//! NOT applied here — values files are user-edited and the RFC permits
9//! omitting empty namespaces (e.g. `preheader: {}`). The strict shape
10//! check happens via `validate()` after parsing.
11
12use regex_lite::Regex;
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::OnceLock;
17
18use crate::error::{Error, Result};
19
20/// Currently supported values file schema version (RFC §5 Edge cases).
21pub const SUPPORTED_VERSION: u32 = 1;
22
23/// Top-level `values/<env>.yaml` document.
24#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25pub struct ValuesFile {
26    pub version: u32,
27    #[serde(default, skip_serializing_if = "Globals::is_empty")]
28    pub globals: Globals,
29    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
30    pub content_block: BTreeMap<String, ContentBlockValues>,
31    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
32    pub email_template: BTreeMap<String, EmailTemplateValues>,
33}
34
35/// Cross-resource per-env values. Currently only `custom` is populated;
36/// future extension may add `globals.lid` / `globals.cb_id` (RFC §2.2).
37#[derive(Debug, Clone, Default, Deserialize, Serialize)]
38pub struct Globals {
39    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
40    pub custom: BTreeMap<String, CustomEntry>,
41}
42
43impl Globals {
44    fn is_empty(&self) -> bool {
45        self.custom.is_empty()
46    }
47}
48
49/// Resource-scoped values for a content_block. content_block bodies are
50/// single-body so `lid` / `cb_id` / `custom` live directly under the
51/// resource (RFC §2.2).
52#[derive(Debug, Clone, Default, Deserialize, Serialize)]
53pub struct ContentBlockValues {
54    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
55    pub lid: BTreeMap<String, LidEntry>,
56    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub cb_id: BTreeMap<String, CbIdEntry>,
58    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59    pub custom: BTreeMap<String, CustomEntry>,
60}
61
62/// Resource-scoped values for an email_template. `lid` / `cb_id` are
63/// field-scoped because lid is a per-occurrence ID tied to in-field
64/// position. `custom` lives at the resource root only (RFC §2.2).
65#[derive(Debug, Clone, Default, Deserialize, Serialize)]
66pub struct EmailTemplateValues {
67    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
68    pub custom: BTreeMap<String, CustomEntry>,
69    #[serde(default, skip_serializing_if = "FieldValues::is_empty")]
70    pub subject: FieldValues,
71    #[serde(default, skip_serializing_if = "FieldValues::is_empty")]
72    pub preheader: FieldValues,
73    #[serde(default, skip_serializing_if = "FieldValues::is_empty")]
74    pub body_html: FieldValues,
75    #[serde(default, skip_serializing_if = "FieldValues::is_empty")]
76    pub body_plaintext: FieldValues,
77}
78
79/// lid / cb_id namespaces for one email_template field.
80#[derive(Debug, Clone, Default, Deserialize, Serialize)]
81pub struct FieldValues {
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub lid: BTreeMap<String, LidEntry>,
84    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
85    pub cb_id: BTreeMap<String, CbIdEntry>,
86}
87
88impl FieldValues {
89    fn is_empty(&self) -> bool {
90        self.lid.is_empty() && self.cb_id.is_empty()
91    }
92}
93
94/// A lid value with its correlation anchor. `url` is set for fields that
95/// have a hyperlink context (HTML / plaintext bodies); `anchor` is set
96/// for URL-less fields (subject / preheader). Either may be absent for
97/// skeletons generated by `templatize` (RFC §2.7).
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct LidEntry {
100    /// `null` is allowed for skeletons (RFC §2.7) and is preserved on
101    /// save so the skeleton marker survives the round trip.
102    pub value: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub url: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub anchor: Option<String>,
107}
108
109/// A cb_id value. Key = referenced content_block name slug (RFC §3 Q3).
110#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct CbIdEntry {
112    pub value: Option<String>,
113}
114
115/// User-managed custom value; opaque string. No shape validation per
116/// RFC §9 Q3 (custom is left to user).
117#[derive(Debug, Clone, Deserialize, Serialize)]
118pub struct CustomEntry {
119    pub value: Option<String>,
120}
121
122fn lid_re() -> &'static Regex {
123    static RE: OnceLock<Regex> = OnceLock::new();
124    RE.get_or_init(|| Regex::new(r"^[a-z0-9]{8,}$").expect("lid regex is valid"))
125}
126
127fn cb_id_re() -> &'static Regex {
128    static RE: OnceLock<Regex> = OnceLock::new();
129    RE.get_or_init(|| Regex::new(r"^cb[0-9]+$").expect("cb_id regex is valid"))
130}
131
132fn key_re() -> &'static Regex {
133    static RE: OnceLock<Regex> = OnceLock::new();
134    RE.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("key regex is valid"))
135}
136
137impl ValuesFile {
138    /// Load and validate a values file from disk.
139    ///
140    /// Errors:
141    /// - I/O failure → `Error::Io`
142    /// - YAML parse failure → `Error::YamlParse`
143    /// - Unsupported `version` or built-in shape violation →
144    ///   `Error::InvalidFormat` (RFC §5 Edge cases)
145    pub fn load(path: &Path) -> Result<Self> {
146        let raw = std::fs::read_to_string(path)?;
147        let parsed: ValuesFile =
148            serde_norway::from_str(&raw).map_err(|source| Error::YamlParse {
149                path: path.to_path_buf(),
150                source,
151            })?;
152        parsed.validate(path)?;
153        Ok(parsed)
154    }
155
156    /// Serialize and atomically write to `path` (tmp + `rename(2)`).
157    /// Used by `export` (Phase 3) to write back updated values entries.
158    ///
159    /// Note: this round-trips through `serde_norway` and therefore does
160    /// NOT preserve user comments or quoting style. Phase 3 accepts this
161    /// trade-off; a comment-preserving editor is out of scope for v0.14.
162    pub fn save(&self, path: &Path) -> Result<()> {
163        self.validate(path)?;
164        let yaml = serde_norway::to_string(self).map_err(|e| Error::InvalidFormat {
165            path: path.to_path_buf(),
166            message: format!("serializing values file: {e}"),
167        })?;
168        crate::fs::write_atomic(path, yaml.as_bytes())
169    }
170
171    /// Validate version + built-in value shapes. Skeleton entries
172    /// (`value: null`) are skipped (RFC §2.7 / §5).
173    pub fn validate(&self, path: &Path) -> Result<()> {
174        if self.version != SUPPORTED_VERSION {
175            return Err(Error::InvalidFormat {
176                path: path.to_path_buf(),
177                message: format!(
178                    "values file requires schema version {} (found: {})",
179                    SUPPORTED_VERSION, self.version
180                ),
181            });
182        }
183
184        let mut errors: Vec<String> = Vec::new();
185
186        // globals.custom — only key shape; values are opaque.
187        for key in self.globals.custom.keys() {
188            check_key(key, "globals.custom", &mut errors);
189        }
190
191        for (cb_name, cb) in &self.content_block {
192            let scope = format!("content_block.{}", cb_name);
193            check_lid_map(&cb.lid, &scope, &mut errors);
194            check_cb_id_map(&cb.cb_id, &scope, &mut errors);
195            for key in cb.custom.keys() {
196                check_key(key, &format!("{scope}.custom"), &mut errors);
197            }
198        }
199
200        for (et_name, et) in &self.email_template {
201            let root = format!("email_template.{}", et_name);
202            for key in et.custom.keys() {
203                check_key(key, &format!("{root}.custom"), &mut errors);
204            }
205            for (field_name, field) in [
206                ("subject", &et.subject),
207                ("preheader", &et.preheader),
208                ("body_html", &et.body_html),
209                ("body_plaintext", &et.body_plaintext),
210            ] {
211                let field_scope = format!("{root}.{field_name}");
212                check_lid_map(&field.lid, &field_scope, &mut errors);
213                check_cb_id_map(&field.cb_id, &field_scope, &mut errors);
214            }
215        }
216
217        if errors.is_empty() {
218            Ok(())
219        } else {
220            Err(Error::InvalidFormat {
221                path: path.to_path_buf(),
222                message: errors.join("; "),
223            })
224        }
225    }
226}
227
228fn check_key(key: &str, scope: &str, errors: &mut Vec<String>) {
229    if !key_re().is_match(key) {
230        errors.push(format!("{scope}: key '{key}' must match [a-z][a-z0-9_]*"));
231    }
232}
233
234fn check_lid_map(map: &BTreeMap<String, LidEntry>, scope: &str, errors: &mut Vec<String>) {
235    for (key, entry) in map {
236        check_key(key, &format!("{scope}.lid"), errors);
237        if let Some(value) = &entry.value {
238            if !lid_re().is_match(value) {
239                errors.push(format!(
240                    "{scope}.lid.{key}: value '{value}' must match ^[a-z0-9]{{8,}}$"
241                ));
242            }
243        }
244    }
245}
246
247fn check_cb_id_map(map: &BTreeMap<String, CbIdEntry>, scope: &str, errors: &mut Vec<String>) {
248    for (key, entry) in map {
249        check_key(key, &format!("{scope}.cb_id"), errors);
250        if let Some(value) = &entry.value {
251            if !cb_id_re().is_match(value) {
252                errors.push(format!(
253                    "{scope}.cb_id.{key}: value '{value}' must match ^cb[0-9]+$"
254                ));
255            }
256        }
257    }
258}
259
260/// Default location resolver. RFC §2.1: `values_file` config field wins,
261/// otherwise `values/<env>.yaml` relative to the config dir.
262pub fn default_values_path(config_dir: &Path, env: &str) -> PathBuf {
263    config_dir.join("values").join(format!("{env}.yaml"))
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use std::io::Write;
270    use tempfile::NamedTempFile;
271
272    fn write_temp(contents: &str) -> NamedTempFile {
273        let mut f = NamedTempFile::new().unwrap();
274        f.write_all(contents.as_bytes()).unwrap();
275        f
276    }
277
278    #[test]
279    fn parses_minimal_valid_file() {
280        let f = write_temp("version: 1\n");
281        let parsed = ValuesFile::load(f.path()).unwrap();
282        assert_eq!(parsed.version, 1);
283        assert!(parsed.content_block.is_empty());
284        assert!(parsed.email_template.is_empty());
285    }
286
287    #[test]
288    fn parses_full_shape() {
289        let f = write_temp(
290            r#"
291version: 1
292globals:
293  custom:
294    api_host:
295      value: api-prod.example.com
296content_block:
297  cb_promo_banner:
298    lid:
299      spring_sale:
300        value: ai8kexrxcp03
301        url: https://example.com/spring-sale
302    cb_id:
303      cb_promo_image:
304        value: cb42
305    custom:
306      banner_variant:
307        value: A
308email_template:
309  welcome:
310    custom:
311      user_segment_id:
312        value: seg_prod_42
313    subject:
314      lid:
315        promo_subject:
316          value: lidsubj42
317          anchor: "{{promo_code}}"
318    body_html:
319      lid:
320        cta:
321          value: lidhtml42
322          url: https://example.com/welcome/cta
323      cb_id:
324        cb_promo_image:
325          value: cb42
326"#,
327        );
328        let parsed = ValuesFile::load(f.path()).unwrap();
329        assert_eq!(parsed.version, 1);
330        assert_eq!(
331            parsed.globals.custom["api_host"].value.as_deref(),
332            Some("api-prod.example.com")
333        );
334        let cb = &parsed.content_block["cb_promo_banner"];
335        assert_eq!(cb.lid["spring_sale"].value.as_deref(), Some("ai8kexrxcp03"));
336        assert_eq!(
337            cb.lid["spring_sale"].url.as_deref(),
338            Some("https://example.com/spring-sale")
339        );
340        assert_eq!(cb.cb_id["cb_promo_image"].value.as_deref(), Some("cb42"));
341        let et = &parsed.email_template["welcome"];
342        assert_eq!(
343            et.custom["user_segment_id"].value.as_deref(),
344            Some("seg_prod_42")
345        );
346        assert_eq!(
347            et.subject.lid["promo_subject"].anchor.as_deref(),
348            Some("{{promo_code}}")
349        );
350        assert_eq!(et.body_html.lid["cta"].value.as_deref(), Some("lidhtml42"));
351    }
352
353    #[test]
354    fn rejects_unsupported_version() {
355        let f = write_temp("version: 2\n");
356        let err = ValuesFile::load(f.path()).unwrap_err();
357        match err {
358            Error::InvalidFormat { message, .. } => {
359                assert!(message.contains("schema version"));
360            }
361            other => panic!("expected InvalidFormat, got {other:?}"),
362        }
363    }
364
365    #[test]
366    fn rejects_bad_lid_shape() {
367        let f = write_temp(
368            r#"
369version: 1
370content_block:
371  cb:
372    lid:
373      foo:
374        value: TOO_SHORT
375"#,
376        );
377        let err = ValuesFile::load(f.path()).unwrap_err();
378        match err {
379            Error::InvalidFormat { message, .. } => {
380                assert!(message.contains("content_block.cb.lid.foo"));
381                assert!(message.contains("TOO_SHORT"));
382            }
383            other => panic!("expected InvalidFormat, got {other:?}"),
384        }
385    }
386
387    #[test]
388    fn rejects_bad_cb_id_shape() {
389        let f = write_temp(
390            r#"
391version: 1
392content_block:
393  cb:
394    cb_id:
395      target:
396        value: not_cb_form
397"#,
398        );
399        let err = ValuesFile::load(f.path()).unwrap_err();
400        match err {
401            Error::InvalidFormat { message, .. } => {
402                assert!(message.contains("cb_id.target"));
403            }
404            other => panic!("expected InvalidFormat, got {other:?}"),
405        }
406    }
407
408    #[test]
409    fn accepts_null_value_skeleton() {
410        // RFC §2.7: templatize-generated skeleton uses `value: null` to
411        // signal "needs export". Shape check must skip these.
412        let f = write_temp(
413            r#"
414version: 1
415content_block:
416  cb:
417    lid:
418      foo:
419        value: null
420        url: https://example.com/foo
421"#,
422        );
423        let parsed = ValuesFile::load(f.path()).unwrap();
424        assert!(parsed.content_block["cb"].lid["foo"].value.is_none());
425    }
426
427    #[test]
428    fn rejects_bad_key_shape() {
429        let f = write_temp(
430            r#"
431version: 1
432content_block:
433  cb:
434    custom:
435      BadKey:
436        value: x
437"#,
438        );
439        let err = ValuesFile::load(f.path()).unwrap_err();
440        match err {
441            Error::InvalidFormat { message, .. } => {
442                assert!(message.contains("BadKey"));
443            }
444            other => panic!("expected InvalidFormat, got {other:?}"),
445        }
446    }
447
448    #[test]
449    fn yaml_parse_error_surfaces() {
450        let f = write_temp(":\n  unbalanced");
451        let err = ValuesFile::load(f.path()).unwrap_err();
452        assert!(matches!(err, Error::YamlParse { .. }));
453    }
454
455    #[test]
456    fn save_omits_empty_namespaces_and_none_anchors() {
457        // Regression: without skip_serializing_if, exporting a single
458        // content_block bloated the file with `globals.custom: {}`,
459        // every empty field on every email_template, and `anchor: null`
460        // on every lid entry. Pin the lean output shape so users don't
461        // see noisy diffs after their first export.
462        let mut vf = ValuesFile {
463            version: 1,
464            ..Default::default()
465        };
466        let mut cb = ContentBlockValues::default();
467        cb.lid.insert(
468            "cta".to_string(),
469            LidEntry {
470                value: Some("newlidvalue1".into()),
471                url: Some("https://example.com/cta".into()),
472                anchor: None,
473            },
474        );
475        vf.content_block.insert("promo".into(), cb);
476
477        let s = serde_norway::to_string(&vf).unwrap();
478        assert!(!s.contains("globals"), "empty globals leaked: {s}");
479        assert!(
480            !s.contains("email_template"),
481            "empty email_template leaked: {s}"
482        );
483        assert!(!s.contains("cb_id"), "empty cb_id leaked: {s}");
484        assert!(!s.contains("custom"), "empty custom leaked: {s}");
485        assert!(!s.contains("anchor"), "None anchor leaked: {s}");
486        assert!(s.contains("value: newlidvalue1"));
487        assert!(s.contains("url: https://example.com/cta"));
488    }
489
490    #[test]
491    fn skeleton_null_value_survives_round_trip() {
492        // RFC §2.7 skeletons use `value: null` as the "needs export"
493        // marker. The save path must preserve it (not omit it).
494        let f = write_temp(
495            r#"version: 1
496content_block:
497  cb:
498    lid:
499      foo:
500        value: null
501        url: https://example.com/foo
502"#,
503        );
504        let parsed = ValuesFile::load(f.path()).unwrap();
505        let s = serde_norway::to_string(&parsed).unwrap();
506        assert!(
507            s.contains("value: null") || s.contains("value: ~"),
508            "skeleton null marker must survive save, got: {s}"
509        );
510    }
511
512    #[test]
513    fn default_path_uses_env_name() {
514        let p = default_values_path(Path::new("/tmp/repo"), "prod");
515        assert_eq!(p, PathBuf::from("/tmp/repo/values/prod.yaml"));
516    }
517}