ch-config 0.2.0

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

use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::OnceLock;

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>> {
    let direct = read_variable(variable)?;
    let file = read_variable(&format!("{variable}_FILE"))?;
    let value = match (direct, file) {
        (Some(_), Some(_)) => return Err(ConfigError::VariableConflict { variable }),
        (Some(value), None) => Some(value),
        (None, Some(path)) => Some(
            fs::read_to_string(path)
                .map_err(|source| ConfigError::VariableFile { variable, source })?,
        ),
        (None, None) => None,
    };
    value
        .map(|value| {
            let trimmed = value.trim();
            if trimmed.is_empty() {
                Err(ConfigError::VariableValue {
                    variable,
                    message: "must not be empty",
                })
            } else if trimmed.len() == value.len() {
                Ok(value)
            } else {
                Ok(trimmed.to_owned())
            }
        })
        .transpose()
}

// One immutable configuration source per application process. Parsing finishes
// before installation, so concurrent readers never see a partially loaded file.
static CONFIGURATION: OnceLock<LoadedFiles> = OnceLock::new();

struct LoadedFiles {
    files: EnvFiles,
    report: EnvLoad,
    values: HashMap<OsString, OsString>,
}

/// Reads a raw value from the startup snapshot or the process environment.
/// Unlike [`value`], this preserves whitespace and does not read `_FILE` companions.
/// Errors never contain the configured value.
pub fn var(variable: &str) -> Result<String> {
    var_os(variable)
        .ok_or_else(|| ConfigError::VariableMissing {
            variable: variable.to_owned(),
        })?
        .into_string()
        .map_err(|_| ConfigError::VariableUnicode {
            variable: variable.to_owned(),
        })
}

/// Reads a raw OS value from the same source as [`var`].
pub fn var_os(variable: impl AsRef<OsStr>) -> Option<OsString> {
    match CONFIGURATION.get() {
        Some(configuration) => configuration
            .values
            .get(&environment_key(variable.as_ref()))
            .cloned(),
        None => std::env::var_os(variable),
    }
}

/// Returns the resolved startup environment for a child process.
/// Pass these bindings to `std::process::Command::envs`.
pub fn vars_os() -> impl Iterator<Item = (OsString, OsString)> {
    match CONFIGURATION.get() {
        Some(configuration) => configuration.values.clone(),
        None => std::env::vars_os().collect(),
    }
    .into_iter()
}

fn environment_key(key: &OsStr) -> OsString {
    #[cfg(windows)]
    if let Some(key) = key.to_str() {
        return OsString::from(key.to_ascii_uppercase());
    }
    key.to_owned()
}

fn read_variable(variable: &str) -> Result<Option<String>> {
    match var(variable) {
        Ok(value) => Ok(Some(value)),
        Err(ConfigError::VariableMissing { .. }) => Ok(None),
        Err(error) => Err(error),
    }
}

/// 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 replaces base and process values
/// in an immutable configuration snapshot. The OS process environment is untouched.
#[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 {
    /// Atomically installs one immutable startup configuration.
    ///
    /// Read it through this module's accessors and pass [`vars_os`] to child
    /// processes. Repeating the same selection returns the existing report.
    /// Selecting different files after successful initialization returns an error.
    /// A failed load leaves the resolver and the process environment unchanged.
    pub fn load(&self) -> Result<EnvLoad> {
        if let Some(configuration) = CONFIGURATION.get() {
            return if configuration.files == *self {
                Ok(configuration.report)
            } else {
                Err(ConfigError::EnvironmentAlreadyLoaded)
            };
        }
        let mut values = std::env::vars_os()
            .map(|(key, value)| (environment_key(&key), value))
            .collect::<HashMap<_, _>>();
        let mut report = EnvLoad::default();
        for (path, is_override) in [(&self.base, false), (&self.override_file, true)] {
            let Some(path) = path else { continue };
            let source = match fs::read_to_string(path) {
                Ok(source) => source,
                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
                Err(source) => {
                    return Err(ConfigError::Environment {
                        path: path.clone(),
                        source: crate::EnvironmentFileError::Io(source),
                    });
                }
            };
            let mut loader = dotenv::EnvLoader::with_reader(source.as_bytes()).substitution(true);
            if is_override {
                loader = loader
                    .sequence(dotenv::EnvSequence::InputOnly)
                    .substitutions(values.iter().filter_map(|(key, value)| {
                        Some((key.to_str()?.to_owned(), value.to_str()?.to_owned()))
                    }));
            } else {
                loader = loader.sequence(dotenv::EnvSequence::InputThenEnv);
            }
            let parsed = loader
                .load()
                .map_err(|error| ConfigError::environment(path.clone(), error))?;
            values.extend(
                parsed
                    .into_iter()
                    .map(|(key, value)| (environment_key(OsStr::new(&key)), value.into())),
            );
            if is_override {
                report.override_file = true
            } else {
                report.base = true
            }
        }
        let configuration = CONFIGURATION.get_or_init(|| LoadedFiles {
            files: self.clone(),
            report,
            values,
        });
        if configuration.files == *self {
            Ok(configuration.report)
        } else {
            Err(ConfigError::EnvironmentAlreadyLoaded)
        }
    }
}

/// 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 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 { .. })
        ));
    }
}