pub mod schema;
pub use schema::{
ConfigFile, Defaults, EnvironmentConfig, NamingConfig, ResourceConfig, ResourcesConfig,
};
use crate::error::{Error, Result};
use crate::resource::ResourceKind;
use regex_lite::Regex;
use secrecy::SecretString;
use std::collections::HashMap;
use std::path::Path;
use url::Url;
#[derive(Debug)]
pub struct ResolvedConfig {
pub environment_name: String,
pub api_endpoint: Url,
pub api_key: SecretString,
pub resources: ResourcesConfig,
pub naming: NamingConfig,
pub excludes: HashMap<ResourceKind, Vec<Regex>>,
}
impl ResolvedConfig {
pub fn excludes_for(&self, kind: ResourceKind) -> &[Regex] {
self.excludes.get(&kind).map(Vec::as_slice).unwrap_or(&[])
}
}
impl ConfigFile {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let bytes = std::fs::read_to_string(path)?;
let cfg: ConfigFile =
serde_norway::from_str(&bytes).map_err(|source| Error::YamlParse {
path: path.to_path_buf(),
source,
})?;
cfg.validate_static()?;
Ok(cfg)
}
fn validate_static(&self) -> Result<()> {
if self.version != 1 {
return Err(Error::Config(format!(
"unsupported config version {} (this binary supports version 1; \
see IMPLEMENTATION.md §2.5 for the forward-compat policy)",
self.version
)));
}
if !self.environments.contains_key(&self.default_environment) {
return Err(Error::Config(format!(
"default_environment '{}' is not declared in the environments map",
self.default_environment
)));
}
for (name, env) in &self.environments {
if env.api_key_env.trim().is_empty() {
return Err(Error::Config(format!(
"environment '{name}': api_key_env must not be empty"
)));
}
match env.api_endpoint.scheme() {
"http" | "https" => {}
scheme => {
return Err(Error::Config(format!(
"environment '{name}': api_endpoint must use http or https \
(got '{scheme}')"
)));
}
}
}
for kind in ResourceKind::all() {
let rc = self.resources.for_kind(*kind);
compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?;
}
Ok(())
}
pub fn resolve(self, env_override: Option<&str>) -> Result<ResolvedConfig> {
self.resolve_with(env_override, |k| std::env::var(k).ok())
}
pub fn resolve_with(
mut self,
env_override: Option<&str>,
env_lookup: impl Fn(&str) -> Option<String>,
) -> Result<ResolvedConfig> {
let env_name = env_override
.map(str::to_string)
.unwrap_or_else(|| self.default_environment.clone());
if !self.environments.contains_key(&env_name) {
let known: Vec<&str> = self.environments.keys().map(String::as_str).collect();
return Err(Error::Config(format!(
"unknown environment '{}'; declared: [{}]",
env_name,
known.join(", ")
)));
}
let env_cfg = self
.environments
.remove(&env_name)
.expect("presence checked immediately above");
let api_key_str = env_lookup(&env_cfg.api_key_env)
.ok_or_else(|| Error::MissingEnv(env_cfg.api_key_env.clone()))?;
if api_key_str.is_empty() {
return Err(Error::Config(format!(
"environment variable '{}' is set but empty",
env_cfg.api_key_env
)));
}
let mut excludes: HashMap<ResourceKind, Vec<Regex>> = HashMap::new();
for kind in ResourceKind::all() {
let rc = self.resources.for_kind(*kind);
excludes.insert(
*kind,
compile_exclude_patterns(&rc.exclude_patterns, kind.as_str())?,
);
}
Ok(ResolvedConfig {
environment_name: env_name,
api_endpoint: env_cfg.api_endpoint,
api_key: SecretString::from(api_key_str),
resources: self.resources,
naming: self.naming,
excludes,
})
}
}
pub fn compile_exclude_patterns(patterns: &[String], context: &str) -> Result<Vec<Regex>> {
patterns
.iter()
.enumerate()
.map(|(i, p)| {
Regex::new(p).map_err(|e| {
Error::Config(format!(
"{context}.exclude_patterns[{i}]: invalid regex {p:?}: {e}"
))
})
})
.collect()
}
pub fn is_excluded(name: &str, patterns: &[Regex]) -> bool {
patterns.iter().any(|r| r.is_match(name))
}
pub fn load_dotenv() -> Result<()> {
match dotenvy::from_path(".env") {
Ok(()) => Ok(()),
Err(e) if e.not_found() => Ok(()),
Err(e) => Err(Error::Config(format!(".env load error: {e}"))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use std::io::Write;
fn write_config(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
const MINIMAL: &str = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
"#;
#[test]
fn loads_minimal_config_with_all_defaults() {
let f = write_config(MINIMAL);
let cfg = ConfigFile::load(f.path()).unwrap();
assert_eq!(cfg.version, 1);
assert_eq!(cfg.default_environment, "dev");
assert_eq!(cfg.environments.len(), 1);
assert!(cfg.resources.catalog_schema.enabled);
assert_eq!(
cfg.resources.catalog_schema.path,
std::path::PathBuf::from("catalogs/")
);
assert_eq!(
cfg.resources.custom_attribute.path,
std::path::PathBuf::from("custom_attributes/registry.yaml")
);
}
#[test]
fn loads_full_config_from_section_10() {
const FULL: &str = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
prod:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_PROD_API_KEY
resources:
catalog_schema:
enabled: true
path: catalogs/
content_block:
enabled: true
path: content_blocks/
email_template:
enabled: false
path: email_templates/
custom_attribute:
enabled: true
path: custom_attributes/registry.yaml
naming:
catalog_name_pattern: "^[a-z][a-z0-9_]*$"
"#;
let f = write_config(FULL);
let cfg = ConfigFile::load(f.path()).unwrap();
assert_eq!(cfg.environments.len(), 2);
assert!(!cfg.resources.email_template.enabled);
assert_eq!(
cfg.naming.catalog_name_pattern.as_deref(),
Some("^[a-z][a-z0-9_]*$")
);
}
#[test]
fn rejects_wrong_version() {
let yaml = r#"
version: 2
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("version 2"));
}
#[test]
fn rejects_unknown_top_level_field() {
let yaml = r#"
version: 1
default_environment: dev
mystery_key: 1
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
}
#[test]
fn rejects_legacy_catalog_items_resource_section() {
let yaml = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
resources:
catalog_items:
enabled: true
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
}
#[test]
fn rejects_legacy_defaults_rate_limit_per_minute() {
let yaml = r#"
version: 1
default_environment: dev
defaults:
rate_limit_per_minute: 40
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
}
#[test]
fn rejects_legacy_environment_rate_limit_per_minute() {
let yaml = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
rate_limit_per_minute: 30
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::YamlParse { .. }), "got: {err:?}");
}
#[test]
fn accepts_exclude_patterns_on_resource_config() {
let yaml = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
resources:
custom_attribute:
path: custom_attributes/registry.yaml
exclude_patterns:
- "^_"
- "^(hoge|hack)$"
"#;
let f = write_config(yaml);
let cfg = ConfigFile::load(f.path()).unwrap();
assert_eq!(
cfg.resources.custom_attribute.exclude_patterns,
vec!["^_".to_string(), "^(hoge|hack)$".to_string()]
);
}
#[test]
fn rejects_invalid_exclude_pattern_at_load_time() {
let yaml = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
resources:
custom_attribute:
path: custom_attributes/registry.yaml
exclude_patterns:
- "("
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
match err {
Error::Config(msg) => {
assert!(msg.contains("custom_attribute"), "msg: {msg}");
assert!(msg.contains("exclude_patterns[0]"), "msg: {msg}");
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn is_excluded_matches_any_pattern() {
let patterns =
compile_exclude_patterns(&["^_".to_string(), "^test_".to_string()], "test").unwrap();
assert!(is_excluded("_unset", &patterns));
assert!(is_excluded("test_foo", &patterns));
assert!(!is_excluded("regular_attr", &patterns));
}
#[test]
fn rejects_non_http_endpoint_scheme() {
let yaml = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: ftp://rest.braze.eu
api_key_env: BRAZE_DEV_API_KEY
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::Config(_)));
let msg = err.to_string();
assert!(msg.contains("http"), "expected http scheme hint: {msg}");
assert!(msg.contains("ftp"), "expected actual scheme: {msg}");
}
#[test]
fn rejects_default_environment_not_in_map() {
let yaml = r#"
version: 1
default_environment: missing
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("missing"));
}
#[test]
fn resolve_uses_default_environment_when_no_override() {
let f = write_config(MINIMAL);
let cfg = ConfigFile::load(f.path()).unwrap();
let resolved = cfg
.resolve_with(None, |k| {
assert_eq!(k, "BRAZE_DEV_API_KEY");
Some("token-abc".into())
})
.unwrap();
assert_eq!(resolved.environment_name, "dev");
assert_eq!(resolved.api_key.expose_secret(), "token-abc");
}
#[test]
fn resolve_uses_override_when_provided() {
const TWO_ENVS: &str = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_DEV_API_KEY
prod:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: BRAZE_PROD_API_KEY
"#;
let f = write_config(TWO_ENVS);
let cfg = ConfigFile::load(f.path()).unwrap();
let resolved = cfg
.resolve_with(Some("prod"), |k| {
assert_eq!(k, "BRAZE_PROD_API_KEY");
Some("prod-token".into())
})
.unwrap();
assert_eq!(resolved.environment_name, "prod");
}
#[test]
fn resolve_unknown_env_lists_known_envs() {
let f = write_config(MINIMAL);
let cfg = ConfigFile::load(f.path()).unwrap();
let err = cfg
.resolve_with(Some("staging"), |_| Some("x".into()))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("staging"));
assert!(msg.contains("dev"));
}
#[test]
fn resolve_missing_env_var_is_typed_error() {
let f = write_config(MINIMAL);
let cfg = ConfigFile::load(f.path()).unwrap();
let err = cfg.resolve_with(None, |_| None).unwrap_err();
match err {
Error::MissingEnv(name) => assert_eq!(name, "BRAZE_DEV_API_KEY"),
other => panic!("expected MissingEnv, got {other:?}"),
}
}
#[test]
fn resolve_empty_env_var_is_rejected() {
let f = write_config(MINIMAL);
let cfg = ConfigFile::load(f.path()).unwrap();
let err = cfg.resolve_with(None, |_| Some(String::new())).unwrap_err();
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("empty"));
}
#[test]
fn debug_format_does_not_leak_api_key() {
let f = write_config(MINIMAL);
let resolved = ConfigFile::load(f.path())
.unwrap()
.resolve_with(None, |_| Some("super-secret-token-abc-123".into()))
.unwrap();
let dbg = format!("{resolved:?}");
assert!(
!dbg.contains("super-secret-token-abc-123"),
"Debug output leaked api key: {dbg}"
);
}
#[test]
fn rejects_empty_api_key_env() {
let yaml = r#"
version: 1
default_environment: dev
environments:
dev:
api_endpoint: https://rest.fra-02.braze.eu
api_key_env: ""
"#;
let f = write_config(yaml);
let err = ConfigFile::load(f.path()).unwrap_err();
assert!(matches!(err, Error::Config(_)), "got: {err:?}");
assert!(err.to_string().contains("api_key_env"));
}
#[test]
fn load_io_error_for_missing_file() {
let err = ConfigFile::load("/nonexistent/braze-sync.config.yaml").unwrap_err();
assert!(matches!(err, Error::Io(_)), "got: {err:?}");
}
}