ch-config 0.1.0

Typed configuration loading with validation and secret-file support
Documentation
//! Process-environment and dotenv-file loading for application startup.

use std::net::SocketAddr;
use std::path::PathBuf;

use crate::config::{ConfigError, Result};

/// Resolves a trimmed environment value or its mutually exclusive `_FILE`
/// companion.
///
/// Empty values, non-Unicode data, conflicting sources, and unreadable secret
/// files are reported without retaining the configured value in the error.
pub fn value(variable: &'static str) -> Result<Option<String>> {
    crate::config::environment_value(variable)
}

/// Resolves an optional boolean environment value.
pub fn flag(variable: &'static str) -> Result<Option<bool>> {
    value(variable)?
        .map(|value| match value.to_ascii_lowercase().as_str() {
            "1" | "true" | "yes" | "on" => Ok(true),
            "0" | "false" | "no" | "off" => Ok(false),
            _ => Err(ConfigError::VariableValue {
                variable,
                message: "must be a boolean value",
            }),
        })
        .transpose()
}

/// Resolves and validates the conventional `PUBLIC_URL` value.
///
/// The URL must use HTTP or HTTPS, include a host, omit credentials, query,
/// and fragment components, and is normalized without a trailing slash.
pub fn public_url() -> Result<Option<url::Url>> {
    value("PUBLIC_URL")?.map(parse_public_url).transpose()
}

/// Resolves `PUBLIC_URL` or returns a loopback URL using the listener port.
pub fn public_url_or_local(bind_addr: SocketAddr) -> Result<url::Url> {
    public_url()?.map_or_else(
        || {
            url::Url::parse(&format!("http://127.0.0.1:{}", bind_addr.port())).map_err(|_| {
                ConfigError::validation("public_url", "the generated local HTTP URL must be valid")
            })
        },
        Ok,
    )
}

fn parse_public_url(value: String) -> Result<url::Url> {
    let mut url = crate::config::parse_network_url(
        "public_url",
        &value,
        &["http", "https"],
        false,
        "must be an HTTP or HTTPS URL with a host and no embedded credentials",
    )?;
    if url.query().is_some() || url.fragment().is_some() {
        return Err(ConfigError::validation(
            "public_url",
            "must not contain a query or fragment",
        ));
    }

    let normalized_path = url.path().trim_end_matches('/').to_owned();
    url.set_path(&normalized_path);
    Ok(url)
}

/// Environment files selected by the application.
///
/// Missing files are ignored so production deployments can use process-level
/// variables exclusively. The optional override is loaded second and replaces
/// values from both the base file and the existing process environment.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EnvFiles {
    /// Base dotenv file.
    pub base: Option<PathBuf>,
    /// Local or deployment-specific override file.
    pub override_file: Option<PathBuf>,
}

impl EnvFiles {
    /// Loads the explicitly selected files.
    ///
    /// Call this during single-threaded application startup because process
    /// environment mutation is global to the process.
    pub fn load(&self) -> Result<EnvLoad> {
        let mut loaded = EnvLoad::default();

        if let Some(path) = &self.base
            && path.is_file()
        {
            dotenvy::from_path(path)
                .map_err(|error| ConfigError::environment(path.clone(), error))?;
            loaded.base = true;
        }

        if let Some(path) = &self.override_file
            && path.is_file()
        {
            dotenvy::from_path_override(path)
                .map_err(|error| ConfigError::environment(path.clone(), error))?;
            loaded.override_file = true;
        }

        Ok(loaded)
    }
}

/// Reports which explicitly selected dotenv files existed and were loaded.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EnvLoad {
    /// Whether the base file was loaded.
    pub base: bool,
    /// Whether the override file was loaded.
    pub override_file: bool,
}

impl EnvLoad {
    /// Returns whether at least one file was loaded.
    pub const fn any(self) -> bool {
        self.base || self.override_file
    }
}

#[cfg(test)]
mod tests {
    use std::error::Error as _;
    use std::fs;
    use std::sync::atomic::{AtomicU64, Ordering};

    use super::*;
    use crate::EnvironmentFileError;

    static FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);

    #[test]
    fn absent_explicit_files_are_ignored() {
        let files = EnvFiles {
            base: Some(std::env::temp_dir().join("missing-base.env")),
            override_file: Some(std::env::temp_dir().join("missing-override.env")),
        };
        assert_eq!(
            files.load().expect("missing files should be optional"),
            EnvLoad::default()
        );
    }

    #[test]
    fn load_report_combines_results() {
        assert!(!EnvLoad::default().any());
        assert!(
            EnvLoad {
                base: true,
                override_file: false,
            }
            .any()
        );
    }

    #[test]
    fn public_urls_are_validated_and_normalized() {
        let url = parse_public_url("https://example.invalid/app/".to_owned())
            .expect("valid public URL should resolve");
        assert_eq!(url.as_str(), "https://example.invalid/app");

        for invalid in [
            "ftp://example.invalid",
            "https://user:secret@example.invalid",
            "https://example.invalid?debug=true",
            "https://example.invalid#fragment",
        ] {
            assert!(
                parse_public_url(invalid.to_owned()).is_err(),
                "{invalid} should be rejected"
            );
        }
    }

    #[test]
    fn malformed_file_reports_a_sanitized_typed_source() {
        let path = std::env::temp_dir().join(format!(
            "explicit-env-{}-{}.env",
            std::process::id(),
            FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
        ));
        fs::write(&path, "API_TOKEN='do-not-log-this\n")
            .expect("invalid dotenv fixture should be writable");

        let error = EnvFiles {
            base: Some(path.clone()),
            override_file: None,
        }
        .load()
        .expect_err("malformed dotenv should fail");
        fs::remove_file(path).ok();

        let display = error.to_string();
        let debug = format!("{error:?}");
        assert!(!display.contains("do-not-log-this"));
        assert!(!debug.contains("do-not-log-this"));
        assert!(matches!(
            error
                .source()
                .and_then(|source| source.downcast_ref::<EnvironmentFileError>()),
            Some(EnvironmentFileError::Syntax { .. })
        ));
    }
}