ch-config 0.2.0

Typed configuration loading with validation and secret-file support
Documentation
use super::Result;
use super::{CaptchaConfig, EmailConfig, MariadbConfig, MediaLibraryConfig, SpacetimeDbConfig};

/// Typed application configuration assembled from independent sections.
#[derive(Clone, Debug, Default)]
pub struct AppConfig {
    /// Optional MariaDB connection configuration.
    pub mariadb: MariadbConfig,
    /// Optional SpacetimeDB endpoint configuration.
    pub spacetimedb: SpacetimeDbConfig,
    /// Optional durable media storage configuration.
    pub media_library: MediaLibraryConfig,
    /// Optional captcha policy.
    pub captcha: CaptchaConfig,
    /// Optional email delivery configuration.
    pub email: EmailConfig,
}

impl AppConfig {
    /// Resolves deployment-specific environment values on top of application
    /// defaults and validates the resulting configuration.
    pub fn from_env(mut defaults: Self) -> Result<Self> {
        defaults.apply_environment(&mut crate::environment::value)?;
        defaults.validate()?;
        Ok(defaults)
    }

    fn apply_environment(
        &mut self,
        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
    ) -> Result<()> {
        self.mariadb.apply_environment(value)?;
        self.spacetimedb.apply_environment(value)?;
        self.media_library.apply_environment(value)?;
        self.captcha.apply_environment(value)?;
        self.email.apply_environment(value)?;
        Ok(())
    }

    /// Validates all configured sections without performing I/O.
    pub fn validate(&self) -> Result<()> {
        self.mariadb.validate()?;
        self.spacetimedb.validate()?;
        self.media_library.validate()?;
        self.captcha.validate()?;
        self.email.validate()?;
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;

    #[test]
    fn empty_defaults_are_valid() {
        AppConfig::default()
            .validate()
            .expect("an application may opt into only the sections it uses");
    }

    #[test]
    fn environment_values_override_only_supplied_defaults() {
        let mut values = HashMap::from([
            ("DATABASE_URL", "mysql://database.invalid/app".to_string()),
            ("CAPTCHA_BYPASS", "true".to_string()),
        ]);
        let mut config = AppConfig {
            captcha: CaptchaConfig::standard(),
            ..AppConfig::default()
        };

        config
            .apply_environment(&mut |name| Ok(values.remove(name)))
            .expect("environment values should resolve");
        config
            .validate()
            .expect("resolved defaults should validate");

        assert_eq!(
            config.mariadb.connection_url().expect("database URL"),
            "mysql://database.invalid/app"
        );
        assert!(config.captcha.bypass);
    }

    #[test]
    fn public_parsers_return_sanitized_concrete_errors() {
        use super::super::{EmailSmtpSecurity, EmailTransport, MediaLibraryBackend};

        fn concrete_error<T: std::error::Error + Send + Sync + 'static>(_: &T) {}
        for error in [
            "private-rejected-value"
                .parse::<EmailTransport>()
                .unwrap_err(),
            "private-rejected-value"
                .parse::<EmailSmtpSecurity>()
                .unwrap_err(),
            "private-rejected-value"
                .parse::<MediaLibraryBackend>()
                .unwrap_err(),
        ] {
            concrete_error(&error);
            assert!(!error.to_string().contains("private-rejected-value"));
            assert!(!format!("{error:?}").contains("private-rejected-value"));
        }
    }

    #[test]
    fn debug_output_redacts_credentials() {
        let config = AppConfig {
            mariadb: MariadbConfig::new("mysql://user:password@database.invalid/schema"),
            email: EmailConfig::smtp(
                "sender@example.invalid",
                "Example",
                "mail.example.invalid",
                2525,
                super::super::EmailSmtpSecurity::StartTls,
            ),
            ..AppConfig::default()
        };
        let mut config = config;
        config.email.smtp.username = Some("account".to_string());
        config.email.smtp.password = Some("secret".to_string());

        config.spacetimedb =
            SpacetimeDbConfig::new("https://database.invalid?token=spacetime-secret");
        config.media_library.object_root = "objects".into();
        config.media_library.s3.bucket = "media".into();
        config.media_library.s3.endpoint =
            Some("https://storage.invalid?token=storage-secret".into());
        config.email.mailersend.api_url = "https://mail.invalid?token=mail-secret".into();
        config
            .validate()
            .expect("query parameters are valid configuration");

        let debug = format!("{config:?}");
        assert!(!debug.contains("user:password"));
        assert!(!debug.contains("secret"));
        assert!(!debug.contains("account"));
        assert!(!debug.contains("spacetime-secret"));
        assert!(!debug.contains("storage-secret"));
        assert!(!debug.contains("mail-secret"));
        assert!(debug.contains("[REDACTED]"));
    }
}