diff --git a/Cargo.lock b/Cargo.lock index 544e8dc1..a45df6cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -211,6 +211,7 @@ dependencies = [ "async-trait", "chrono", "convert_case", + "dotenvy", "float-cmp", "futures", "glob", @@ -326,6 +327,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "encoding_rs" version = "0.8.35" diff --git a/Cargo.toml b/Cargo.toml index e3d5e981..4888a316 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,7 @@ yaml = ["yaml-rust2"] ini = ["rust-ini"] json5 = ["json5_rs", "dep:serde-untagged"] corn = ["dep:corn"] +dotenv = ["dep:dotenvy"] convert-case = ["convert_case"] preserve_order = ["indexmap", "toml?/preserve_order", "serde_json?/preserve_order", "ron?/indexmap"] async = ["async-trait"] @@ -143,6 +144,7 @@ rust-ini = { version = "0.21.3", optional = true } ron = { version = "0.8.1", optional = true } json5_rs = { version = "0.4.1", optional = true, package = "json5" } corn = { version = "0.10.0", optional = true, package = "libcorn" } +dotenvy = { version = "0.15.7", optional = true } indexmap = { version = "2.11.4", features = ["serde"], optional = true } convert_case = { version = "0.6.0", optional = true } pathdiff = "0.2.3" diff --git a/src/file/format/dotenv.rs b/src/file/format/dotenv.rs new file mode 100644 index 00000000..f51d6535 --- /dev/null +++ b/src/file/format/dotenv.rs @@ -0,0 +1,19 @@ +use std::error::Error; +use std::io::Cursor; + +use crate::map::Map; +use crate::value::{Value, ValueKind}; + +pub(crate) fn parse( + uri: Option<&String>, + text: &str, +) -> Result, Box> { + let mut map: Map = Map::new(); + let str_iter = dotenvy::Iter::new(Cursor::new(text)); + for item in str_iter { + let (key, value) = item?; + map.insert(key, Value::new(uri, ValueKind::String(value))); + } + + Ok(map) +} diff --git a/src/file/format/mod.rs b/src/file/format/mod.rs index bb3df49d..483fdfb5 100644 --- a/src/file/format/mod.rs +++ b/src/file/format/mod.rs @@ -24,6 +24,9 @@ mod json5; #[cfg(feature = "corn")] mod corn; +#[cfg(feature = "dotenv")] +mod dotenv; + /// File formats provided by the library. /// /// Although it is possible to define custom formats using [`Format`] trait it is recommended to use `FileFormat` if possible. @@ -57,6 +60,10 @@ pub enum FileFormat { /// Corn (parsed with `libcorn`) #[cfg(feature = "corn")] Corn, + + /// Dotenv (parsed with `dotenvy`) + #[cfg(feature = "dotenv")] + Dotenv, } impl FileFormat { @@ -76,6 +83,8 @@ impl FileFormat { FileFormat::Json5, #[cfg(feature = "corn")] FileFormat::Corn, + #[cfg(feature = "dotenv")] + FileFormat::Dotenv, ] } @@ -102,6 +111,9 @@ impl FileFormat { #[cfg(feature = "corn")] FileFormat::Corn => &["corn"], + #[cfg(feature = "dotenv")] + FileFormat::Dotenv => &["dotenv"], + #[cfg(all( not(feature = "toml"), not(feature = "json"), @@ -109,6 +121,7 @@ impl FileFormat { not(feature = "ini"), not(feature = "ron"), not(feature = "json5"), + not(feature = "dotenv"), ))] _ => unreachable!("No features are enabled, this library won't work without features"), } @@ -141,6 +154,9 @@ impl FileFormat { #[cfg(feature = "corn")] FileFormat::Corn => corn::parse(uri, text), + #[cfg(feature = "dotenv")] + FileFormat::Dotenv => dotenv::parse(uri, text), + #[cfg(all( not(feature = "toml"), not(feature = "json"), @@ -148,6 +164,7 @@ impl FileFormat { not(feature = "ini"), not(feature = "ron"), not(feature = "json5"), + not(feature = "dotenv"), ))] _ => unreachable!("No features are enabled, this library won't work without features"), } diff --git a/tests/testsuite/.env b/tests/testsuite/.env new file mode 100644 index 00000000..57a4f951 --- /dev/null +++ b/tests/testsuite/.env @@ -0,0 +1,3 @@ +foobar="I am FOOBAR envfile" +foo="I am foo envfile" +bar="I am BAR envfile" diff --git a/tests/testsuite/.env.local b/tests/testsuite/.env.local new file mode 100644 index 00000000..2f60dc27 --- /dev/null +++ b/tests/testsuite/.env.local @@ -0,0 +1,3 @@ +foobar="I am FOOBAR envfile local" +foo="I am foo envfile local" +bar="I am BAR envfile local" diff --git a/tests/testsuite/file_dotenv.rs b/tests/testsuite/file_dotenv.rs new file mode 100644 index 00000000..c89f8c65 --- /dev/null +++ b/tests/testsuite/file_dotenv.rs @@ -0,0 +1,325 @@ +#![cfg(feature = "dotenv")] + +use config::Config; +use snapbox::{assert_data_eq, str}; + +#[test] +fn basic_dotenv() { + let s = Config::builder() + .add_source(config::File::from_str( + r#" +FOO=bar +BAZ=qux +"#, + config::FileFormat::Dotenv, + )) + .build() + .unwrap(); + + assert_eq!(s.get::("FOO").unwrap(), "bar"); + assert_eq!(s.get::("BAZ").unwrap(), "qux"); +} + +#[test] +fn optional_variables() { + let s = Config::builder() + .add_source(config::File::from_str( + r#" +FOO=bar +BAZ=${FOO} +BAR=${UNDEFINED:-} +"#, + config::FileFormat::Dotenv, + )) + .build() + .unwrap(); + + assert_eq!(s.get::("BAR").unwrap(), ""); +} + +#[test] +fn multiple_files() { + let s = Config::builder() + .add_source(config::File::from_str( + r#" +FOO=bar +"#, + config::FileFormat::Dotenv, + )) + .add_source(config::File::from_str( + r#" +BAZ=qux +"#, + config::FileFormat::Dotenv, + )) + .build() + .unwrap(); + + assert_eq!(s.get::("FOO").unwrap(), "bar"); + assert_eq!(s.get::("BAZ").unwrap(), "qux"); +} + +#[test] +fn test_file() { + #[derive(Debug, Deserialize, PartialEq)] + struct Settings { + name: String, + longitude: f64, + latitude: f64, + favorite: bool, + reviews: u64, + rating: Option, + } + + let c = Config::builder() + .add_source(config::File::from_str( + r#" +name = "Torre di Pisa" +longitude = 43.7224985 +latitude = 10.3970522 +favorite = false +reviews = 3866 +rating = 4.5 +"#, + config::FileFormat::Dotenv, + )) + .build() + .unwrap(); + let s: Settings = c.try_deserialize().unwrap(); + assert_eq!( + s, + Settings { + name: String::from("Torre di Pisa"), + longitude: 43.722_498_5, + latitude: 10.397_052_2, + favorite: false, + reviews: 3866, + rating: Some(4.5), + } + ); +} + +#[test] +fn test_error_parse() { + let res = Config::builder() + .add_source(config::File::from_str( + r#" +ok : true, +error +"#, + config::FileFormat::Dotenv, + )) + .build(); + + assert!(res.is_err()); + assert_data_eq!( + res.unwrap_err().to_string(), + str![[r#"Error parsing line: 'ok : true,', error at line index: 3"#]] + ); +} + +#[test] +fn test_override_uppercase_value_for_struct() { + #[derive(Debug, Deserialize, PartialEq)] + struct StructSettings { + foo: String, + bar: String, + } + + #[derive(Debug, Deserialize, PartialEq)] + #[allow(non_snake_case)] + struct CapSettings { + FOO: String, + } + + std::env::set_var("APP_FOO", "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE"); + + let cfg = Config::builder() + .add_source(config::File::from_str( + r#" +debug = true +production = false +FOO = "FOO should be overridden" +bar = "I am bar" +longitude = 43.7224985 +latitude = 10.3970522 +favorite = false +reviews = 3866 +rating = 4.5 +"#, + config::FileFormat::Dotenv, + )) + .add_source(config::Environment::with_prefix("APP").separator("_")) + .build() + .unwrap(); + let cap_settings = cfg.clone().try_deserialize::(); + let lower_settings = cfg.try_deserialize::().unwrap(); + + match cap_settings { + Ok(v) => { + // this assertion will ensure that the map has only lowercase keys + assert_eq!(v.FOO, "FOO should be overridden"); + assert_eq!( + lower_settings.foo, + "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE".to_owned() + ); + } + Err(e) => { + if matches!(e, config::ConfigError::NotFound(_)) { + assert_eq!( + lower_settings.foo, + "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE".to_owned() + ); + } else { + panic!("{}", e); + } + } + } +} + +#[test] +fn test_override_lowercase_value_for_struct() { + #[derive(Debug, Deserialize, PartialEq)] + struct StructSettings { + foo: String, + bar: String, + } + + std::env::set_var("config_foo", "I have been overridden_with_lower_case"); + + let cfg = Config::builder() + .add_source(config::File::from_str( + r#" +FOO = "FOO should be overridden" +bar = "I am bar" +longitude = 43.7224985 +latitude = 10.3970522 +favorite = false +reviews = 3866 +rating = 4.5 +"#, + config::FileFormat::Dotenv, + )) + .add_source(config::Environment::with_prefix("config").separator("_")) + .build() + .unwrap(); + + let values: StructSettings = cfg.try_deserialize().unwrap(); + assert_eq!( + values.foo, + "I have been overridden_with_lower_case".to_owned() + ); + assert_eq!(values.bar, "I am bar".to_owned()); +} + +#[test] +fn test_override_uppercase_value_for_enums() { + #[derive(Debug, Deserialize, PartialEq)] + enum EnumSettings { + Bar(String), + } + + std::env::set_var("APPS_BAR", "I HAVE BEEN OVERRIDDEN_WITH_UPPER_CASE"); + + let cfg = Config::builder() + .add_source(config::File::from_str( + r#" +bar = "bar is a lowercase param" +"#, + config::FileFormat::Dotenv, + )) + .add_source(config::Environment::with_prefix("APPS").separator("_")) + .build() + .unwrap(); + + let param = cfg.try_deserialize::(); + assert!(param.is_err()); + assert_data_eq!( + param.unwrap_err().to_string(), + str!["enum EnumSettings does not have variant constructor bar"] + ); +} + +#[test] +fn test_override_lowercase_value_for_enums() { + #[derive(Debug, Deserialize, PartialEq)] + enum EnumSettings { + Bar(String), + } + + std::env::set_var("test_bar", "I have been overridden_with_lower_case"); + + let cfg = Config::builder() + .add_source(config::File::from_str( + r#" +bar = "bar is a lowercase param" +"#, + config::FileFormat::Dotenv, + )) + .add_source(config::Environment::with_prefix("test").separator("_")) + .build() + .unwrap(); + + let param = cfg.try_deserialize::(); + assert!(param.is_err()); + assert_data_eq!( + param.unwrap_err().to_string(), + str!["enum EnumSettings does not have variant constructor bar"] + ); +} + +#[test] +fn test_loading_env_file() { + #[derive(Debug, Deserialize, PartialEq)] + struct StructSettings { + foobar: String, + foo: String, + bar: String, + } + + let cfg = Config::builder() + .add_source( + config::File::with_name("tests/testsuite/.env").format(config::FileFormat::Dotenv), + ) + .build() + .unwrap(); + let s: StructSettings = cfg.try_deserialize().unwrap(); + assert_eq!( + s, + StructSettings { + foobar: String::from("I am FOOBAR envfile"), + foo: String::from("I am foo envfile"), + bar: String::from("I am BAR envfile"), + } + ); +} + +#[test] +fn test_loading_env_file_with_substitution() { + #[derive(Debug, Deserialize, PartialEq)] + struct StructSettings { + foobar: String, + foo: String, + bar: String, + } + + let cfg = Config::builder() + .add_source( + config::File::with_name("tests/testsuite/.env").format(config::FileFormat::Dotenv), + ) + .add_source( + config::File::with_name("tests/testsuite/.env.local") + .format(config::FileFormat::Dotenv), + ) + .build() + .unwrap(); + let s: StructSettings = cfg.try_deserialize().unwrap(); + assert_eq!( + s, + StructSettings { + foobar: String::from("I am FOOBAR envfile local"), + foo: String::from("I am foo envfile local"), + bar: String::from("I am BAR envfile local"), + } + ); +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 6f9759c8..68b795d5 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -9,6 +9,7 @@ pub mod env; pub mod errors; pub mod file; pub mod file_corn; +pub mod file_dotenv; pub mod file_ini; pub mod file_json; pub mod file_json5;