ch-config 0.1.0

Typed configuration loading with validation and secret-file support
Documentation
use std::env;
use std::fs;
use std::str::FromStr;

use super::{ConfigError, Result};

pub(crate) fn value(variable: &'static str) -> Result<Option<String>> {
    let direct = read_variable(variable)?;
    let file_variable = format!("{variable}_FILE");
    let file = read_variable(&file_variable)?;

    if direct.is_some() && file.is_some() {
        return Err(ConfigError::VariableConflict { variable });
    }

    let resolved = match (direct, file) {
        (Some(value), None) => Some(value),
        (None, Some(path)) => Some(
            fs::read_to_string(path)
                .map_err(|source| ConfigError::VariableFile { variable, source })?,
        ),
        (None, None) => None,
        (Some(_), Some(_)) => unreachable!("conflicting sources were rejected"),
    };

    resolved
        .map(|value| {
            let value = value.trim();
            if value.is_empty() {
                Err(ConfigError::VariableValue {
                    variable,
                    message: "must not be empty",
                })
            } else {
                Ok(value.to_owned())
            }
        })
        .transpose()
}

pub(crate) fn parsed<T>(
    variable: &'static str,
    value: Option<String>,
    expected: &'static str,
) -> Result<Option<T>>
where
    T: FromStr,
{
    value
        .map(|value| {
            value.parse::<T>().map_err(|_| ConfigError::VariableValue {
                variable,
                message: expected,
            })
        })
        .transpose()
}

fn read_variable(variable: &str) -> Result<Option<String>> {
    match env::var(variable) {
        Ok(value) => Ok(Some(value)),
        Err(env::VarError::NotPresent) => Ok(None),
        Err(env::VarError::NotUnicode(_)) => Err(ConfigError::VariableUnicode {
            variable: variable.to_owned(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parsed_values_never_include_the_rejected_input() {
        let error = parsed::<u16>(
            "SMTP_PORT",
            Some("not-a-port".to_owned()),
            "must be an integer TCP port",
        )
        .expect_err("fixture must be invalid");
        let rendered = format!("{error:?} {error}");
        assert!(!rendered.contains("not-a-port"));
        assert!(rendered.contains("SMTP_PORT"));
    }
}