ch-config 0.1.1

Typed configuration loading with validation and secret-file support
Documentation
use std::fmt;

use super::{CaptchaConfig, EmailConfig, MariadbConfig, MediaLibraryConfig, SpacetimeDbConfig};
use super::{Result, variables};

/// Typed application configuration assembled from independent sections.
#[derive(Clone, 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 fmt::Debug for AppConfig {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut output = formatter.debug_struct("AppConfig");
        output
            .field("mariadb", &self.mariadb)
            .field("spacetimedb", &self.spacetimedb)
            .field("media_library", &self.media_library)
            .field("captcha", &self.captcha)
            .field("email", &self.email);
        output.finish()
    }
}

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 variables::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 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());

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