Skip to main content

braze_sync/config/
mod.rs

1//! Configuration loading and environment resolution.
2//!
3//! Two-step model:
4//! 1. [`ConfigFile::load`] reads the YAML, parses it, and runs structural
5//!    validation that doesn't need environment variables.
6//! 2. [`ConfigFile::resolve`] picks an environment, looks up the API key
7//!    via the OS environment, and produces a [`ResolvedConfig`] which is
8//!    what the rest of the system consumes.
9//!
10//! The split exists so tests can drive [`ConfigFile::resolve_with`] with a
11//! fake env-lookup closure instead of mutating process-global `std::env`.
12//!
13//! See IMPLEMENTATION.md §10. The api key is wrapped in
14//! [`secrecy::SecretString`] from the moment it leaves the OS so that
15//! `Debug`, `tracing`, and panic messages cannot leak it.
16
17pub 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/// Fully-resolved config: an environment has been picked and the API key
30/// has been pulled out of the OS environment.
31#[derive(Debug)]
32pub struct ResolvedConfig {
33    pub environment_name: String,
34    pub api_endpoint: Url,
35    /// API key, secrecy-wrapped. Use [`secrecy::ExposeSecret`] at the call
36    /// site that needs the plaintext (typically only the BrazeClient
37    /// constructor).
38    pub api_key: SecretString,
39    pub rate_limit_per_minute: u32,
40    pub resources: ResourcesConfig,
41    pub naming: NamingConfig,
42}
43
44impl ConfigFile {
45    /// Load and structurally validate a config file.
46    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        // Validate that all endpoint URLs use http or https. Non-hierarchical
72        // schemes (mailto:, data:, etc.) would panic in BrazeClient::url_for
73        // when calling path_segments_mut().
74        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    /// Resolve to a [`ResolvedConfig`] using the real process environment.
94    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    /// Resolve using a caller-supplied env-var lookup closure. Used by
99    /// tests so they don't have to touch process-global `std::env`.
100    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
146/// Load `.env` from the current working directory only — no parent
147/// traversal — to populate `std::env` before config resolution. A missing
148/// file is the common dev case and is not an error.
149///
150/// IMPLEMENTATION.md §10: via dotenvy, CWD only, no parent traversal.
151pub 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        // defaults applied
188        assert_eq!(cfg.defaults.rate_limit_per_minute, 40);
189        // resources defaulted in full
190        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}