ortho_config 0.9.0

A configuration management library for Rust, inspired by esbuild.
Documentation
//! Environment provider that parses comma-separated lists.
//!
//! Wraps `figment::providers::Env` and converts values containing commas
//! into arrays unless they look like structured data (starting with `[` or
//! `{` or a quote). This allows environment variables such as
//! `DDLINT_RULES=A,B,C` to be deserialized as `Vec<String>`. Values with
//! embedded commas must be wrapped in quotes or brackets to avoid being split.

use figment::providers::Env;
use figment::{
    Profile, Provider,
    error::Error,
    util::nest,
    value::{Dict, Map, Value},
};
use std::ops::Deref;
use uncased::{Uncased, UncasedStr};

/// Environment provider with CSV list support.
///
/// Wraps the standard [`Env`] provider to interpret comma-separated
/// values as arrays, whilst leaving JSON strings untouched.
#[derive(Clone)]
pub struct CsvEnv {
    /// Inner environment provider that performs the actual variable access.
    inner: Env,
}

impl CsvEnv {
    /// Create an unprefixed provider.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ortho_config::CsvEnv;
    /// let env = CsvEnv::raw();
    /// let _ = env;
    /// ```
    #[must_use]
    pub fn raw() -> Self {
        Env::raw().into()
    }

    /// Create a provider using `prefix`.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ortho_config::CsvEnv;
    /// let env = CsvEnv::prefixed("APP_");
    /// let _ = env;
    /// ```
    #[must_use]
    pub fn prefixed(prefix: &str) -> Self {
        Env::prefixed(prefix).into()
    }

    /// Split keys at `pattern`.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ortho_config::CsvEnv;
    /// let env = CsvEnv::raw().split("__");
    /// let _ = env;
    /// ```
    #[must_use]
    pub fn split(self, pattern: &str) -> Self {
        self.inner.split(pattern).into()
    }

    /// Map keys using `mapper`.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ortho_config::CsvEnv;
    /// use uncased::Uncased;
    /// let env = CsvEnv::raw().map(|k| Uncased::from(format!("APP_{k}")));
    /// let _ = env;
    /// ```
    #[must_use]
    pub fn map<F>(self, mapper: F) -> Self
    where
        F: Fn(&UncasedStr) -> Uncased<'_> + Clone + 'static,
    {
        self.inner.map(mapper).into()
    }

    /// Filter and map keys using `f`.
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// use ortho_config::CsvEnv;
    /// use uncased::Uncased;
    /// let env = CsvEnv::raw().filter_map(|k| k.strip_prefix("APP_").map(Uncased::from));
    /// // requires `UncasedStr::strip_prefix`; shown for illustration only
    /// let _ = env;
    /// ```
    #[must_use]
    pub fn filter_map<F>(self, f: F) -> Self
    where
        F: Fn(&UncasedStr) -> Option<Uncased<'_>> + Clone + 'static,
    {
        self.inner.filter_map(f).into()
    }

    /// Whether to lowercase keys before emitting them.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ortho_config::CsvEnv;
    /// let env = CsvEnv::raw().lowercase(true);
    /// let _ = env;
    /// ```
    #[must_use]
    pub fn lowercase(self, lowercase: bool) -> Self {
        self.inner.lowercase(lowercase).into()
    }

    fn iter(&self) -> impl Iterator<Item = (Uncased<'static>, String)> + '_ {
        self.inner.iter()
    }

    /// Determine if a value should be parsed as comma-separated rather than
    /// structured data.
    ///
    /// The value is treated as CSV when it contains a comma and does not start
    /// with `[` , `{`, `"` or `'`. This avoids misinterpreting JSON or quoted
    /// strings as lists.
    fn should_parse_as_csv(value: &str) -> bool {
        let trimmed = value.trim();
        trimmed.contains(',') && !matches!(trimmed.chars().next(), Some('[' | '{' | '"' | '\''))
    }

    /// Parse a scalar (non-CSV) string into a [`Value`].
    ///
    /// Handles boolean literals case-insensitively, then falls back to
    /// `serde_json` parsing, and finally treats the input as a plain string.
    fn parse_scalar(trimmed: &str) -> Value {
        if trimmed.eq_ignore_ascii_case("true") {
            return true.into();
        }
        if trimmed.eq_ignore_ascii_case("false") {
            return false.into();
        }
        trimmed
            .parse()
            .unwrap_or_else(|_| Value::from(trimmed.to_owned()))
    }

    fn parse_value(raw: &str) -> Value {
        let trimmed = raw.trim();
        if Self::should_parse_as_csv(trimmed) {
            trimmed
                .split(',')
                .map(|s| Value::from(s.trim().to_owned()))
                .collect::<Vec<_>>()
                .into()
        } else {
            Self::parse_scalar(trimmed)
        }
    }
}

impl Provider for CsvEnv {
    fn metadata(&self) -> figment::Metadata {
        self.inner.metadata()
    }

    fn profile(&self) -> Option<Profile> {
        Some(self.inner.profile.clone())
    }

    fn data(&self) -> Result<Map<Profile, Dict>, Error> {
        let mut dict = Dict::new();
        for (k, v) in self.iter() {
            let value = Self::parse_value(&v);
            let Some(nested) = nest(k.as_str(), value).into_dict() else {
                return Err(Error::from(format!(
                    "environment key `{k}` produced a non-object value"
                )));
            };
            dict.extend(nested);
        }
        Ok(self.inner.profile.collect(dict))
    }
}

impl From<Env> for CsvEnv {
    fn from(inner: Env) -> Self {
        Self { inner }
    }
}

impl Deref for CsvEnv {
    type Target = Env;

    fn deref(&self) -> &Env {
        &self.inner
    }
}

#[cfg(test)]
mod tests {
    //! Unit tests for CSV environment-variable parsing.

    use super::*;
    use figment::value::Tag;
    use rstest::rstest;

    #[rstest]
    #[case("true", Value::Bool(Tag::Default, true))]
    #[case("false", Value::Bool(Tag::Default, false))]
    #[case("TRUE", Value::Bool(Tag::Default, true))]
    #[case("FALSE", Value::Bool(Tag::Default, false))]
    #[case("True", Value::Bool(Tag::Default, true))]
    #[case("False", Value::Bool(Tag::Default, false))]
    fn parse_scalar_handles_boolean_strings(#[case] input: &str, #[case] expected: Value) {
        assert_eq!(CsvEnv::parse_scalar(input), expected);
    }

    #[rstest]
    #[case("hello")]
    #[case("some_value")]
    fn parse_scalar_falls_back_to_string(#[case] input: &str) {
        assert_eq!(CsvEnv::parse_scalar(input), Value::from(input.to_owned()));
    }
}