1use 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
20pub const SUPPORTED_VERSION: u32 = 1;
22
23#[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#[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#[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#[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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct LidEntry {
100 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#[derive(Debug, Clone, Deserialize, Serialize)]
111pub struct CbIdEntry {
112 pub value: Option<String>,
113}
114
115#[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 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 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 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 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
260pub 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 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 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 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}