coil-config 0.1.0

Configuration models and loaders for the Coil framework.
Documentation
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;

use serde::{Deserialize, Serialize};
use url::Url;

use crate::{
    AppConfig, AssetsConfig, CacheConfig, ConfigValidationErrors, DatabaseConfig, HttpConfig,
    JobsConfig, ObservabilityConfig, SecretRef, ServerConfig, StorageConfig, TlsConfig,
};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PlatformConfig {
    pub app: AppConfig,
    pub server: ServerConfig,
    pub http: HttpConfig,
    pub tls: TlsConfig,
    #[serde(default)]
    pub database: DatabaseConfig,
    pub storage: StorageConfig,
    pub cache: CacheConfig,
    #[serde(default)]
    pub i18n: I18nConfig,
    #[serde(default)]
    pub seo: SeoConfig,
    #[serde(default)]
    pub sites: Vec<SiteConfig>,
    pub auth: AuthConfig,
    pub modules: ModulesConfig,
    pub wasm: WasmConfig,
    pub jobs: JobsConfig,
    pub observability: ObservabilityConfig,
    pub assets: AssetsConfig,
}

impl PlatformConfig {
    pub fn from_toml_str(input: &str) -> Result<Self, ConfigError> {
        Self::from_toml_str_with_overlays(input, std::iter::empty::<&str>())
    }

    pub fn from_toml_str_with_overlays<'a>(
        input: &str,
        overlays: impl IntoIterator<Item = &'a str>,
    ) -> Result<Self, ConfigError> {
        let mut merged: toml::Value = toml::from_str(input)?;

        for overlay in overlays {
            let overlay_value: toml::Value = toml::from_str(overlay)?;
            merge_toml_value(&mut merged, overlay_value);
        }

        let config: Self = merged.try_into()?;
        config.validate()?;
        Ok(config)
    }

    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
        let contents = fs::read_to_string(path).map_err(ConfigError::Io)?;
        Self::from_toml_str(&contents)
    }

    pub fn from_toml_str_with_env_overlays(
        input: &str,
        env_vars: &[&str],
    ) -> Result<Self, ConfigError> {
        let overlays = env_vars
            .iter()
            .filter_map(|var| env::var(var).ok())
            .collect::<Vec<_>>();

        Self::from_toml_str_with_overlays(input, overlays.iter().map(String::as_str))
    }

    pub fn render_effective_toml(&self) -> Result<String, toml::ser::Error> {
        toml::to_string_pretty(self)
    }

    pub fn site_for_host(&self, host: &str) -> Option<&SiteConfig> {
        let host = normalize_host(host);
        self.sites.iter().find(|site| {
            normalize_host(&site.canonical_host) == host
                || site.hosts.iter().any(|value| normalize_host(value) == host)
        })
    }

    pub fn site_for_id(&self, site_id: &str) -> Option<&SiteConfig> {
        self.sites.iter().find(|site| site.id == site_id)
    }

    pub fn default_site(&self) -> Option<&SiteConfig> {
        self.sites.first()
    }

    pub fn canonical_host_for_site(&self, site_id: Option<&str>) -> &str {
        site_id
            .and_then(|site_id| self.site_for_id(site_id))
            .or_else(|| self.default_site())
            .map(|site| site.canonical_host.as_str())
            .unwrap_or(self.seo.canonical_host.as_str())
    }

    pub fn default_locale_for_site(&self, site_id: Option<&str>) -> &str {
        site_id
            .and_then(|site_id| self.site_for_id(site_id))
            .or_else(|| self.default_site())
            .map(|site| site.default_locale.as_str())
            .unwrap_or(self.i18n.default_locale.as_str())
    }

    pub fn supported_locales_for_site(&self, site_id: Option<&str>) -> &[String] {
        site_id
            .and_then(|site_id| self.site_for_id(site_id))
            .or_else(|| self.default_site())
            .map(|site| site.supported_locales.as_slice())
            .unwrap_or(self.i18n.supported_locales.as_slice())
    }

    pub fn localized_routes_for_site(&self, site_id: Option<&str>) -> bool {
        site_id
            .and_then(|site_id| self.site_for_id(site_id))
            .or_else(|| self.default_site())
            .and_then(|site| site.localized_routes)
            .unwrap_or(self.i18n.localized_routes)
    }
}

fn normalize_host(host: &str) -> &str {
    if let Some(stripped) = host.strip_prefix('[') {
        if let Some(end) = stripped.find(']') {
            return &stripped[..end];
        }
    }

    match host.rsplit_once(':') {
        Some((candidate, port))
            if !candidate.contains(':')
                && !candidate.is_empty()
                && port.chars().all(|ch| ch.is_ascii_digit()) =>
        {
            candidate
        }
        _ => host,
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct I18nConfig {
    #[serde(default)]
    pub default_locale: String,
    #[serde(default)]
    pub supported_locales: Vec<String>,
    #[serde(default)]
    pub fallback_locale: String,
    #[serde(default)]
    pub localized_routes: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SeoConfig {
    pub canonical_host: String,
    pub emit_json_ld: bool,
    #[serde(default = "default_sitemap_enabled")]
    pub sitemap_enabled: bool,
}

impl Default for SeoConfig {
    fn default() -> Self {
        Self {
            canonical_host: String::new(),
            emit_json_ld: false,
            sitemap_enabled: default_sitemap_enabled(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SiteConfig {
    pub id: String,
    pub display_name: String,
    #[serde(default)]
    pub brand_name: Option<String>,
    pub canonical_host: String,
    #[serde(default)]
    pub hosts: Vec<String>,
    pub default_locale: String,
    pub supported_locales: Vec<String>,
    #[serde(default)]
    pub localized_routes: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AuthConfig {
    pub package: String,
    pub explain_api: bool,
    pub tenant_id: i64,
    #[serde(default)]
    pub tuple_store_secret: Option<crate::SecretRef>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModulesConfig {
    pub enabled: Vec<String>,
    #[serde(flatten, default)]
    pub settings: toml::Table,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WasmConfig {
    pub directory: String,
    pub default_time_limit_ms: u64,
    pub allow_network: bool,
    #[serde(default)]
    pub secret_bindings: BTreeMap<String, SecretRef>,
    #[serde(default)]
    pub outbound_http: Vec<WasmOutboundHttpIntegration>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WasmOutboundHttpIntegration {
    pub integration: String,
    pub endpoint: Url,
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("failed to read config file: {0}")]
    Io(std::io::Error),
    #[error("failed to parse config: {0}")]
    Parse(#[from] toml::de::Error),
    #[error(transparent)]
    Validation(#[from] ConfigValidationErrors),
}

fn default_sitemap_enabled() -> bool {
    true
}

fn merge_toml_value(base: &mut toml::Value, overlay: toml::Value) {
    match (base, overlay) {
        (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
            for (key, value) in overlay_table {
                match base_table.get_mut(&key) {
                    Some(existing) => merge_toml_value(existing, value),
                    None => {
                        base_table.insert(key, value);
                    }
                }
            }
        }
        (base_value, overlay_value) => {
            *base_value = overlay_value;
        }
    }
}