1pub mod schema;
18
19pub use schema::{
20 ApplyOrder, ConfigFile, Defaults, EnvironmentConfig, NamingConfig, ResourceConfig,
21 ResourcesConfig,
22};
23
24use crate::error::{Error, Result};
25use crate::resource::ResourceKind;
26use regex_lite::Regex;
27use secrecy::SecretString;
28use std::collections::HashMap;
29use std::path::Path;
30use url::Url;
31
32#[derive(Debug)]
35pub struct ResolvedConfig {
36 pub environment_name: String,
37 pub api_endpoint: Url,
38 pub api_key: SecretString,
42 pub resources: ResourcesConfig,
43 pub naming: NamingConfig,
44 pub excludes: HashMap<ResourceKind, Vec<Regex>>,
48 pub values_file: Option<std::path::PathBuf>,
52}
53
54impl ResolvedConfig {
55 pub fn excludes_for(&self, kind: ResourceKind) -> &[Regex] {
58 self.excludes.get(&kind).map(Vec::as_slice).unwrap_or(&[])
59 }
60}
61
62impl ConfigFile {
63 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
65 let path = path.as_ref();
66 let bytes = std::fs::read_to_string(path)?;
67 let cfg: ConfigFile =
68 serde_norway::from_str(&bytes).map_err(|source| Error::YamlParse {
69 path: path.to_path_buf(),
70 source,
71 })?;
72 cfg.validate_static()?;
73 Ok(cfg)
74 }
75
76 fn validate_static(&self) -> Result<()> {
77 if self.version != 1 {
78 return Err(Error::Config(format!(
79 "unsupported config version {} (this binary supports version 1; \
80 see IMPLEMENTATION.md §2.5 for the forward-compat policy)",
81 self.version
82 )));
83 }
84 if !self.environments.contains_key(&self.default_environment) {
85 return Err(Error::Config(format!(
86 "default_environment '{}' is not declared in the environments map",
87 self.default_environment
88 )));
89 }
90 for (name, env) in &self.environments {
94 if env.api_key_env.trim().is_empty() {
95 return Err(Error::Config(format!(
96 "environment '{name}': api_key_env must not be empty"
97 )));
98 }
99 match env.api_endpoint.scheme() {
100 "http" | "https" => {}
101 scheme => {
102 return Err(Error::Config(format!(
103 "environment '{name}': api_endpoint must use http or https \
104 (got '{scheme}')"
105 )));
106 }
107 }
108 }
109 for kind in ResourceKind::all() {
112 let rc = self.resources.for_kind(*kind);
113 compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?;
114 }
115 Ok(())
116 }
117
118 pub fn resolve(self, env_override: Option<&str>) -> Result<ResolvedConfig> {
120 self.resolve_with(env_override, |k| std::env::var(k).ok())
121 }
122
123 pub fn resolve_with(
126 mut self,
127 env_override: Option<&str>,
128 env_lookup: impl Fn(&str) -> Option<String>,
129 ) -> Result<ResolvedConfig> {
130 let env_name = env_override
131 .map(str::to_string)
132 .unwrap_or_else(|| self.default_environment.clone());
133
134 if !self.environments.contains_key(&env_name) {
135 let known: Vec<&str> = self.environments.keys().map(String::as_str).collect();
136 return Err(Error::Config(format!(
137 "unknown environment '{}'; declared: [{}]",
138 env_name,
139 known.join(", ")
140 )));
141 }
142 let env_cfg = self
143 .environments
144 .remove(&env_name)
145 .expect("presence checked immediately above");
146
147 let api_key_str = env_lookup(&env_cfg.api_key_env)
148 .ok_or_else(|| Error::MissingEnv(env_cfg.api_key_env.clone()))?;
149 if api_key_str.is_empty() {
150 return Err(Error::Config(format!(
151 "environment variable '{}' is set but empty",
152 env_cfg.api_key_env
153 )));
154 }
155
156 let mut excludes: HashMap<ResourceKind, Vec<Regex>> = HashMap::new();
157 for kind in ResourceKind::all() {
158 let rc = self.resources.for_kind(*kind);
159 excludes.insert(
160 *kind,
161 compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?,
162 );
163 }
164
165 Ok(ResolvedConfig {
166 environment_name: env_name,
167 api_endpoint: env_cfg.api_endpoint,
168 api_key: SecretString::from(api_key_str),
169 resources: self.resources,
170 naming: self.naming,
171 excludes,
172 values_file: env_cfg.values_file,
173 })
174 }
175}
176
177pub fn compile_exclude_patterns(patterns: &[String], context: &str) -> Result<Vec<Regex>> {
181 patterns
182 .iter()
183 .enumerate()
184 .map(|(i, p)| {
185 Regex::new(p).map_err(|e| {
186 Error::Config(format!(
187 "{context}.exclude_patterns[{i}]: invalid regex {p:?}: {e}"
188 ))
189 })
190 })
191 .collect()
192}
193
194pub fn is_excluded(name: &str, patterns: &[Regex]) -> bool {
196 patterns.iter().any(|r| r.is_match(name))
197}
198
199pub fn load_dotenv() -> Result<()> {
205 match dotenvy::from_path(".env") {
206 Ok(()) => Ok(()),
207 Err(e) if e.not_found() => Ok(()),
208 Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use secrecy::ExposeSecret;
216 use std::io::Write;
217
218 fn write_config(content: &str) -> tempfile::NamedTempFile {
219 let mut f = tempfile::NamedTempFile::new().unwrap();
220 f.write_all(content.as_bytes()).unwrap();
221 f
222 }
223
224 const MINIMAL: &str = r#"
225version: 1
226default_environment: dev
227environments:
228 dev:
229 api_endpoint: https://rest.fra-02.braze.eu
230 api_key_env: BRAZE_DEV_API_KEY
231"#;
232
233 #[test]
234 fn loads_minimal_config_with_all_defaults() {
235 let f = write_config(MINIMAL);
236 let cfg = ConfigFile::load(f.path()).unwrap();
237 assert_eq!(cfg.version, 1);
238 assert_eq!(cfg.default_environment, "dev");
239 assert_eq!(cfg.environments.len(), 1);
240 assert!(cfg.resources.catalog_schema.enabled);
242 assert_eq!(
243 cfg.resources.catalog_schema.path,
244 std::path::PathBuf::from("catalogs/")
245 );
246 assert_eq!(
247 cfg.resources.custom_attribute.path,
248 std::path::PathBuf::from("custom_attributes/registry.yaml")
249 );
250 }
251
252 #[test]
253 fn loads_full_config_from_section_10() {
254 const FULL: &str = r#"
255version: 1
256default_environment: dev
257environments:
258 dev:
259 api_endpoint: https://rest.fra-02.braze.eu
260 api_key_env: BRAZE_DEV_API_KEY
261 prod:
262 api_endpoint: https://rest.fra-02.braze.eu
263 api_key_env: BRAZE_PROD_API_KEY
264resources:
265 catalog_schema:
266 enabled: true
267 path: catalogs/
268 content_block:
269 enabled: true
270 path: content_blocks/
271 email_template:
272 enabled: false
273 path: email_templates/
274 custom_attribute:
275 enabled: true
276 path: custom_attributes/registry.yaml
277naming:
278 catalog_name_pattern: "^[a-z][a-z0-9_]*$"
279"#;
280 let f = write_config(FULL);
281 let cfg = ConfigFile::load(f.path()).unwrap();
282 assert_eq!(cfg.environments.len(), 2);
283 assert!(!cfg.resources.email_template.enabled);
284 assert_eq!(
285 cfg.naming.catalog_name_pattern.as_deref(),
286 Some("^[a-z][a-z0-9_]*$")
287 );
288 }
289
290 #[test]
291 fn rejects_wrong_version() {
292 let yaml = r#"
293version: 2
294default_environment: dev
295environments:
296 dev:
297 api_endpoint: https://rest.fra-02.braze.eu
298 api_key_env: BRAZE_DEV_API_KEY
299"#;
300 let f = write_config(yaml);
301 let err = ConfigFile::load(f.path()).unwrap_err();
302 assert!(matches!(err, Error::Config(_)));
303 assert!(err.to_string().contains("version 2"));
304 }
305
306 #[test]
307 fn rejects_unknown_top_level_field() {
308 let yaml = r#"
309version: 1
310default_environment: dev
311mystery_key: 1
312environments:
313 dev:
314 api_endpoint: https://rest.fra-02.braze.eu
315 api_key_env: BRAZE_DEV_API_KEY
316"#;
317 let f = write_config(yaml);
318 let err = ConfigFile::load(f.path()).unwrap_err();
319 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
320 }
321
322 #[test]
323 fn rejects_legacy_catalog_items_resource_section() {
324 let yaml = r#"
325version: 1
326default_environment: dev
327environments:
328 dev:
329 api_endpoint: https://rest.fra-02.braze.eu
330 api_key_env: BRAZE_DEV_API_KEY
331resources:
332 catalog_items:
333 enabled: true
334"#;
335 let f = write_config(yaml);
336 let err = ConfigFile::load(f.path()).unwrap_err();
337 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
338 }
339
340 #[test]
341 fn rejects_legacy_defaults_rate_limit_per_minute() {
342 let yaml = r#"
346version: 1
347default_environment: dev
348defaults:
349 rate_limit_per_minute: 40
350environments:
351 dev:
352 api_endpoint: https://rest.fra-02.braze.eu
353 api_key_env: BRAZE_DEV_API_KEY
354"#;
355 let f = write_config(yaml);
356 let err = ConfigFile::load(f.path()).unwrap_err();
357 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
358 }
359
360 #[test]
361 fn rejects_legacy_environment_rate_limit_per_minute() {
362 let yaml = r#"
363version: 1
364default_environment: dev
365environments:
366 dev:
367 api_endpoint: https://rest.fra-02.braze.eu
368 api_key_env: BRAZE_DEV_API_KEY
369 rate_limit_per_minute: 30
370"#;
371 let f = write_config(yaml);
372 let err = ConfigFile::load(f.path()).unwrap_err();
373 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
374 }
375
376 #[test]
377 fn accepts_exclude_patterns_on_resource_config() {
378 let yaml = r#"
379version: 1
380default_environment: dev
381environments:
382 dev:
383 api_endpoint: https://rest.fra-02.braze.eu
384 api_key_env: BRAZE_DEV_API_KEY
385resources:
386 custom_attribute:
387 path: custom_attributes/registry.yaml
388 exclude_patterns:
389 - "^_"
390 - "^(hoge|hack)$"
391"#;
392 let f = write_config(yaml);
393 let cfg = ConfigFile::load(f.path()).unwrap();
394 assert_eq!(
395 cfg.resources.custom_attribute.exclude_patterns,
396 vec!["^_".to_string(), "^(hoge|hack)$".to_string()]
397 );
398 }
399
400 #[test]
401 fn rejects_invalid_exclude_pattern_at_load_time() {
402 let yaml = r#"
405version: 1
406default_environment: dev
407environments:
408 dev:
409 api_endpoint: https://rest.fra-02.braze.eu
410 api_key_env: BRAZE_DEV_API_KEY
411resources:
412 custom_attribute:
413 path: custom_attributes/registry.yaml
414 exclude_patterns:
415 - "("
416"#;
417 let f = write_config(yaml);
418 let err = ConfigFile::load(f.path()).unwrap_err();
419 match err {
420 Error::Config(msg) => {
421 assert!(msg.contains("custom_attribute"), "msg: {msg}");
422 assert!(msg.contains("exclude_patterns[0]"), "msg: {msg}");
423 }
424 other => panic!("expected Config error, got {other:?}"),
425 }
426 }
427
428 #[test]
429 fn is_excluded_matches_any_pattern() {
430 let patterns =
431 compile_exclude_patterns(&["^_".to_string(), "^test_".to_string()], "test").unwrap();
432 assert!(is_excluded("_unset", &patterns));
433 assert!(is_excluded("test_foo", &patterns));
434 assert!(!is_excluded("regular_attr", &patterns));
435 }
436
437 #[test]
438 fn rejects_non_http_endpoint_scheme() {
439 let yaml = r#"
440version: 1
441default_environment: dev
442environments:
443 dev:
444 api_endpoint: ftp://rest.braze.eu
445 api_key_env: BRAZE_DEV_API_KEY
446"#;
447 let f = write_config(yaml);
448 let err = ConfigFile::load(f.path()).unwrap_err();
449 assert!(matches!(err, Error::Config(_)));
450 let msg = err.to_string();
451 assert!(msg.contains("http"), "expected http scheme hint: {msg}");
452 assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
453 }
454
455 #[test]
456 fn rejects_default_environment_not_in_map() {
457 let yaml = r#"
458version: 1
459default_environment: missing
460environments:
461 dev:
462 api_endpoint: https://rest.fra-02.braze.eu
463 api_key_env: BRAZE_DEV_API_KEY
464"#;
465 let f = write_config(yaml);
466 let err = ConfigFile::load(f.path()).unwrap_err();
467 assert!(matches!(err, Error::Config(_)));
468 assert!(err.to_string().contains("missing"));
469 }
470
471 #[test]
472 fn resolve_uses_default_environment_when_no_override() {
473 let f = write_config(MINIMAL);
474 let cfg = ConfigFile::load(f.path()).unwrap();
475 let resolved = cfg
476 .resolve_with(None, |k| {
477 assert_eq!(k, "BRAZE_DEV_API_KEY");
478 Some("token-abc".into())
479 })
480 .unwrap();
481 assert_eq!(resolved.environment_name, "dev");
482 assert_eq!(resolved.api_key.expose_secret(), "token-abc");
483 }
484
485 #[test]
486 fn resolve_uses_override_when_provided() {
487 const TWO_ENVS: &str = r#"
488version: 1
489default_environment: dev
490environments:
491 dev:
492 api_endpoint: https://rest.fra-02.braze.eu
493 api_key_env: BRAZE_DEV_API_KEY
494 prod:
495 api_endpoint: https://rest.fra-02.braze.eu
496 api_key_env: BRAZE_PROD_API_KEY
497"#;
498 let f = write_config(TWO_ENVS);
499 let cfg = ConfigFile::load(f.path()).unwrap();
500 let resolved = cfg
501 .resolve_with(Some("prod"), |k| {
502 assert_eq!(k, "BRAZE_PROD_API_KEY");
503 Some("prod-token".into())
504 })
505 .unwrap();
506 assert_eq!(resolved.environment_name, "prod");
507 }
508
509 #[test]
510 fn resolve_unknown_env_lists_known_envs() {
511 let f = write_config(MINIMAL);
512 let cfg = ConfigFile::load(f.path()).unwrap();
513 let err = cfg
514 .resolve_with(Some("staging"), |_| Some("x".into()))
515 .unwrap_err();
516 let msg = err.to_string();
517 assert!(msg.contains("staging"));
518 assert!(msg.contains("dev"));
519 }
520
521 #[test]
522 fn resolve_missing_env_var_is_typed_error() {
523 let f = write_config(MINIMAL);
524 let cfg = ConfigFile::load(f.path()).unwrap();
525 let err = cfg.resolve_with(None, |_| None).unwrap_err();
526 match err {
527 Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
528 other => panic!("expected MissingEnv, got {other:?}"),
529 }
530 }
531
532 #[test]
533 fn resolve_empty_env_var_is_rejected() {
534 let f = write_config(MINIMAL);
535 let cfg = ConfigFile::load(f.path()).unwrap();
536 let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
537 assert!(matches!(err, Error::Config(_)));
538 assert!(err.to_string().contains("empty"));
539 }
540
541 #[test]
542 fn debug_format_does_not_leak_api_key() {
543 let f = write_config(MINIMAL);
544 let resolved = ConfigFile::load(f.path())
545 .unwrap()
546 .resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
547 .unwrap();
548 let dbg = format!("{resolved:?}");
549 assert!(
550 !dbg.contains("super-secret-token-abc-123"),
551 "Debug output leaked api key: {dbg}"
552 );
553 }
554
555 #[test]
556 fn rejects_empty_api_key_env() {
557 let yaml = r#"
558version: 1
559default_environment: dev
560environments:
561 dev:
562 api_endpoint: https://rest.fra-02.braze.eu
563 api_key_env: ""
564"#;
565 let f = write_config(yaml);
566 let err = ConfigFile::load(f.path()).unwrap_err();
567 assert!(matches!(err, Error::Config(_)), "got: {err:?}");
568 assert!(err.to_string().contains("api_key_env"));
569 }
570
571 #[test]
572 fn load_io_error_for_missing_file() {
573 let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
574 assert!(matches!(err, Error::Io(_)), "got: {err:?}");
575 }
576}