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