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] {
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 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>> {
173 patterns
174 .iter()
175 .enumerate()
176 .map(|(i, p)| {
177 Regex::new(p).map_err(|e| {
178 Error::Config(format!(
179 "{context}.exclude_patterns[{i}]: invalid regex {p:?}: {e}"
180 ))
181 })
182 })
183 .collect()
184}
185
186pub fn is_excluded(name: &str, patterns: &[Regex]) -> bool {
187 patterns.iter().any(|r| r.is_match(name))
188}
189
190pub fn load_dotenv() -> Result<()> {
192 match dotenvy::from_path(".env") {
193 Ok(()) => Ok(()),
194 Err(e) if e.not_found() => Ok(()),
195 Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use secrecy::ExposeSecret;
203 use std::io::Write;
204
205 fn write_config(content: &str) -> tempfile::NamedTempFile {
206 let mut f = tempfile::NamedTempFile::new().unwrap();
207 f.write_all(content.as_bytes()).unwrap();
208 f
209 }
210
211 const MINIMAL: &str = r#"
212version: 1
213default_environment: dev
214environments:
215 dev:
216 api_endpoint: https://rest.fra-02.braze.eu
217 api_key_env: BRAZE_DEV_API_KEY
218"#;
219
220 #[test]
221 fn loads_minimal_config_with_all_defaults() {
222 let f = write_config(MINIMAL);
223 let cfg = ConfigFile::load(f.path()).unwrap();
224 assert_eq!(cfg.version, 1);
225 assert_eq!(cfg.default_environment, "dev");
226 assert_eq!(cfg.environments.len(), 1);
227 assert!(cfg.resources.catalog_schema.enabled);
229 assert_eq!(
230 cfg.resources.catalog_schema.path,
231 std::path::PathBuf::from("catalogs/")
232 );
233 assert_eq!(
234 cfg.resources.custom_attribute.path,
235 std::path::PathBuf::from("custom_attributes/registry.yaml")
236 );
237 }
238
239 #[test]
240 fn loads_full_config_from_section_10() {
241 const FULL: &str = r#"
242version: 1
243default_environment: dev
244environments:
245 dev:
246 api_endpoint: https://rest.fra-02.braze.eu
247 api_key_env: BRAZE_DEV_API_KEY
248 prod:
249 api_endpoint: https://rest.fra-02.braze.eu
250 api_key_env: BRAZE_PROD_API_KEY
251resources:
252 catalog_schema:
253 enabled: true
254 path: catalogs/
255 content_block:
256 enabled: true
257 path: content_blocks/
258 email_template:
259 enabled: false
260 path: email_templates/
261 custom_attribute:
262 enabled: true
263 path: custom_attributes/registry.yaml
264naming:
265 catalog_name_pattern: "^[a-z][a-z0-9_]*$"
266"#;
267 let f = write_config(FULL);
268 let cfg = ConfigFile::load(f.path()).unwrap();
269 assert_eq!(cfg.environments.len(), 2);
270 assert!(!cfg.resources.email_template.enabled);
271 assert_eq!(
272 cfg.naming.catalog_name_pattern.as_deref(),
273 Some("^[a-z][a-z0-9_]*$")
274 );
275 }
276
277 #[test]
278 fn rejects_wrong_version() {
279 let yaml = r#"
280version: 2
281default_environment: dev
282environments:
283 dev:
284 api_endpoint: https://rest.fra-02.braze.eu
285 api_key_env: BRAZE_DEV_API_KEY
286"#;
287 let f = write_config(yaml);
288 let err = ConfigFile::load(f.path()).unwrap_err();
289 assert!(matches!(err, Error::Config(_)));
290 assert!(err.to_string().contains("version 2"));
291 }
292
293 #[test]
294 fn rejects_unknown_top_level_field() {
295 let yaml = r#"
296version: 1
297default_environment: dev
298mystery_key: 1
299environments:
300 dev:
301 api_endpoint: https://rest.fra-02.braze.eu
302 api_key_env: BRAZE_DEV_API_KEY
303"#;
304 let f = write_config(yaml);
305 let err = ConfigFile::load(f.path()).unwrap_err();
306 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
307 }
308
309 #[test]
310 fn rejects_legacy_catalog_items_resource_section() {
311 let yaml = r#"
312version: 1
313default_environment: dev
314environments:
315 dev:
316 api_endpoint: https://rest.fra-02.braze.eu
317 api_key_env: BRAZE_DEV_API_KEY
318resources:
319 catalog_items:
320 enabled: true
321"#;
322 let f = write_config(yaml);
323 let err = ConfigFile::load(f.path()).unwrap_err();
324 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
325 }
326
327 #[test]
328 fn rejects_legacy_defaults_rate_limit_per_minute() {
329 let yaml = r#"
333version: 1
334default_environment: dev
335defaults:
336 rate_limit_per_minute: 40
337environments:
338 dev:
339 api_endpoint: https://rest.fra-02.braze.eu
340 api_key_env: BRAZE_DEV_API_KEY
341"#;
342 let f = write_config(yaml);
343 let err = ConfigFile::load(f.path()).unwrap_err();
344 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
345 }
346
347 #[test]
348 fn accepts_exclude_patterns_on_resource_config() {
349 let yaml = r#"
350version: 1
351default_environment: dev
352environments:
353 dev:
354 api_endpoint: https://rest.fra-02.braze.eu
355 api_key_env: BRAZE_DEV_API_KEY
356resources:
357 custom_attribute:
358 path: custom_attributes/registry.yaml
359 exclude_patterns:
360 - "^_"
361 - "^(hoge|hack)$"
362"#;
363 let f = write_config(yaml);
364 let cfg = ConfigFile::load(f.path()).unwrap();
365 assert_eq!(
366 cfg.resources.custom_attribute.exclude_patterns,
367 vec!["^_".to_string(), "^(hoge|hack)$".to_string()]
368 );
369 }
370
371 #[test]
372 fn rejects_invalid_exclude_pattern_at_load_time() {
373 let yaml = r#"
376version: 1
377default_environment: dev
378environments:
379 dev:
380 api_endpoint: https://rest.fra-02.braze.eu
381 api_key_env: BRAZE_DEV_API_KEY
382resources:
383 custom_attribute:
384 path: custom_attributes/registry.yaml
385 exclude_patterns:
386 - "("
387"#;
388 let f = write_config(yaml);
389 let err = ConfigFile::load(f.path()).unwrap_err();
390 match err {
391 Error::Config(msg) => {
392 assert!(msg.contains("custom_attribute"), "msg: {msg}");
393 assert!(msg.contains("exclude_patterns[0]"), "msg: {msg}");
394 }
395 other => panic!("expected Config error, got {other:?}"),
396 }
397 }
398
399 #[test]
400 fn is_excluded_matches_any_pattern() {
401 let patterns =
402 compile_exclude_patterns(&["^_".to_string(), "^test_".to_string()], "test").unwrap();
403 assert!(is_excluded("_unset", &patterns));
404 assert!(is_excluded("test_foo", &patterns));
405 assert!(!is_excluded("regular_attr", &patterns));
406 }
407
408 #[test]
409 fn rejects_non_http_endpoint_scheme() {
410 let yaml = r#"
411version: 1
412default_environment: dev
413environments:
414 dev:
415 api_endpoint: ftp://rest.braze.eu
416 api_key_env: BRAZE_DEV_API_KEY
417"#;
418 let f = write_config(yaml);
419 let err = ConfigFile::load(f.path()).unwrap_err();
420 assert!(matches!(err, Error::Config(_)));
421 let msg = err.to_string();
422 assert!(msg.contains("http"), "expected http scheme hint: {msg}");
423 assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
424 }
425
426 #[test]
427 fn rejects_default_environment_not_in_map() {
428 let yaml = r#"
429version: 1
430default_environment: missing
431environments:
432 dev:
433 api_endpoint: https://rest.fra-02.braze.eu
434 api_key_env: BRAZE_DEV_API_KEY
435"#;
436 let f = write_config(yaml);
437 let err = ConfigFile::load(f.path()).unwrap_err();
438 assert!(matches!(err, Error::Config(_)));
439 assert!(err.to_string().contains("missing"));
440 }
441
442 #[test]
443 fn resolve_uses_default_environment_when_no_override() {
444 let f = write_config(MINIMAL);
445 let cfg = ConfigFile::load(f.path()).unwrap();
446 let resolved = cfg
447 .resolve_with(None, |k| {
448 assert_eq!(k, "BRAZE_DEV_API_KEY");
449 Some("token-abc".into())
450 })
451 .unwrap();
452 assert_eq!(resolved.environment_name, "dev");
453 assert_eq!(resolved.api_key.expose_secret(), "token-abc");
454 }
455
456 #[test]
457 fn resolve_uses_override_when_provided() {
458 const TWO_ENVS: &str = r#"
459version: 1
460default_environment: dev
461environments:
462 dev:
463 api_endpoint: https://rest.fra-02.braze.eu
464 api_key_env: BRAZE_DEV_API_KEY
465 prod:
466 api_endpoint: https://rest.fra-02.braze.eu
467 api_key_env: BRAZE_PROD_API_KEY
468"#;
469 let f = write_config(TWO_ENVS);
470 let cfg = ConfigFile::load(f.path()).unwrap();
471 let resolved = cfg
472 .resolve_with(Some("prod"), |k| {
473 assert_eq!(k, "BRAZE_PROD_API_KEY");
474 Some("prod-token".into())
475 })
476 .unwrap();
477 assert_eq!(resolved.environment_name, "prod");
478 }
479
480 #[test]
481 fn resolve_unknown_env_lists_known_envs() {
482 let f = write_config(MINIMAL);
483 let cfg = ConfigFile::load(f.path()).unwrap();
484 let err = cfg
485 .resolve_with(Some("staging"), |_| Some("x".into()))
486 .unwrap_err();
487 let msg = err.to_string();
488 assert!(msg.contains("staging"));
489 assert!(msg.contains("dev"));
490 }
491
492 #[test]
493 fn resolve_missing_env_var_is_typed_error() {
494 let f = write_config(MINIMAL);
495 let cfg = ConfigFile::load(f.path()).unwrap();
496 let err = cfg.resolve_with(None, |_| None).unwrap_err();
497 match err {
498 Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
499 other => panic!("expected MissingEnv, got {other:?}"),
500 }
501 }
502
503 #[test]
504 fn resolve_empty_env_var_is_rejected() {
505 let f = write_config(MINIMAL);
506 let cfg = ConfigFile::load(f.path()).unwrap();
507 let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
508 assert!(matches!(err, Error::Config(_)));
509 assert!(err.to_string().contains("empty"));
510 }
511
512 #[test]
513 fn debug_format_does_not_leak_api_key() {
514 let f = write_config(MINIMAL);
515 let resolved = ConfigFile::load(f.path())
516 .unwrap()
517 .resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
518 .unwrap();
519 let dbg = format!("{resolved:?}");
520 assert!(
521 !dbg.contains("super-secret-token-abc-123"),
522 "Debug output leaked api key: {dbg}"
523 );
524 }
525
526 #[test]
527 fn rejects_empty_api_key_env() {
528 let yaml = r#"
529version: 1
530default_environment: dev
531environments:
532 dev:
533 api_endpoint: https://rest.fra-02.braze.eu
534 api_key_env: ""
535"#;
536 let f = write_config(yaml);
537 let err = ConfigFile::load(f.path()).unwrap_err();
538 assert!(matches!(err, Error::Config(_)), "got: {err:?}");
539 assert!(err.to_string().contains("api_key_env"));
540 }
541
542 #[test]
543 fn load_io_error_for_missing_file() {
544 let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
545 assert!(matches!(err, Error::Io(_)), "got: {err:?}");
546 }
547}