1pub mod schema;
18
19pub use schema::{
20 CatalogItemsConfig, ConfigFile, Defaults, EnvironmentConfig, NamingConfig, ResourceConfig,
21 ResourcesConfig,
22};
23
24use crate::error::{Error, Result};
25use secrecy::SecretString;
26use std::path::Path;
27use url::Url;
28
29#[derive(Debug)]
32pub struct ResolvedConfig {
33 pub environment_name: String,
34 pub api_endpoint: Url,
35 pub api_key: SecretString,
39 pub rate_limit_per_minute: u32,
40 pub resources: ResourcesConfig,
41 pub naming: NamingConfig,
42}
43
44impl ConfigFile {
45 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
47 let path = path.as_ref();
48 let bytes = std::fs::read_to_string(path)?;
49 let cfg: ConfigFile = serde_yml::from_str(&bytes).map_err(|source| Error::YamlParse {
50 path: path.to_path_buf(),
51 source,
52 })?;
53 cfg.validate_static()?;
54 Ok(cfg)
55 }
56
57 fn validate_static(&self) -> Result<()> {
58 if self.version != 1 {
59 return Err(Error::Config(format!(
60 "unsupported config version {} (this binary supports version 1; \
61 see IMPLEMENTATION.md §2.5 for the forward-compat policy)",
62 self.version
63 )));
64 }
65 if !self.environments.contains_key(&self.default_environment) {
66 return Err(Error::Config(format!(
67 "default_environment '{}' is not declared in the environments map",
68 self.default_environment
69 )));
70 }
71 for (name, env) in &self.environments {
75 if env.api_key_env.trim().is_empty() {
76 return Err(Error::Config(format!(
77 "environment '{name}': api_key_env must not be empty"
78 )));
79 }
80 match env.api_endpoint.scheme() {
81 "http" | "https" => {}
82 scheme => {
83 return Err(Error::Config(format!(
84 "environment '{name}': api_endpoint must use http or https \
85 (got '{scheme}')"
86 )));
87 }
88 }
89 }
90 Ok(())
91 }
92
93 pub fn resolve(self, env_override: Option<&str>) -> Result<ResolvedConfig> {
95 self.resolve_with(env_override, |k| std::env::var(k).ok())
96 }
97
98 pub fn resolve_with(
101 mut self,
102 env_override: Option<&str>,
103 env_lookup: impl Fn(&str) -> Option<String>,
104 ) -> Result<ResolvedConfig> {
105 let env_name = env_override
106 .map(str::to_string)
107 .unwrap_or_else(|| self.default_environment.clone());
108
109 if !self.environments.contains_key(&env_name) {
110 let known: Vec<&str> = self.environments.keys().map(String::as_str).collect();
111 return Err(Error::Config(format!(
112 "unknown environment '{}'; declared: [{}]",
113 env_name,
114 known.join(", ")
115 )));
116 }
117 let env_cfg = self
118 .environments
119 .remove(&env_name)
120 .expect("presence checked immediately above");
121
122 let api_key_str = env_lookup(&env_cfg.api_key_env)
123 .ok_or_else(|| Error::MissingEnv(env_cfg.api_key_env.clone()))?;
124 if api_key_str.is_empty() {
125 return Err(Error::Config(format!(
126 "environment variable '{}' is set but empty",
127 env_cfg.api_key_env
128 )));
129 }
130
131 let rate_limit_per_minute = env_cfg
132 .rate_limit_per_minute
133 .unwrap_or(self.defaults.rate_limit_per_minute);
134
135 Ok(ResolvedConfig {
136 environment_name: env_name,
137 api_endpoint: env_cfg.api_endpoint,
138 api_key: SecretString::from(api_key_str),
139 rate_limit_per_minute,
140 resources: self.resources,
141 naming: self.naming,
142 })
143 }
144}
145
146pub fn load_dotenv() -> Result<()> {
152 match dotenvy::from_path(".env") {
153 Ok(()) => Ok(()),
154 Err(e) if e.not_found() => Ok(()),
155 Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use secrecy::ExposeSecret;
163 use std::io::Write;
164
165 fn write_config(content: &str) -> tempfile::NamedTempFile {
166 let mut f = tempfile::NamedTempFile::new().unwrap();
167 f.write_all(content.as_bytes()).unwrap();
168 f
169 }
170
171 const MINIMAL: &str = r#"
172version: 1
173default_environment: dev
174environments:
175 dev:
176 api_endpoint: https://rest.fra-02.braze.eu
177 api_key_env: BRAZE_DEV_API_KEY
178"#;
179
180 #[test]
181 fn loads_minimal_config_with_all_defaults() {
182 let f = write_config(MINIMAL);
183 let cfg = ConfigFile::load(f.path()).unwrap();
184 assert_eq!(cfg.version, 1);
185 assert_eq!(cfg.default_environment, "dev");
186 assert_eq!(cfg.environments.len(), 1);
187 assert_eq!(cfg.defaults.rate_limit_per_minute, 40);
189 assert!(cfg.resources.catalog_schema.enabled);
191 assert_eq!(
192 cfg.resources.catalog_schema.path,
193 std::path::PathBuf::from("catalogs/")
194 );
195 assert_eq!(cfg.resources.catalog_items.parallel_batches, 4);
196 assert_eq!(
197 cfg.resources.custom_attribute.path,
198 std::path::PathBuf::from("custom_attributes/registry.yaml")
199 );
200 }
201
202 #[test]
203 fn loads_full_config_from_section_10() {
204 const FULL: &str = r#"
205version: 1
206default_environment: dev
207defaults:
208 rate_limit_per_minute: 50
209environments:
210 dev:
211 api_endpoint: https://rest.fra-02.braze.eu
212 api_key_env: BRAZE_DEV_API_KEY
213 prod:
214 api_endpoint: https://rest.fra-02.braze.eu
215 api_key_env: BRAZE_PROD_API_KEY
216 rate_limit_per_minute: 30
217resources:
218 catalog_schema:
219 enabled: true
220 path: catalogs/
221 catalog_items:
222 enabled: true
223 path: catalogs/
224 parallel_batches: 8
225 content_block:
226 enabled: true
227 path: content_blocks/
228 email_template:
229 enabled: false
230 path: email_templates/
231 custom_attribute:
232 enabled: true
233 path: custom_attributes/registry.yaml
234naming:
235 catalog_name_pattern: "^[a-z][a-z0-9_]*$"
236"#;
237 let f = write_config(FULL);
238 let cfg = ConfigFile::load(f.path()).unwrap();
239 assert_eq!(cfg.environments.len(), 2);
240 assert_eq!(cfg.defaults.rate_limit_per_minute, 50);
241 assert_eq!(cfg.environments["prod"].rate_limit_per_minute, Some(30));
242 assert_eq!(cfg.resources.catalog_items.parallel_batches, 8);
243 assert!(!cfg.resources.email_template.enabled);
244 assert_eq!(
245 cfg.naming.catalog_name_pattern.as_deref(),
246 Some("^[a-z][a-z0-9_]*$")
247 );
248 }
249
250 #[test]
251 fn rejects_wrong_version() {
252 let yaml = r#"
253version: 2
254default_environment: dev
255environments:
256 dev:
257 api_endpoint: https://rest.fra-02.braze.eu
258 api_key_env: BRAZE_DEV_API_KEY
259"#;
260 let f = write_config(yaml);
261 let err = ConfigFile::load(f.path()).unwrap_err();
262 assert!(matches!(err, Error::Config(_)));
263 assert!(err.to_string().contains("version 2"));
264 }
265
266 #[test]
267 fn rejects_unknown_top_level_field() {
268 let yaml = r#"
269version: 1
270default_environment: dev
271mystery_key: 1
272environments:
273 dev:
274 api_endpoint: https://rest.fra-02.braze.eu
275 api_key_env: BRAZE_DEV_API_KEY
276"#;
277 let f = write_config(yaml);
278 let err = ConfigFile::load(f.path()).unwrap_err();
279 assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
280 }
281
282 #[test]
283 fn rejects_non_http_endpoint_scheme() {
284 let yaml = r#"
285version: 1
286default_environment: dev
287environments:
288 dev:
289 api_endpoint: ftp://rest.braze.eu
290 api_key_env: BRAZE_DEV_API_KEY
291"#;
292 let f = write_config(yaml);
293 let err = ConfigFile::load(f.path()).unwrap_err();
294 assert!(matches!(err, Error::Config(_)));
295 let msg = err.to_string();
296 assert!(msg.contains("http"), "expected http scheme hint: {msg}");
297 assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
298 }
299
300 #[test]
301 fn rejects_default_environment_not_in_map() {
302 let yaml = r#"
303version: 1
304default_environment: missing
305environments:
306 dev:
307 api_endpoint: https://rest.fra-02.braze.eu
308 api_key_env: BRAZE_DEV_API_KEY
309"#;
310 let f = write_config(yaml);
311 let err = ConfigFile::load(f.path()).unwrap_err();
312 assert!(matches!(err, Error::Config(_)));
313 assert!(err.to_string().contains("missing"));
314 }
315
316 #[test]
317 fn resolve_uses_default_environment_when_no_override() {
318 let f = write_config(MINIMAL);
319 let cfg = ConfigFile::load(f.path()).unwrap();
320 let resolved = cfg
321 .resolve_with(None, |k| {
322 assert_eq!(k, "BRAZE_DEV_API_KEY");
323 Some("token-abc".into())
324 })
325 .unwrap();
326 assert_eq!(resolved.environment_name, "dev");
327 assert_eq!(resolved.api_key.expose_secret(), "token-abc");
328 assert_eq!(resolved.rate_limit_per_minute, 40);
329 }
330
331 #[test]
332 fn resolve_uses_override_when_provided() {
333 const TWO_ENVS: &str = r#"
334version: 1
335default_environment: dev
336environments:
337 dev:
338 api_endpoint: https://rest.fra-02.braze.eu
339 api_key_env: BRAZE_DEV_API_KEY
340 prod:
341 api_endpoint: https://rest.fra-02.braze.eu
342 api_key_env: BRAZE_PROD_API_KEY
343 rate_limit_per_minute: 25
344"#;
345 let f = write_config(TWO_ENVS);
346 let cfg = ConfigFile::load(f.path()).unwrap();
347 let resolved = cfg
348 .resolve_with(Some("prod"), |k| {
349 assert_eq!(k, "BRAZE_PROD_API_KEY");
350 Some("prod-token".into())
351 })
352 .unwrap();
353 assert_eq!(resolved.environment_name, "prod");
354 assert_eq!(resolved.rate_limit_per_minute, 25);
355 }
356
357 #[test]
358 fn resolve_unknown_env_lists_known_envs() {
359 let f = write_config(MINIMAL);
360 let cfg = ConfigFile::load(f.path()).unwrap();
361 let err = cfg
362 .resolve_with(Some("staging"), |_| Some("x".into()))
363 .unwrap_err();
364 let msg = err.to_string();
365 assert!(msg.contains("staging"));
366 assert!(msg.contains("dev"));
367 }
368
369 #[test]
370 fn resolve_missing_env_var_is_typed_error() {
371 let f = write_config(MINIMAL);
372 let cfg = ConfigFile::load(f.path()).unwrap();
373 let err = cfg.resolve_with(None, |_| None).unwrap_err();
374 match err {
375 Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
376 other => panic!("expected MissingEnv, got {other:?}"),
377 }
378 }
379
380 #[test]
381 fn resolve_empty_env_var_is_rejected() {
382 let f = write_config(MINIMAL);
383 let cfg = ConfigFile::load(f.path()).unwrap();
384 let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
385 assert!(matches!(err, Error::Config(_)));
386 assert!(err.to_string().contains("empty"));
387 }
388
389 #[test]
390 fn env_rate_limit_overrides_defaults() {
391 const OVERRIDE: &str = r#"
392version: 1
393default_environment: prod
394defaults:
395 rate_limit_per_minute: 100
396environments:
397 prod:
398 api_endpoint: https://rest.fra-02.braze.eu
399 api_key_env: K
400 rate_limit_per_minute: 7
401"#;
402 let f = write_config(OVERRIDE);
403 let resolved = ConfigFile::load(f.path())
404 .unwrap()
405 .resolve_with(None, |_| Some("k".into()))
406 .unwrap();
407 assert_eq!(resolved.rate_limit_per_minute, 7);
408 }
409
410 #[test]
411 fn defaults_apply_when_environment_has_no_override() {
412 const NO_OVERRIDE: &str = r#"
413version: 1
414default_environment: dev
415defaults:
416 rate_limit_per_minute: 12
417environments:
418 dev:
419 api_endpoint: https://rest.fra-02.braze.eu
420 api_key_env: K
421"#;
422 let f = write_config(NO_OVERRIDE);
423 let resolved = ConfigFile::load(f.path())
424 .unwrap()
425 .resolve_with(None, |_| Some("k".into()))
426 .unwrap();
427 assert_eq!(resolved.rate_limit_per_minute, 12);
428 }
429
430 #[test]
431 fn debug_format_does_not_leak_api_key() {
432 let f = write_config(MINIMAL);
433 let resolved = ConfigFile::load(f.path())
434 .unwrap()
435 .resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
436 .unwrap();
437 let dbg = format!("{resolved:?}");
438 assert!(
439 !dbg.contains("super-secret-token-abc-123"),
440 "Debug output leaked api key: {dbg}"
441 );
442 }
443
444 #[test]
445 fn rejects_empty_api_key_env() {
446 let yaml = r#"
447version: 1
448default_environment: dev
449environments:
450 dev:
451 api_endpoint: https://rest.fra-02.braze.eu
452 api_key_env: ""
453"#;
454 let f = write_config(yaml);
455 let err = ConfigFile::load(f.path()).unwrap_err();
456 assert!(matches!(err, Error::Config(_)), "got: {err:?}");
457 assert!(err.to_string().contains("api_key_env"));
458 }
459
460 #[test]
461 fn load_io_error_for_missing_file() {
462 let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
463 assert!(matches!(err, Error::Io(_)), "got: {err:?}");
464 }
465}