use figment::providers::Env;
use figment::{
Profile, Provider,
error::Error,
util::nest,
value::{Dict, Map, Value},
};
use std::ops::Deref;
use uncased::{Uncased, UncasedStr};
#[derive(Clone)]
pub struct CsvEnv {
inner: Env,
}
impl CsvEnv {
#[must_use]
pub fn raw() -> Self {
Env::raw().into()
}
#[must_use]
pub fn prefixed(prefix: &str) -> Self {
Env::prefixed(prefix).into()
}
#[must_use]
pub fn split(self, pattern: &str) -> Self {
self.inner.split(pattern).into()
}
#[must_use]
pub fn map<F>(self, mapper: F) -> Self
where
F: Fn(&UncasedStr) -> Uncased<'_> + Clone + 'static,
{
self.inner.map(mapper).into()
}
#[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()
}
#[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()
}
fn should_parse_as_csv(value: &str) -> bool {
let trimmed = value.trim();
trimmed.contains(',') && !matches!(trimmed.chars().next(), Some('[' | '{' | '"' | '\''))
}
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 {
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()));
}
}