diff --git a/Cargo.toml b/Cargo.toml index e3d5e981..4abfb11c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,6 +131,7 @@ convert-case = ["convert_case"] preserve_order = ["indexmap", "toml?/preserve_order", "serde_json?/preserve_order", "ron?/indexmap"] async = ["async-trait"] toml = ["dep:toml"] +substitute_env = [] [dependencies] serde_core = "1.0.228" diff --git a/src/file/format/corn.rs b/src/file/format/corn.rs index f188df16..231e41e0 100644 --- a/src/file/format/corn.rs +++ b/src/file/format/corn.rs @@ -1,6 +1,7 @@ use crate::value::{Value, ValueKind}; use crate::{format, Map}; use std::error::Error; +use crate::nested_env_vars::ExpandEnvVars; pub(crate) fn parse( uri: Option<&String>, @@ -12,7 +13,7 @@ pub(crate) fn parse( fn from_corn_value(uri: Option<&String>, value: &corn::Value<'_>) -> Value { match value { - corn::Value::String(value) => Value::new(uri, ValueKind::String(value.to_string())), + corn::Value::String(value) => Value::new(uri, ValueKind::String(value.expand_env_vars())), corn::Value::Integer(value) => Value::new(uri, ValueKind::I64(*value)), corn::Value::Float(value) => Value::new(uri, ValueKind::Float(*value)), corn::Value::Boolean(value) => Value::new(uri, ValueKind::Boolean(*value)), diff --git a/src/file/format/ini.rs b/src/file/format/ini.rs index 7394d6dd..13985442 100644 --- a/src/file/format/ini.rs +++ b/src/file/format/ini.rs @@ -3,6 +3,7 @@ use std::error::Error; use ini::Ini; use crate::map::Map; +use crate::nested_env_vars::ExpandEnvVars; use crate::value::{Value, ValueKind}; pub(crate) fn parse( @@ -18,7 +19,7 @@ pub(crate) fn parse( for (k, v) in prop.iter() { sec_map.insert( k.to_owned(), - Value::new(uri, ValueKind::String(v.to_owned())), + Value::new(uri, ValueKind::String(v.expand_env_vars())), ); } map.insert(sec.to_owned(), Value::new(uri, ValueKind::Table(sec_map))); @@ -27,7 +28,7 @@ pub(crate) fn parse( for (k, v) in prop.iter() { map.insert( k.to_owned(), - Value::new(uri, ValueKind::String(v.to_owned())), + Value::new(uri, ValueKind::String(v.expand_env_vars())), ); } } diff --git a/src/file/format/json.rs b/src/file/format/json.rs index 9100b662..b2c6a8d5 100644 --- a/src/file/format/json.rs +++ b/src/file/format/json.rs @@ -2,6 +2,7 @@ use std::error::Error; use crate::format; use crate::map::Map; +use crate::nested_env_vars::ExpandEnvVars; use crate::value::{Value, ValueKind}; pub(crate) fn parse( @@ -15,7 +16,7 @@ pub(crate) fn parse( fn from_json_value(uri: Option<&String>, value: &serde_json::Value) -> Value { match *value { - serde_json::Value::String(ref value) => Value::new(uri, ValueKind::String(value.clone())), + serde_json::Value::String(ref value) => Value::new(uri, ValueKind::String(value.expand_env_vars())), serde_json::Value::Number(ref value) => { if let Some(value) = value.as_i64() { diff --git a/src/file/format/json5.rs b/src/file/format/json5.rs index e61b5b16..eb9fdb26 100644 --- a/src/file/format/json5.rs +++ b/src/file/format/json5.rs @@ -2,6 +2,7 @@ use std::error::Error; use crate::format; use crate::map::Map; +use crate::nested_env_vars::ExpandEnvVars; use crate::value::{Value, ValueKind}; #[derive(Debug)] @@ -43,7 +44,7 @@ pub(crate) fn parse( fn from_json5_value(uri: Option<&String>, value: Val) -> Value { let vk = match value { Val::Null => ValueKind::Nil, - Val::String(v) => ValueKind::String(v), + Val::String(v) => ValueKind::String(v.expand_env_vars()), Val::Integer(v) => ValueKind::I64(v), Val::Float(v) => ValueKind::Float(v), Val::Boolean(v) => ValueKind::Boolean(v), diff --git a/src/file/format/ron.rs b/src/file/format/ron.rs index 2911a73c..f1c1b899 100644 --- a/src/file/format/ron.rs +++ b/src/file/format/ron.rs @@ -2,6 +2,7 @@ use std::error::Error; use crate::format; use crate::map::Map; +use crate::nested_env_vars::ExpandEnvVars; use crate::value::{Value, ValueKind}; pub(crate) fn parse( @@ -33,7 +34,7 @@ fn from_ron_value( ron::Value::Char(value) => ValueKind::String(value.to_string()), - ron::Value::String(value) => ValueKind::String(value), + ron::Value::String(value) => ValueKind::String(value.expand_env_vars()), ron::Value::Seq(values) => { let array = values diff --git a/src/file/format/toml.rs b/src/file/format/toml.rs index 454a057c..80bec892 100644 --- a/src/file/format/toml.rs +++ b/src/file/format/toml.rs @@ -1,6 +1,7 @@ use std::error::Error; use crate::map::Map; +use crate::nested_env_vars::ExpandEnvVars; use crate::value::Value; pub(crate) fn parse( @@ -24,7 +25,7 @@ fn from_toml_table(uri: Option<&String>, table: toml::Table) -> Map, value: toml::Value) -> Value { match value { - toml::Value::String(value) => Value::new(uri, value), + toml::Value::String(value) => Value::new(uri, value.expand_env_vars()), toml::Value::Float(value) => Value::new(uri, value), toml::Value::Integer(value) => Value::new(uri, value), toml::Value::Boolean(value) => Value::new(uri, value), diff --git a/src/file/format/yaml.rs b/src/file/format/yaml.rs index f74c2d66..bc7b6776 100644 --- a/src/file/format/yaml.rs +++ b/src/file/format/yaml.rs @@ -6,6 +6,7 @@ use yaml_rust2 as yaml; use crate::format; use crate::map::Map; +use crate::nested_env_vars::ExpandEnvVars; use crate::value::{Value, ValueKind}; pub(crate) fn parse( @@ -31,7 +32,7 @@ fn from_yaml_value( value: &yaml::Yaml, ) -> Result> { match *value { - yaml::Yaml::String(ref value) => Ok(Value::new(uri, ValueKind::String(value.clone()))), + yaml::Yaml::String(ref value) => Ok(Value::new(uri, ValueKind::String(value.expand_env_vars()))), yaml::Yaml::Real(ref value) => { // TODO: Figure out in what cases this can panic? value diff --git a/src/lib.rs b/src/lib.rs index 72114540..8a6184b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,8 @@ mod ser; mod source; mod value; +mod nested_env_vars; + // Re-export #[cfg(feature = "convert-case")] pub use convert_case::Case; diff --git a/src/nested_env_vars.rs b/src/nested_env_vars.rs new file mode 100644 index 00000000..9213e54f --- /dev/null +++ b/src/nested_env_vars.rs @@ -0,0 +1,246 @@ + +pub(crate) trait ExpandEnvVars { + fn expand_env_vars(&self) -> String; +} + +#[cfg(not(feature = "substitute_env"))] +impl ExpandEnvVars for str { + fn expand_env_vars(&self) -> String { + self.to_string() + } +} + +#[cfg(feature = "substitute_env")] +impl ExpandEnvVars for str { + fn expand_env_vars(&self) -> String { + let mut result = String::with_capacity(self.len()); + let mut chars = self.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '$' && chars.peek() == Some(&'{') { + chars.next(); // consume '{' + + // Find the closing '}' + let mut var_expr = String::new(); + let mut found_closing = false; + + while let Some(ch) = chars.next() { + if ch == '}' { + found_closing = true; + break; + } + var_expr.push(ch); + } + + if found_closing { + // Check for default value syntax: VAR:-default + if let Some(colon_pos) = var_expr.find(":-") { + let var_name = &var_expr[..colon_pos]; + let default_value = &var_expr[colon_pos + 2..]; + + match std::env::var(var_name) { + Ok(value) if !value.is_empty() => result.push_str(&value), + _ => result.push_str(default_value), + } + } else { + // Simple variable reference: VAR + match std::env::var(&var_expr) { + Ok(value) => result.push_str(&value), + Err(_) => { + // Variable not found, leave the original syntax + result.push_str("${"); + result.push_str(&var_expr); + result.push('}'); + } + } + } + } else { + // No closing '}' found, treat as literal text + result.push('$'); + result.push('{'); + result.push_str(&var_expr); + } + } else { + result.push(ch); + } + } + + result + } +} + +#[cfg(all(test, feature = "substitute_env"))] +mod tests { + use super::*; + use temp_env; + + #[test] + fn test_no_env_vars() { + let input = "hello world"; + assert_eq!(input.expand_env_vars(), "hello world"); + } + + #[test] + fn test_simple_env_var_exists() { + temp_env::with_vars([("TEST_VAR", Some("test_value"))], || { + let input = "prefix ${TEST_VAR} suffix"; + assert_eq!(input.expand_env_vars(), "prefix test_value suffix"); + }); + } + + #[test] + fn test_simple_env_var_not_exists() { + temp_env::with_vars([("NONEXISTENT_VAR", None::<&str>)], || { + let input = "prefix ${NONEXISTENT_VAR} suffix"; + assert_eq!(input.expand_env_vars(), "prefix ${NONEXISTENT_VAR} suffix"); + }); + } + + #[test] + fn test_nonexistent_with_default() { + temp_env::with_vars([("NONEXISTENT_VAR", None::<&str>)], || { + let input = "prefix ${NONEXISTENT_VAR:-43.7224985} suffix"; + assert_eq!(input.expand_env_vars(), "prefix 43.7224985 suffix"); + }); + } + + #[test] + fn test_env_var_with_default_var_exists() { + temp_env::with_vars([("TEST_VAR", Some("actual_value"))], || { + let input = "prefix ${TEST_VAR:-default_value} suffix"; + assert_eq!(input.expand_env_vars(), "prefix actual_value suffix"); + }); + } + + #[test] + fn test_env_var_with_default_var_not_exists() { + temp_env::with_vars([("NONEXISTENT_VAR", None::<&str>)], || { + let input = "prefix ${NONEXISTENT_VAR:-default_value} suffix"; + assert_eq!(input.expand_env_vars(), "prefix default_value suffix"); + }); + } + + #[test] + fn test_env_var_with_default_var_empty() { + temp_env::with_vars([("EMPTY_VAR", Some(""))], || { + let input = "prefix ${EMPTY_VAR:-default_value} suffix"; + assert_eq!(input.expand_env_vars(), "prefix default_value suffix"); + }); + } + + #[test] + fn test_multiple_env_vars() { + temp_env::with_vars([("VAR1", Some("value1")), ("VAR2", Some("value2"))], || { + let input = "${VAR1} and ${VAR2}"; + assert_eq!(input.expand_env_vars(), "value1 and value2"); + }); + } + + #[test] + fn test_multiple_env_vars_mixed_existence() { + temp_env::with_vars( + [("EXISTS", Some("found")), ("MISSING", None::<&str>)], + || { + let input = "${EXISTS} and ${MISSING}"; + assert_eq!(input.expand_env_vars(), "found and ${MISSING}"); + }, + ); + } + + #[test] + fn test_multiple_env_vars_with_defaults() { + temp_env::with_vars([("VAR1", Some("actual1")), ("VAR2", None::<&str>)], || { + let input = "${VAR1:-default1} and ${VAR2:-default2}"; + assert_eq!(input.expand_env_vars(), "actual1 and default2"); + }); + } + + // ... existing code ... + + #[test] + fn test_variable_name_with_underscores() { + temp_env::with_vars([("TEST_VAR_123", Some("underscore_value"))], || { + let input = "${TEST_VAR_123}"; + assert_eq!(input.expand_env_vars(), "underscore_value"); + }); + } + + #[test] + fn test_consecutive_variables() { + temp_env::with_vars([("A", Some("a")), ("B", Some("b"))], || { + let input = "${A}${B}"; + assert_eq!(input.expand_env_vars(), "ab"); + }); + } + + #[test] + fn test_variable_at_start_and_end() { + temp_env::with_vars([("START", Some("beginning")), ("END", Some("end"))], || { + let input = "${START} middle ${END}"; + assert_eq!(input.expand_env_vars(), "beginning middle end"); + }); + } + + #[test] + fn test_only_variable() { + temp_env::with_vars([("ONLY_VAR", Some("only"))], || { + let input = "${ONLY_VAR}"; + assert_eq!(input.expand_env_vars(), "only"); + }); + } + + #[test] + fn test_complex_scenario() { + temp_env::with_vars( + [ + ("HOME", Some("/home/user")), + ("USER", Some("testuser")), + ("EDITOR", None::<&str>), + ], + || { + let input = + "User: ${USER}, Home: ${HOME}, Editor: ${EDITOR:-vim}, Config: ${HOME}/.config"; + assert_eq!( + input.expand_env_vars(), + "User: testuser, Home: /home/user, Editor: vim, Config: /home/user/.config" + ); + }, + ); + } + + // ... existing code ... + + #[test] + fn test_colon_without_dash() { + temp_env::with_vars([("TEST_VAR", Some("value"))], || { + // This should not be treated as default syntax since it's missing the dash + let input = "${TEST_VAR:not_default}"; + assert_eq!(input.expand_env_vars(), "${TEST_VAR:not_default}"); + }); + } + + #[test] + fn test_multiple_colon_dash_in_default() { + temp_env::with_vars([("MISSING", None::<&str>)], || { + // Only the first :- should be treated as default syntax + let input = "${MISSING:-default:-with:-colons}"; + assert_eq!(input.expand_env_vars(), "default:-with:-colons"); + }); + } + + #[test] + fn test_env_var_with_numeric_value() { + temp_env::with_vars([("PORT", Some("8080"))], || { + let input = "Server running on port ${PORT}"; + assert_eq!(input.expand_env_vars(), "Server running on port 8080"); + }); + } + + #[test] + fn test_boolean_like_env_var() { + temp_env::with_vars([("DEBUG", Some("true"))], || { + let input = "Debug mode: ${DEBUG:-false}"; + assert_eq!(input.expand_env_vars(), "Debug mode: true"); + }); + } +} diff --git a/tests/testsuite/file_with_env_vars.rs b/tests/testsuite/file_with_env_vars.rs new file mode 100644 index 00000000..b448339b --- /dev/null +++ b/tests/testsuite/file_with_env_vars.rs @@ -0,0 +1,304 @@ +#![cfg(all(feature = "yaml", feature = "substitute_env"))] + + +use float_cmp::ApproxEqUlps; +use serde::Deserialize; + +use config::{Config, File, FileFormat, Map, Value}; + +#[derive(Debug, Deserialize)] +struct Settings { + debug: f64, + production: Option, + place: Place, + #[serde(rename = "arr")] + elements: Vec, + nullable: Option, +} + +#[derive(Debug, Deserialize)] +struct Place { + name: String, + longitude: f64, + latitude: f64, + favorite: bool, + telephone: Option, + reviews: u64, + creator: Map, + rating: Option, +} + +#[test] +fn test_yaml_file_without_envvars() { + temp_env::with_vars( + [("LONGITUDE", None as Option<&str>), ("REVIEWS", None), ("NAME", None)], + || { + let c = Config::builder() + .add_source(File::from_str( + r#" +debug: true +production: false +arr: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +place: + name: Torre di Pisa + longitude: ${LONGITUDE:-43.7224985} + latitude: 10.3970522 + favorite: false + reviews: ${REVIEWS:-3866} + rating: 4.5 + creator: + name: ${NAME:-John Smith} + username: jsmith + email: jsmith@localhost +# For override tests +FOO: FOO should be overridden +bar: I am bar +nullable: null +"#, + FileFormat::Yaml, + )) + .build() + .unwrap(); + + // Deserialize the entire file as single struct + let s: Settings = c.try_deserialize().unwrap(); + + assert!(s.debug.approx_eq_ulps(&1.0, 2)); + assert_eq!(s.production, Some("false".to_owned())); + assert_eq!(s.place.name, "Torre di Pisa"); + assert!(s.place.longitude.approx_eq_ulps(&43.722_498_5, 2)); + assert!(s.place.latitude.approx_eq_ulps(&10.397_052_2, 2)); + assert!(!s.place.favorite); + assert_eq!(s.place.reviews, 3866); + assert_eq!(s.place.rating, Some(4.5)); + assert_eq!(s.place.telephone, None); + assert_eq!(s.elements.len(), 10); + assert_eq!(s.elements[3], "4".to_owned()); + if cfg!(feature = "preserve_order") { + assert_eq!( + s.place + .creator + .into_iter() + .collect::>(), + vec![ + ("name".to_owned(), "John Smith".into()), + ("username".into(), "jsmith".into()), + ("email".into(), "jsmith@localhost".into()), + ] + ); + } else { + assert_eq!( + s.place.creator["name"].clone().into_string().unwrap(), + "John Smith".to_owned() + ); + } + assert_eq!(s.nullable, None); + }, + ) +} + +#[test] +fn test_yaml_file_with_env_vars() { + temp_env::with_vars( + [("LONGITUDE", Some("43.0224985")), ("REVIEWS", Some("3066")), ("NAME",Some("John Watts")),("SIX",Some("6"))], + || { + let c = Config::builder() + .add_source(File::from_str( + r#" +debug: true +production: false +arr: [1, 2, 3, 4, 5, "${SIX:-1}", 7, 8, 9, 10] +place: + name: Torre di Pisa + longitude: ${LONGITUDE:-43.7224985} + latitude: 10.3970522 + favorite: false + reviews: ${REVIEWS:-3866} + rating: 4.5 + creator: + name: ${NAME:-John Smith} + username: jsmith + email: jsmith@localhost +# For override tests +FOO: FOO should be overridden +bar: I am bar +nullable: null +"#, + FileFormat::Yaml, + )) + .build() + .unwrap(); + + // Deserialize the entire file as single struct + let s: Settings = c.try_deserialize().unwrap(); + + assert!(s.debug.approx_eq_ulps(&1.0, 2)); + assert_eq!(s.production, Some("false".to_owned())); + assert_eq!(s.place.name, "Torre di Pisa"); + assert!(s.place.longitude.approx_eq_ulps(&43.022_498_5, 2)); + assert!(s.place.latitude.approx_eq_ulps(&10.397_052_2, 2)); + assert!(!s.place.favorite); + assert_eq!(s.place.reviews, 3066); + assert_eq!(s.place.rating, Some(4.5)); + assert_eq!(s.place.telephone, None); + assert_eq!(s.elements.len(), 10); + assert_eq!(s.elements[3], "4".to_owned()); + assert_eq!(s.elements[5], "6".to_owned()); + if cfg!(feature = "preserve_order") { + assert_eq!( + s.place + .creator + .into_iter() + .collect::>(), + vec![ + ("name".to_owned(), "John Watts".into()), + ("username".into(), "jsmith".into()), + ("email".into(), "jsmith@localhost".into()), + ] + ); + } else { + assert_eq!( + s.place.creator["name"].clone().into_string().unwrap(), + "John Watts".to_owned() + ); + } + assert_eq!(s.nullable, None); + }, + ) +} + + +#[test] +fn test_toml_file_without_envvars() { + #[derive(Debug, Deserialize)] + struct Settings { + debug: f64, + production: Option, + code: AsciiCode, + place: Place, + #[serde(rename = "arr")] + elements: Vec, + } + + #[derive(Debug, Deserialize)] + struct Place { + number: PlaceNumber, + name: String, + longitude: f64, + latitude: f64, + favorite: bool, + telephone: Option, + reviews: u64, + creator: Map, + rating: Option, + } + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct PlaceNumber(u8); + + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct AsciiCode(i8); + + let c = Config::builder() + .add_source(File::from_str( + r#" +debug = true +debug_s = "true" +production = false +production_s = "false" + +code = 53 + +# errors +boolean_s_parse = "fals" + +# For override tests +FOO="FOO should be overridden" +bar="I am bar" + +arr = [1, 2, 3, 4, 5, "${SIX:-6}", 7, 8, 9, 10] +quarks = ["up", "down", "strange", "charm", "bottom", "top"] + +[diodes] +green = "off" + +[diodes.red] +brightness = 100 + +[diodes.blue] +blinking = [300, 700] + +[diodes.white.pattern] +name = "christmas" +inifinite = true + +[[items]] +name = "1" + +[[items]] +name = "2" + +[place] +number = 1 +name = "Torre di Pisa" +longitude = 43.7224985 +latitude = 10.3970522 +favorite = false +reviews = 3866 +rating = 4.5 + +[place.creator] +name = "John Smith" +username = "jsmith" +email = "jsmith@localhost" + +[proton] +up = 2 +down = 1 + +[divisors] +1 = 1 +2 = 2 +4 = 3 +5 = 2 +"#, + FileFormat::Toml, + )) + .build() + .unwrap(); + + // Deserialize the entire file as single struct + let s: Settings = c.try_deserialize().unwrap(); + + assert!(s.debug.approx_eq_ulps(&1.0, 2)); + assert_eq!(s.production, Some("false".to_owned())); + assert_eq!(s.code, AsciiCode(53)); + assert_eq!(s.place.number, PlaceNumber(1)); + assert_eq!(s.place.name, "Torre di Pisa"); + assert!(s.place.longitude.approx_eq_ulps(&43.722_498_5, 2)); + assert!(s.place.latitude.approx_eq_ulps(&10.397_052_2, 2)); + assert!(!s.place.favorite); + assert_eq!(s.place.reviews, 3866); + assert_eq!(s.place.rating, Some(4.5)); + assert_eq!(s.place.telephone, None); + assert_eq!(s.elements.len(), 10); + assert_eq!(s.elements[3], "4".to_owned()); + if cfg!(feature = "preserve_order") { + assert_eq!( + s.place + .creator + .into_iter() + .collect::>(), + vec![ + ("name".to_owned(), "John Smith".into()), + ("username".into(), "jsmith".into()), + ("email".into(), "jsmith@localhost".into()), + ] + ); + } else { + assert_eq!( + s.place.creator["name"].clone().into_string().unwrap(), + "John Smith".to_owned() + ); + } +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 6f9759c8..aac0890c 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -24,3 +24,4 @@ pub mod set; pub mod unsigned_int; pub mod unsigned_int_hm; pub mod weird_keys; +pub mod file_with_env_vars;