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