envir 1.3.0

Deserialize/serialize struct from/to env
Documentation
#[cfg_attr(feature = "serde", deprecated(note = "use `derive` feature instead"))]
pub use envir_derive::*;

use std::collections::HashMap;

#[cfg_attr(feature = "serde", deprecated(note = "use `derive` feature instead"))]
pub trait Serialize {
    fn export(&self) {
        for (k, v) in self.collect() {
            crate::set(&k, v);
        }
    }

    fn collect(&self) -> HashMap<String, String>;
}

#[cfg_attr(feature = "serde", deprecated(note = "use `derive` feature instead"))]
pub trait Deserialize {
    fn from_env() -> crate::Result<Self>
    where
        Self: Sized,
    {
        let env = crate::collect();

        Self::from(&env)
    }

    fn from(env: &HashMap<String, String>) -> crate::Result<Self>
    where
        Self: Sized;
}

#[cfg_attr(feature = "serde", deprecated(note = "use `derive` feature instead"))]
pub fn from_env<T>() -> crate::Result<T>
where
    T: Deserialize,
{
    T::from_env()
}

#[cfg_attr(feature = "serde", deprecated(note = "use `derive` feature instead"))]
pub fn from<T>(env: &HashMap<String, String>) -> crate::Result<T>
where
    T: Deserialize,
{
    T::from(env)
}

#[doc(hidden)]
pub fn load_option<T: std::str::FromStr>(
    env: &HashMap<String, String>,
    var: &str,
    default: Option<String>,
    _separator: char,
) -> crate::Result<Option<T>>
where
    T::Err: ToString,
{
    #[cfg(feature = "extrapolation")]
    fn try_replace<'t, F: FnMut(&regex::Captures) -> crate::Result<String>>(
        regex: &regex::Regex,
        text: &'t str,
        mut rep: F,
    ) -> crate::Result<std::borrow::Cow<'t, str>> {
        let mut it = regex.captures_iter(text).peekable();

        if it.peek().is_none() {
            return Ok(std::borrow::Cow::Borrowed(text));
        }

        let mut new = String::with_capacity(text.len());
        let mut last_match = 0;
        for cap in it {
            // unwrap on 0 is OK because captures only reports matches
            let m = cap.get(0).unwrap();
            new.push_str(&text[last_match..m.start()]);
            new.push_str(&rep(&cap)?);
            last_match = m.end();
        }
        new.push_str(&text[last_match..]);
        Ok(std::borrow::Cow::Owned(new))
    }

    #[cfg(feature = "extrapolation")]
    let default = {
        static REGEX: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
        // @FIXME once_cell_try feature
        let regex = REGEX.get_or_init(|| {
            regex::Regex::new(r#"(\$\{ *(?P<name>.*?) *\})|(\$\( *(?P<cmd>.*?) *\))"#).unwrap()
        });

        default
            .map(|x| {
                try_replace(regex, &x, |caps: &regex::Captures| {
                    let content = if let Some(name) = caps.name("name") {
                        crate::get(name.as_str())?
                    } else if let Some(cmd) = caps.name("cmd") {
                        let output = std::process::Command::new("sh")
                            .arg("-c")
                            .arg(cmd.as_str())
                            .output()?;

                        if output.status.success() {
                            String::from_utf8_lossy(&output.stdout).into_owned()
                        } else {
                            return Err(crate::Error::Command {
                                command: cmd.as_str().to_string(),
                                output,
                            });
                        }
                    } else {
                        unreachable!()
                    };

                    Ok(content)
                })
                .map(|x| x.to_string())
            })
            .transpose()?
    };

    env.get(var)
        .or(default.as_ref())
        .map(|x| parse(var, x))
        .transpose()
}

#[doc(hidden)]
pub fn load_vec<T: std::str::FromStr>(
    env: &HashMap<String, String>,
    var: &str,
    default: Option<String>,
    separator: char,
) -> crate::Result<Option<Vec<T>>>
where
    T::Err: ToString,
{
    env.get(var)
        .or(default.as_ref())
        .map(|x| x.split(separator).map(|x| parse(var, x)).collect())
        .transpose()
}

fn parse<T: std::str::FromStr>(var: &str, value: &str) -> crate::Result<T>
where
    T::Err: ToString,
{
    value
        .parse::<T>()
        .map_err(|e| crate::Error::parse::<T, _>(var, e.to_string()))
}

#[cfg(test)]
mod test {
    #[test]
    fn deserialize() {
        #[derive(Debug, PartialEq, crate::Deserialize)]
        #[envir(prefix = "ENV_")]
        struct Test {
            #[envir(name = "FOO")]
            field1: String,
            #[envir(default)]
            field2: String,
            #[envir(default = "field3")]
            field3: String,
            field4: u8,
            #[envir(load_with = "load_field5")]
            field5: String,
            field6: Option<char>,
            field7: Vec<String>,
            #[envir(separator = ';')]
            field8: Vec<usize>,
            field9: Option<Vec<String>>,
        }

        fn load_field5(_: &std::collections::HashMap<String, String>) -> crate::Result<String> {
            Ok("field5".to_string())
        }

        crate::set("ENV_FOO", "foo");
        crate::set("ENV_FIELD4", 4);
        crate::set("ENV_FIELD7", "value1,value2");
        crate::set("ENV_FIELD8", "1;2");

        let test = crate::from_env::<Test>().unwrap();
        assert_eq!(
            test,
            Test {
                field1: "foo".to_string(),
                field2: String::new(),
                field3: "field3".to_string(),
                field4: 4,
                field5: "field5".to_string(),
                field6: None,
                field7: vec!["value1".to_string(), "value2".to_string()],
                field8: vec![1, 2],
                field9: None,
            }
        );
    }

    #[test]
    fn serialize() {
        use crate::Serialize;

        #[derive(Debug, PartialEq, crate::Serialize)]
        #[envir(prefix = "ENV2_")]
        struct Test2 {
            #[envir(name = "FOO")]
            field1: String,
            field2: String,
            field3: Vec<String>,
            #[envir(separator = ';')]
            field4: Vec<usize>,
            field5: Option<Vec<String>>,
        }

        let test = Test2 {
            field1: "field1".to_string(),
            field2: "field2".to_string(),
            field3: vec!["value1".to_string(), "value2".to_string()],
            field4: vec![1, 2],
            field5: None,
        };

        assert!(std::env::var("ENV2_FOO").is_err());
        assert!(std::env::var("ENV2_FIELD2").is_err());
        assert!(std::env::var("ENV2_FIELD3").is_err());
        assert!(std::env::var("ENV2_FIELD3").is_err());
        assert!(std::env::var("ENV2_FIELD5").is_err());

        test.export();

        assert_eq!(std::env::var("ENV2_FOO"), Ok("field1".to_string()));
        assert_eq!(std::env::var("ENV2_FIELD2"), Ok("field2".to_string()));
        assert_eq!(
            std::env::var("ENV2_FIELD3"),
            Ok("value1,value2".to_string())
        );
        assert_eq!(std::env::var("ENV2_FIELD4"), Ok("1;2".to_string()));
        assert!(std::env::var("ENV2_FIELD5").is_err());
    }

    #[test]
    fn nested() {
        #[derive(Debug, PartialEq, crate::Deserialize, crate::Serialize)]
        struct Test3 {
            #[envir(nested)]
            nested: Nested,
        }

        #[derive(Debug, PartialEq, crate::Deserialize, crate::Serialize)]
        #[envir(prefix = "ENV3_")]
        struct Nested {
            foo: Option<String>,
        }

        let mut env = std::collections::HashMap::new();
        env.insert("ENV3_FOO".to_string(), "foo".to_string());

        let test = crate::from::<Test3>(&env).unwrap();
        assert_eq!(
            test,
            Test3 {
                nested: Nested {
                    foo: Some("foo".to_string()),
                }
            }
        );

        use crate::Serialize;

        assert!(std::env::var("ENV3_FOO").is_err());
        test.export();
        assert_eq!(std::env::var("ENV3_FOO"), Ok("foo".to_string()));
    }

    #[test]
    #[cfg_attr(
        not(feature = "extrapolation"),
        ignore = "feature `extrapolation` is disable"
    )]
    fn env() {
        #[derive(Debug, PartialEq, crate::Deserialize)]
        struct Test4 {
            #[envir(default = "${HOME}/.config")]
            config_dir: String,
            #[envir(default = "$(echo test)")]
            config_content: String,
        }

        let test = crate::from_env::<Test4>().unwrap();

        assert_eq!(
            test,
            Test4 {
                config_dir: format!("{}/.config", std::env::var("HOME").unwrap()),
                config_content: "test\n".to_string(),
            }
        );
    }

    #[test]
    #[cfg_attr(
        not(feature = "extrapolation"),
        ignore = "feature `extrapolation` is disable"
    )]
    fn extrapolation_error() {
        #[derive(crate::Deserialize)]
        struct Test {
            #[envir(default = "${MISSING_ENV}/.config")]
            _config_dir: String,
        }

        assert!(crate::from_env::<Test>().is_err());

        #[derive(Debug, crate::Deserialize)]
        struct Test2 {
            #[envir(default = "$(cat ~/.config)")]
            _config_content: String,
        }

        assert!(crate::from_env::<Test2>().is_err());
    }

    #[test]
    fn skip_export() {
        use crate::Serialize as _;

        #[derive(crate::Serialize)]
        struct Test {
            #[envir(skip_export)]
            #[allow(dead_code)]
            skip_export: String,
        }

        let test = Test {
            skip_export: "skip".to_string(),
        };

        test.export();

        assert!(std::env::var("SKIP_EXPORT").is_err());
    }

    #[test]
    fn skip_load() -> crate::Result {
        #[derive(crate::Deserialize)]
        struct Test {
            #[envir(skip_load)]
            home: String,
        }

        let test = crate::from_env::<Test>()?;

        assert!(test.home.is_empty());

        Ok(())
    }

    #[test]
    fn skip() -> crate::Result {
        use crate::Serialize as _;

        #[derive(crate::Deserialize, crate::Serialize)]
        struct Test {
            #[envir(skip)]
            home: String,
        }

        let test = crate::from_env::<Test>()?;

        assert!(test.home.is_empty());

        test.export();

        assert!(!std::env::var("HOME").unwrap().is_empty());

        Ok(())
    }

    #[test]
    fn skip_export_if() -> crate::Result {
        use crate::Serialize as _;

        #[derive(Default, crate::Serialize)]
        struct Test {
            #[envir(skip_export_if = "String::is_empty")]
            skip: String,
        }

        let test = Test {
            skip: "skip_if_empty".to_string(),
        };

        test.export();

        assert!(std::env::var("SKIP_IF_EMPTY").is_err());

        Ok(())
    }
}