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