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    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/// Fully-resolved config: an environment has been picked and the API key
33/// has been pulled out of the OS environment.
34#[derive(Debug)]
35pub struct ResolvedConfig {
36    pub environment_name: String,
37    pub api_endpoint: Url,
38    /// API key, secrecy-wrapped. Use [`secrecy::ExposeSecret`] at the call
39    /// site that needs the plaintext (typically only the BrazeClient
40    /// constructor).
41    pub api_key: SecretString,
42    pub resources: ResourcesConfig,
43    pub naming: NamingConfig,
44    /// Compiled `exclude_patterns` per resource kind. Populated by
45    /// [`ConfigFile::resolve_with`] so callers can look up a `&[Regex]`
46    /// without recompiling on every invocation.
47    pub excludes: HashMap<ResourceKind, Vec<Regex>>,
48    /// Optional explicit path to the per-env values file (RFC
49    /// `feat-per-env-values.md` §2.1). When `None`, the CLI falls back to
50    /// `values/<environment_name>.yaml` relative to the config dir.
51    pub values_file: Option<std::path::PathBuf>,
52}
53
54impl ResolvedConfig {
55    /// Compiled exclude patterns for `kind`. Returns an empty slice when
56    /// no patterns are configured.
57    pub fn excludes_for(&self, kind: ResourceKind) -> &[Regex] {
58        self.excludes.get(&kind).map(Vec::as_slice).unwrap_or(&[])
59    }
60}
61
62impl ConfigFile {
63    /// Load and structurally validate a config file.
64    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
65        let path = path.as_ref();
66        let bytes = std::fs::read_to_string(path)?;
67        let cfg: ConfigFile =
68            serde_norway::from_str(&bytes).map_err(|source| Error::YamlParse {
69                path: path.to_path_buf(),
70                source,
71            })?;
72        cfg.validate_static()?;
73        Ok(cfg)
74    }
75
76    fn validate_static(&self) -> Result<()> {
77        if self.version != 1 {
78            return Err(Error::Config(format!(
79                "unsupported config version {} (this binary supports version 1; \
80                 see IMPLEMENTATION.md §2.5 for the forward-compat policy)",
81                self.version
82            )));
83        }
84        if !self.environments.contains_key(&self.default_environment) {
85            return Err(Error::Config(format!(
86                "default_environment '{}' is not declared in the environments map",
87                self.default_environment
88            )));
89        }
90        // Validate that all endpoint URLs use http or https. Non-hierarchical
91        // schemes (mailto:, data:, etc.) would panic in BrazeClient::url_for
92        // when calling path_segments_mut().
93        for (name, env) in &self.environments {
94            if env.api_key_env.trim().is_empty() {
95                return Err(Error::Config(format!(
96                    "environment '{name}': api_key_env must not be empty"
97                )));
98            }
99            match env.api_endpoint.scheme() {
100                "http" | "https" => {}
101                scheme => {
102                    return Err(Error::Config(format!(
103                        "environment '{name}': api_endpoint must use http or https \
104                         (got '{scheme}')"
105                    )));
106                }
107            }
108        }
109        // Compile every resource's exclude_patterns at load time so
110        // malformed regexes fail fast instead of at first use.
111        for kind in ResourceKind::all() {
112            let rc = self.resources.for_kind(*kind);
113            compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?;
114        }
115        Ok(())
116    }
117
118    /// Resolve to a [`ResolvedConfig`] using the real process environment.
119    pub fn resolve(self, env_override: Option<&str>) -> Result<ResolvedConfig> {
120        self.resolve_with(env_override, |k| std::env::var(k).ok())
121    }
122
123    /// Resolve using a caller-supplied env-var lookup closure. Used by
124    /// tests so they don't have to touch process-global `std::env`.
125    pub fn resolve_with(
126        mut self,
127        env_override: Option<&str>,
128        env_lookup: impl Fn(&str) -> Option<String>,
129    ) -> Result<ResolvedConfig> {
130        let env_name = env_override
131            .map(str::to_string)
132            .unwrap_or_else(|| self.default_environment.clone());
133
134        if !self.environments.contains_key(&env_name) {
135            let known: Vec<&str> = self.environments.keys().map(String::as_str).collect();
136            return Err(Error::Config(format!(
137                "unknown environment '{}'; declared: [{}]",
138                env_name,
139                known.join(", ")
140            )));
141        }
142        let env_cfg = self
143            .environments
144            .remove(&env_name)
145            .expect("presence checked immediately above");
146
147        let api_key_str = env_lookup(&env_cfg.api_key_env)
148            .ok_or_else(|| Error::MissingEnv(env_cfg.api_key_env.clone()))?;
149        if api_key_str.is_empty() {
150            return Err(Error::Config(format!(
151                "environment variable '{}' is set but empty",
152                env_cfg.api_key_env
153            )));
154        }
155
156        let mut excludes: HashMap<ResourceKind, Vec<Regex>> = HashMap::new();
157        for kind in ResourceKind::all() {
158            let rc = self.resources.for_kind(*kind);
159            excludes.insert(
160                *kind,
161                compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?,
162            );
163        }
164
165        Ok(ResolvedConfig {
166            environment_name: env_name,
167            api_endpoint: env_cfg.api_endpoint,
168            api_key: SecretString::from(api_key_str),
169            resources: self.resources,
170            naming: self.naming,
171            excludes,
172            values_file: env_cfg.values_file,
173        })
174    }
175}
176
177/// Compile a list of raw regex patterns from a resource's
178/// `exclude_patterns` into `Regex` values. The `context` label is used
179/// in error messages (e.g. `"custom_attribute"`).
180pub fn compile_exclude_patterns(patterns: &[String], context: &str) -> Result<Vec<Regex>> {
181    patterns
182        .iter()
183        .enumerate()
184        .map(|(i, p)| {
185            Regex::new(p).map_err(|e| {
186                Error::Config(format!(
187                    "{context}.exclude_patterns[{i}]: invalid regex {p:?}: {e}"
188                ))
189            })
190        })
191        .collect()
192}
193
194/// Return `true` if `name` matches any of the compiled patterns.
195pub fn is_excluded(name: &str, patterns: &[Regex]) -> bool {
196    patterns.iter().any(|r| r.is_match(name))
197}
198
199/// Load `.env` from the current working directory only — no parent
200/// traversal — to populate `std::env` before config resolution. A missing
201/// file is the common dev case and is not an error.
202///
203/// IMPLEMENTATION.md §10: via dotenvy, CWD only, no parent traversal.
204pub fn load_dotenv() -> Result<()> {
205    match dotenvy::from_path(".env") {
206        Ok(()) => Ok(()),
207        Err(e) if e.not_found() => Ok(()),
208        Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use secrecy::ExposeSecret;
216    use std::io::Write;
217
218    fn write_config(content: &str) -> tempfile::NamedTempFile {
219        let mut f = tempfile::NamedTempFile::new().unwrap();
220        f.write_all(content.as_bytes()).unwrap();
221        f
222    }
223
224    const MINIMAL: &str = r#"
225version: 1
226default_environment: dev
227environments:
228  dev:
229    api_endpoint: https://rest.fra-02.braze.eu
230    api_key_env: BRAZE_DEV_API_KEY
231"#;
232
233    #[test]
234    fn loads_minimal_config_with_all_defaults() {
235        let f = write_config(MINIMAL);
236        let cfg = ConfigFile::load(f.path()).unwrap();
237        assert_eq!(cfg.version, 1);
238        assert_eq!(cfg.default_environment, "dev");
239        assert_eq!(cfg.environments.len(), 1);
240        // resources defaulted in full
241        assert!(cfg.resources.catalog_schema.enabled);
242        assert_eq!(
243            cfg.resources.catalog_schema.path,
244            std::path::PathBuf::from("catalogs/")
245        );
246        assert_eq!(
247            cfg.resources.custom_attribute.path,
248            std::path::PathBuf::from("custom_attributes/registry.yaml")
249        );
250    }
251
252    #[test]
253    fn loads_full_config_from_section_10() {
254        const FULL: &str = r#"
255version: 1
256default_environment: dev
257environments:
258  dev:
259    api_endpoint: https://rest.fra-02.braze.eu
260    api_key_env: BRAZE_DEV_API_KEY
261  prod:
262    api_endpoint: https://rest.fra-02.braze.eu
263    api_key_env: BRAZE_PROD_API_KEY
264resources:
265  catalog_schema:
266    enabled: true
267    path: catalogs/
268  content_block:
269    enabled: true
270    path: content_blocks/
271  email_template:
272    enabled: false
273    path: email_templates/
274  custom_attribute:
275    enabled: true
276    path: custom_attributes/registry.yaml
277naming:
278  catalog_name_pattern: "^[a-z][a-z0-9_]*$"
279"#;
280        let f = write_config(FULL);
281        let cfg = ConfigFile::load(f.path()).unwrap();
282        assert_eq!(cfg.environments.len(), 2);
283        assert!(!cfg.resources.email_template.enabled);
284        assert_eq!(
285            cfg.naming.catalog_name_pattern.as_deref(),
286            Some("^[a-z][a-z0-9_]*$")
287        );
288    }
289
290    #[test]
291    fn rejects_wrong_version() {
292        let yaml = r#"
293version: 2
294default_environment: dev
295environments:
296  dev:
297    api_endpoint: https://rest.fra-02.braze.eu
298    api_key_env: BRAZE_DEV_API_KEY
299"#;
300        let f = write_config(yaml);
301        let err = ConfigFile::load(f.path()).unwrap_err();
302        assert!(matches!(err, Error::Config(_)));
303        assert!(err.to_string().contains("version 2"));
304    }
305
306    #[test]
307    fn rejects_unknown_top_level_field() {
308        let yaml = r#"
309version: 1
310default_environment: dev
311mystery_key: 1
312environments:
313  dev:
314    api_endpoint: https://rest.fra-02.braze.eu
315    api_key_env: BRAZE_DEV_API_KEY
316"#;
317        let f = write_config(yaml);
318        let err = ConfigFile::load(f.path()).unwrap_err();
319        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
320    }
321
322    #[test]
323    fn rejects_legacy_catalog_items_resource_section() {
324        let yaml = r#"
325version: 1
326default_environment: dev
327environments:
328  dev:
329    api_endpoint: https://rest.fra-02.braze.eu
330    api_key_env: BRAZE_DEV_API_KEY
331resources:
332  catalog_items:
333    enabled: true
334"#;
335        let f = write_config(yaml);
336        let err = ConfigFile::load(f.path()).unwrap_err();
337        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
338    }
339
340    #[test]
341    fn rejects_legacy_defaults_rate_limit_per_minute() {
342        // v0.8.0: client-side rate limiter was removed. Leftover
343        // `rate_limit_per_minute` keys must hard-error so users notice
344        // they need to delete them, rather than silently ignoring.
345        let yaml = r#"
346version: 1
347default_environment: dev
348defaults:
349  rate_limit_per_minute: 40
350environments:
351  dev:
352    api_endpoint: https://rest.fra-02.braze.eu
353    api_key_env: BRAZE_DEV_API_KEY
354"#;
355        let f = write_config(yaml);
356        let err = ConfigFile::load(f.path()).unwrap_err();
357        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
358    }
359
360    #[test]
361    fn rejects_legacy_environment_rate_limit_per_minute() {
362        let yaml = r#"
363version: 1
364default_environment: dev
365environments:
366  dev:
367    api_endpoint: https://rest.fra-02.braze.eu
368    api_key_env: BRAZE_DEV_API_KEY
369    rate_limit_per_minute: 30
370"#;
371        let f = write_config(yaml);
372        let err = ConfigFile::load(f.path()).unwrap_err();
373        assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
374    }
375
376    #[test]
377    fn accepts_exclude_patterns_on_resource_config() {
378        let yaml = r#"
379version: 1
380default_environment: dev
381environments:
382  dev:
383    api_endpoint: https://rest.fra-02.braze.eu
384    api_key_env: BRAZE_DEV_API_KEY
385resources:
386  custom_attribute:
387    path: custom_attributes/registry.yaml
388    exclude_patterns:
389      - "^_"
390      - "^(hoge|hack)$"
391"#;
392        let f = write_config(yaml);
393        let cfg = ConfigFile::load(f.path()).unwrap();
394        assert_eq!(
395            cfg.resources.custom_attribute.exclude_patterns,
396            vec!["^_".to_string(), "^(hoge|hack)$".to_string()]
397        );
398    }
399
400    #[test]
401    fn rejects_invalid_exclude_pattern_at_load_time() {
402        // Unbalanced paren — invalid regex should hard-error at load,
403        // not at first use.
404        let yaml = r#"
405version: 1
406default_environment: dev
407environments:
408  dev:
409    api_endpoint: https://rest.fra-02.braze.eu
410    api_key_env: BRAZE_DEV_API_KEY
411resources:
412  custom_attribute:
413    path: custom_attributes/registry.yaml
414    exclude_patterns:
415      - "("
416"#;
417        let f = write_config(yaml);
418        let err = ConfigFile::load(f.path()).unwrap_err();
419        match err {
420            Error::Config(msg) => {
421                assert!(msg.contains("custom_attribute"), "msg: {msg}");
422                assert!(msg.contains("exclude_patterns[0]"), "msg: {msg}");
423            }
424            other => panic!("expected Config error, got {other:?}"),
425        }
426    }
427
428    #[test]
429    fn is_excluded_matches_any_pattern() {
430        let patterns =
431            compile_exclude_patterns(&["^_".to_string(), "^test_".to_string()], "test").unwrap();
432        assert!(is_excluded("_unset", &patterns));
433        assert!(is_excluded("test_foo", &patterns));
434        assert!(!is_excluded("regular_attr", &patterns));
435    }
436
437    #[test]
438    fn rejects_non_http_endpoint_scheme() {
439        let yaml = r#"
440version: 1
441default_environment: dev
442environments:
443  dev:
444    api_endpoint: ftp://rest.braze.eu
445    api_key_env: BRAZE_DEV_API_KEY
446"#;
447        let f = write_config(yaml);
448        let err = ConfigFile::load(f.path()).unwrap_err();
449        assert!(matches!(err, Error::Config(_)));
450        let msg = err.to_string();
451        assert!(msg.contains("http"), "expected http scheme hint: {msg}");
452        assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
453    }
454
455    #[test]
456    fn rejects_default_environment_not_in_map() {
457        let yaml = r#"
458version: 1
459default_environment: missing
460environments:
461  dev:
462    api_endpoint: https://rest.fra-02.braze.eu
463    api_key_env: BRAZE_DEV_API_KEY
464"#;
465        let f = write_config(yaml);
466        let err = ConfigFile::load(f.path()).unwrap_err();
467        assert!(matches!(err, Error::Config(_)));
468        assert!(err.to_string().contains("missing"));
469    }
470
471    #[test]
472    fn resolve_uses_default_environment_when_no_override() {
473        let f = write_config(MINIMAL);
474        let cfg = ConfigFile::load(f.path()).unwrap();
475        let resolved = cfg
476            .resolve_with(None, |k| {
477                assert_eq!(k, "BRAZE_DEV_API_KEY");
478                Some("token-abc".into())
479            })
480            .unwrap();
481        assert_eq!(resolved.environment_name, "dev");
482        assert_eq!(resolved.api_key.expose_secret(), "token-abc");
483    }
484
485    #[test]
486    fn resolve_uses_override_when_provided() {
487        const TWO_ENVS: &str = r#"
488version: 1
489default_environment: dev
490environments:
491  dev:
492    api_endpoint: https://rest.fra-02.braze.eu
493    api_key_env: BRAZE_DEV_API_KEY
494  prod:
495    api_endpoint: https://rest.fra-02.braze.eu
496    api_key_env: BRAZE_PROD_API_KEY
497"#;
498        let f = write_config(TWO_ENVS);
499        let cfg = ConfigFile::load(f.path()).unwrap();
500        let resolved = cfg
501            .resolve_with(Some("prod"), |k| {
502                assert_eq!(k, "BRAZE_PROD_API_KEY");
503                Some("prod-token".into())
504            })
505            .unwrap();
506        assert_eq!(resolved.environment_name, "prod");
507    }
508
509    #[test]
510    fn resolve_unknown_env_lists_known_envs() {
511        let f = write_config(MINIMAL);
512        let cfg = ConfigFile::load(f.path()).unwrap();
513        let err = cfg
514            .resolve_with(Some("staging"), |_| Some("x".into()))
515            .unwrap_err();
516        let msg = err.to_string();
517        assert!(msg.contains("staging"));
518        assert!(msg.contains("dev"));
519    }
520
521    #[test]
522    fn resolve_missing_env_var_is_typed_error() {
523        let f = write_config(MINIMAL);
524        let cfg = ConfigFile::load(f.path()).unwrap();
525        let err = cfg.resolve_with(None, |_| None).unwrap_err();
526        match err {
527            Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
528            other => panic!("expected MissingEnv, got {other:?}"),
529        }
530    }
531
532    #[test]
533    fn resolve_empty_env_var_is_rejected() {
534        let f = write_config(MINIMAL);
535        let cfg = ConfigFile::load(f.path()).unwrap();
536        let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
537        assert!(matches!(err, Error::Config(_)));
538        assert!(err.to_string().contains("empty"));
539    }
540
541    #[test]
542    fn debug_format_does_not_leak_api_key() {
543        let f = write_config(MINIMAL);
544        let resolved = ConfigFile::load(f.path())
545            .unwrap()
546            .resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
547            .unwrap();
548        let dbg = format!("{resolved:?}");
549        assert!(
550            !dbg.contains("super-secret-token-abc-123"),
551            "Debug output leaked api key: {dbg}"
552        );
553    }
554
555    #[test]
556    fn rejects_empty_api_key_env() {
557        let yaml = r#"
558version: 1
559default_environment: dev
560environments:
561  dev:
562    api_endpoint: https://rest.fra-02.braze.eu
563    api_key_env: ""
564"#;
565        let f = write_config(yaml);
566        let err = ConfigFile::load(f.path()).unwrap_err();
567        assert!(matches!(err, Error::Config(_)), "got: {err:?}");
568        assert!(err.to_string().contains("api_key_env"));
569    }
570
571    #[test]
572    fn load_io_error_for_missing_file() {
573        let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
574        assert!(matches!(err, Error::Io(_)), "got: {err:?}");
575    }
576}