use regex_lite::Regex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::error::{Error, Result};
pub const SUPPORTED_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ValuesFile {
pub version: u32,
#[serde(default, skip_serializing_if = "Globals::is_empty")]
pub globals: Globals,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub content_block: BTreeMap<String, ContentBlockValues>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub email_template: BTreeMap<String, EmailTemplateValues>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Globals {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub custom: BTreeMap<String, CustomEntry>,
}
impl Globals {
fn is_empty(&self) -> bool {
self.custom.is_empty()
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ContentBlockValues {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub lid: BTreeMap<String, LidEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub cb_id: BTreeMap<String, CbIdEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub custom: BTreeMap<String, CustomEntry>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct EmailTemplateValues {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub custom: BTreeMap<String, CustomEntry>,
#[serde(default, skip_serializing_if = "FieldValues::is_empty")]
pub subject: FieldValues,
#[serde(default, skip_serializing_if = "FieldValues::is_empty")]
pub preheader: FieldValues,
#[serde(default, skip_serializing_if = "FieldValues::is_empty")]
pub body_html: FieldValues,
#[serde(default, skip_serializing_if = "FieldValues::is_empty")]
pub body_plaintext: FieldValues,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct FieldValues {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub lid: BTreeMap<String, LidEntry>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub cb_id: BTreeMap<String, CbIdEntry>,
}
impl FieldValues {
fn is_empty(&self) -> bool {
self.lid.is_empty() && self.cb_id.is_empty()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LidEntry {
pub value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anchor: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CbIdEntry {
pub value: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CustomEntry {
pub value: Option<String>,
}
fn lid_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-z0-9]{8,}$").expect("lid regex is valid"))
}
fn cb_id_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^cb[0-9]+$").expect("cb_id regex is valid"))
}
fn key_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*$").expect("key regex is valid"))
}
impl ValuesFile {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)?;
let parsed: ValuesFile =
serde_norway::from_str(&raw).map_err(|source| Error::YamlParse {
path: path.to_path_buf(),
source,
})?;
parsed.validate(path)?;
Ok(parsed)
}
pub fn save(&self, path: &Path) -> Result<()> {
self.validate(path)?;
let yaml = serde_norway::to_string(self).map_err(|e| Error::InvalidFormat {
path: path.to_path_buf(),
message: format!("serializing values file: {e}"),
})?;
crate::fs::write_atomic(path, yaml.as_bytes())
}
pub fn validate(&self, path: &Path) -> Result<()> {
if self.version != SUPPORTED_VERSION {
return Err(Error::InvalidFormat {
path: path.to_path_buf(),
message: format!(
"values file requires schema version {} (found: {})",
SUPPORTED_VERSION, self.version
),
});
}
let mut errors: Vec<String> = Vec::new();
for key in self.globals.custom.keys() {
check_key(key, "globals.custom", &mut errors);
}
for (cb_name, cb) in &self.content_block {
let scope = format!("content_block.{}", cb_name);
check_lid_map(&cb.lid, &scope, &mut errors);
check_cb_id_map(&cb.cb_id, &scope, &mut errors);
for key in cb.custom.keys() {
check_key(key, &format!("{scope}.custom"), &mut errors);
}
}
for (et_name, et) in &self.email_template {
let root = format!("email_template.{}", et_name);
for key in et.custom.keys() {
check_key(key, &format!("{root}.custom"), &mut errors);
}
for (field_name, field) in [
("subject", &et.subject),
("preheader", &et.preheader),
("body_html", &et.body_html),
("body_plaintext", &et.body_plaintext),
] {
let field_scope = format!("{root}.{field_name}");
check_lid_map(&field.lid, &field_scope, &mut errors);
check_cb_id_map(&field.cb_id, &field_scope, &mut errors);
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Error::InvalidFormat {
path: path.to_path_buf(),
message: errors.join("; "),
})
}
}
}
fn check_key(key: &str, scope: &str, errors: &mut Vec<String>) {
if !key_re().is_match(key) {
errors.push(format!("{scope}: key '{key}' must match [a-z][a-z0-9_]*"));
}
}
fn check_lid_map(map: &BTreeMap<String, LidEntry>, scope: &str, errors: &mut Vec<String>) {
for (key, entry) in map {
check_key(key, &format!("{scope}.lid"), errors);
if let Some(value) = &entry.value {
if !lid_re().is_match(value) {
errors.push(format!(
"{scope}.lid.{key}: value '{value}' must match ^[a-z0-9]{{8,}}$"
));
}
}
}
}
fn check_cb_id_map(map: &BTreeMap<String, CbIdEntry>, scope: &str, errors: &mut Vec<String>) {
for (key, entry) in map {
check_key(key, &format!("{scope}.cb_id"), errors);
if let Some(value) = &entry.value {
if !cb_id_re().is_match(value) {
errors.push(format!(
"{scope}.cb_id.{key}: value '{value}' must match ^cb[0-9]+$"
));
}
}
}
}
pub fn default_values_path(config_dir: &Path, env: &str) -> PathBuf {
config_dir.join("values").join(format!("{env}.yaml"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn write_temp(contents: &str) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(contents.as_bytes()).unwrap();
f
}
#[test]
fn parses_minimal_valid_file() {
let f = write_temp("version: 1\n");
let parsed = ValuesFile::load(f.path()).unwrap();
assert_eq!(parsed.version, 1);
assert!(parsed.content_block.is_empty());
assert!(parsed.email_template.is_empty());
}
#[test]
fn parses_full_shape() {
let f = write_temp(
r#"
version: 1
globals:
custom:
api_host:
value: api-prod.example.com
content_block:
cb_promo_banner:
lid:
spring_sale:
value: ai8kexrxcp03
url: https://example.com/spring-sale
cb_id:
cb_promo_image:
value: cb42
custom:
banner_variant:
value: A
email_template:
welcome:
custom:
user_segment_id:
value: seg_prod_42
subject:
lid:
promo_subject:
value: lidsubj42
anchor: "{{promo_code}}"
body_html:
lid:
cta:
value: lidhtml42
url: https://example.com/welcome/cta
cb_id:
cb_promo_image:
value: cb42
"#,
);
let parsed = ValuesFile::load(f.path()).unwrap();
assert_eq!(parsed.version, 1);
assert_eq!(
parsed.globals.custom["api_host"].value.as_deref(),
Some("api-prod.example.com")
);
let cb = &parsed.content_block["cb_promo_banner"];
assert_eq!(cb.lid["spring_sale"].value.as_deref(), Some("ai8kexrxcp03"));
assert_eq!(
cb.lid["spring_sale"].url.as_deref(),
Some("https://example.com/spring-sale")
);
assert_eq!(cb.cb_id["cb_promo_image"].value.as_deref(), Some("cb42"));
let et = &parsed.email_template["welcome"];
assert_eq!(
et.custom["user_segment_id"].value.as_deref(),
Some("seg_prod_42")
);
assert_eq!(
et.subject.lid["promo_subject"].anchor.as_deref(),
Some("{{promo_code}}")
);
assert_eq!(et.body_html.lid["cta"].value.as_deref(), Some("lidhtml42"));
}
#[test]
fn rejects_unsupported_version() {
let f = write_temp("version: 2\n");
let err = ValuesFile::load(f.path()).unwrap_err();
match err {
Error::InvalidFormat { message, .. } => {
assert!(message.contains("schema version"));
}
other => panic!("expected InvalidFormat, got {other:?}"),
}
}
#[test]
fn rejects_bad_lid_shape() {
let f = write_temp(
r#"
version: 1
content_block:
cb:
lid:
foo:
value: TOO_SHORT
"#,
);
let err = ValuesFile::load(f.path()).unwrap_err();
match err {
Error::InvalidFormat { message, .. } => {
assert!(message.contains("content_block.cb.lid.foo"));
assert!(message.contains("TOO_SHORT"));
}
other => panic!("expected InvalidFormat, got {other:?}"),
}
}
#[test]
fn rejects_bad_cb_id_shape() {
let f = write_temp(
r#"
version: 1
content_block:
cb:
cb_id:
target:
value: not_cb_form
"#,
);
let err = ValuesFile::load(f.path()).unwrap_err();
match err {
Error::InvalidFormat { message, .. } => {
assert!(message.contains("cb_id.target"));
}
other => panic!("expected InvalidFormat, got {other:?}"),
}
}
#[test]
fn accepts_null_value_skeleton() {
let f = write_temp(
r#"
version: 1
content_block:
cb:
lid:
foo:
value: null
url: https://example.com/foo
"#,
);
let parsed = ValuesFile::load(f.path()).unwrap();
assert!(parsed.content_block["cb"].lid["foo"].value.is_none());
}
#[test]
fn rejects_bad_key_shape() {
let f = write_temp(
r#"
version: 1
content_block:
cb:
custom:
BadKey:
value: x
"#,
);
let err = ValuesFile::load(f.path()).unwrap_err();
match err {
Error::InvalidFormat { message, .. } => {
assert!(message.contains("BadKey"));
}
other => panic!("expected InvalidFormat, got {other:?}"),
}
}
#[test]
fn yaml_parse_error_surfaces() {
let f = write_temp(":\n unbalanced");
let err = ValuesFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::YamlParse { .. }));
}
#[test]
fn save_omits_empty_namespaces_and_none_anchors() {
let mut vf = ValuesFile {
version: 1,
..Default::default()
};
let mut cb = ContentBlockValues::default();
cb.lid.insert(
"cta".to_string(),
LidEntry {
value: Some("newlidvalue1".into()),
url: Some("https://example.com/cta".into()),
anchor: None,
},
);
vf.content_block.insert("promo".into(), cb);
let s = serde_norway::to_string(&vf).unwrap();
assert!(!s.contains("globals"), "empty globals leaked: {s}");
assert!(
!s.contains("email_template"),
"empty email_template leaked: {s}"
);
assert!(!s.contains("cb_id"), "empty cb_id leaked: {s}");
assert!(!s.contains("custom"), "empty custom leaked: {s}");
assert!(!s.contains("anchor"), "None anchor leaked: {s}");
assert!(s.contains("value: newlidvalue1"));
assert!(s.contains("url: https://example.com/cta"));
}
#[test]
fn skeleton_null_value_survives_round_trip() {
let f = write_temp(
r#"version: 1
content_block:
cb:
lid:
foo:
value: null
url: https://example.com/foo
"#,
);
let parsed = ValuesFile::load(f.path()).unwrap();
let s = serde_norway::to_string(&parsed).unwrap();
assert!(
s.contains("value: null") || s.contains("value: ~"),
"skeleton null marker must survive save, got: {s}"
);
}
#[test]
fn default_path_uses_env_name() {
let p = default_values_path(Path::new("/tmp/repo"), "prod");
assert_eq!(p, PathBuf::from("/tmp/repo/values/prod.yaml"));
}
}