diff --git a/README.md b/README.md index ced71b6..b27db7a 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,39 @@ pub struct Config { } ``` +### Nested configs with prefix + +You can also nest configs with a prefix, which will strip the prefix from environment variables before passing them to the nested config. This is useful for grouping related environment variables. + +```rust +#[derive(Envconfig)] +pub struct DbConfig { + #[envconfig(from = "HOST")] // Will use DB_HOST from environment + pub host: String, + + #[envconfig(from = "PORT", default = "5432")] // Will use DB_PORT from environment + pub port: u16, +} + +#[derive(Envconfig)] +pub struct CacheConfig { + #[envconfig(from = "HOST")] // Will use CACHE_HOST from environment + pub host: String, + + #[envconfig(from = "PORT", default = "6379")] // Will use CACHE_PORT from environment + pub port: u16, +} + +#[derive(Envconfig)] +pub struct Config { + #[envconfig(nested, prefix = "DB_")] // <--- + db: DbConfig, + + #[envconfig(nested, prefix = "CACHE_")] // <--- + cache: CacheConfig, +} +``` + ### Custom types diff --git a/envconfig/src/lib.rs b/envconfig/src/lib.rs index 7a1970a..92328d0 100644 --- a/envconfig/src/lib.rs +++ b/envconfig/src/lib.rs @@ -32,6 +32,40 @@ //! assert_eq!(config.http_port, 8080); //! ``` //! +//! ## Nested Configuration with Prefixes +//! +//! You can also nest configuration structures with prefixes: +//! +//! ``` +//! use std::env; +//! use envconfig::Envconfig; +//! +//! #[derive(Envconfig)] +//! struct DbConfig { +//! #[envconfig(from = "HOST")] // Will look for DB_HOST +//! pub host: String, +//! +//! #[envconfig(from = "PORT")] // Will look for DB_PORT +//! pub port: u16, +//! } +//! +//! #[derive(Envconfig)] +//! struct Config { +//! #[envconfig(nested, prefix = "DB_")] +//! pub db: DbConfig, +//! } +//! +//! // Set environment variables with prefixes +//! env::set_var("DB_HOST", "localhost"); +//! env::set_var("DB_PORT", "5432"); +//! +//! // Initialize config from environment variables +//! let config = Config::init_from_env().unwrap(); +//! +//! assert_eq!(config.db.host, "localhost"); +//! assert_eq!(config.db.port, 5432); +//! ``` +//! //! The library uses `std::str::FromStr` trait to convert environment variables into custom //! data type. So, if your data type does not implement `std::str::FromStr` the program //! will not compile. diff --git a/envconfig_derive/src/lib.rs b/envconfig_derive/src/lib.rs index 6321263..1283fae 100644 --- a/envconfig_derive/src/lib.rs +++ b/envconfig_derive/src/lib.rs @@ -99,7 +99,16 @@ fn gen_field_assign(field: &Field, source: &Source) -> proc_macro2::TokenStream // If nested attribute is present let nested_value_opt = find_item_in_list(&list, "nested"); match nested_value_opt { - Some(MatchingItem::NoValue) => return gen_field_assign_for_struct_type(field, source), + Some(MatchingItem::NoValue) => { + // Check if there's a prefix attribute + let prefix_opt = find_item_in_list(&list, "prefix"); + let prefix = match prefix_opt { + Some(MatchingItem::WithValue(prefix_lit)) => Some(prefix_lit), + Some(MatchingItem::NoValue) => panic!("`prefix` attribute must have a value"), + None => None, + }; + return gen_field_assign_for_struct_type(field, source, prefix); + } Some(MatchingItem::WithValue(_)) => { panic!("`nested` attribute must not have a value") } @@ -157,17 +166,57 @@ fn gen( /// Generates the derived field assignment for a (nested) struct type /// /// # Panics -/// Panics if the field type is not a path -fn gen_field_assign_for_struct_type(field: &Field, source: &Source) -> proc_macro2::TokenStream { +/// Panics if the field type is not a path or if the prefix is not a string literal +fn gen_field_assign_for_struct_type(field: &Field, source: &Source, prefix: Option<&Lit>) -> proc_macro2::TokenStream { let ident: &Option = &field.ident; + match &field.ty { - syn::Type::Path(path) => match source { - Source::Environment => quote! { - #ident: #path :: init_from_env()? - }, - Source::HashMap => quote! { - #ident: #path :: init_from_hashmap(hashmap)? - }, + syn::Type::Path(path) => { + // Use pattern matching to handle the prefix + let Some(prefix_lit) = prefix else { + // If there's no prefix, use the simple form + return match source { + Source::Environment => quote! { + #ident: #path :: init_from_env()? + }, + Source::HashMap => quote! { + #ident: #path :: init_from_hashmap(hashmap)? + }, + }; + }; + + // Otherwise, process with prefix + let prefix_str = match prefix_lit { + Lit::Str(s) => s.value(), + _ => panic!("Expected prefix to be a string literal"), + }; + + match source { + Source::Environment => quote! { + #ident: { + // Create a new hashmap with prefixed environment variables + let mut prefixed_env = ::std::collections::HashMap::new(); + for (key, value) in ::std::env::vars() { + if let Some(suffix) = key.strip_prefix(#prefix_str) { + prefixed_env.insert(suffix.to_string(), value); + } + } + #path :: init_from_hashmap(&prefixed_env)? + } + }, + Source::HashMap => quote! { + #ident: { + // Create a new hashmap with prefixed variables + let mut prefixed_map = ::std::collections::HashMap::new(); + for (key, value) in hashmap.iter() { + if key.starts_with(#prefix_str) { + prefixed_map.insert(key[#prefix_str.len()..].to_string(), value.clone()); + } + } + #path :: init_from_hashmap(&prefixed_map)? + } + }, + } }, _ => panic!("Expected field type to be a path: {ident:?}",), } diff --git a/test_suite/tests/nested_prefix.rs b/test_suite/tests/nested_prefix.rs new file mode 100644 index 0000000..830e6d9 --- /dev/null +++ b/test_suite/tests/nested_prefix.rs @@ -0,0 +1,145 @@ +extern crate envconfig; + +use envconfig::{Envconfig, Error}; +use std::collections::HashMap; +use std::env; + +#[derive(Envconfig)] +pub struct DBConfig { + #[envconfig(from = "HOST")] + pub host: String, + #[envconfig(from = "PORT")] + pub port: u16, +} + +#[derive(Envconfig)] +pub struct Config { + #[envconfig(nested, prefix = "DB_")] + pub db: DBConfig, + + #[envconfig(nested, prefix = "CACHE_")] + pub cache: CacheConfig, +} + +#[derive(Envconfig)] +pub struct CacheConfig { + #[envconfig(from = "HOST")] + pub host: String, + + #[envconfig(from = "PORT", default = "6379")] + pub port: u16, +} + +#[derive(Envconfig)] +pub struct MultiConfig { + #[envconfig(nested, prefix = "DB1_")] + pub db1: DBConfig, + + #[envconfig(nested, prefix = "DB2_")] + pub db2: DBConfig, +} + +fn setup() { + // Clean up all environment variables we might use in tests + env::remove_var("DB_HOST"); + env::remove_var("DB_PORT"); + env::remove_var("CACHE_HOST"); + env::remove_var("CACHE_PORT"); + env::remove_var("DB1_HOST"); + env::remove_var("DB1_PORT"); + env::remove_var("DB2_HOST"); + env::remove_var("DB2_PORT"); +} + +#[test] +fn test_nested_prefix_env() { + setup(); + + env::set_var("DB_HOST", "localhost"); + env::set_var("DB_PORT", "5432"); + env::set_var("CACHE_HOST", "redis"); + // CACHE_PORT uses default value + + let config = Config::init_from_env().unwrap(); + assert_eq!(config.db.host, "localhost"); + assert_eq!(config.db.port, 5432u16); + assert_eq!(config.cache.host, "redis"); + assert_eq!(config.cache.port, 6379u16); +} + +#[test] +fn test_nested_prefix_hashmap() { + setup(); + + let mut hashmap = HashMap::new(); + hashmap.insert("DB_HOST".to_string(), "localhost".to_string()); + hashmap.insert("DB_PORT".to_string(), "5432".to_string()); + hashmap.insert("CACHE_HOST".to_string(), "redis".to_string()); + // CACHE_PORT uses default value + + let config = Config::init_from_hashmap(&hashmap).unwrap(); + assert_eq!(config.db.host, "localhost"); + assert_eq!(config.db.port, 5432u16); + assert_eq!(config.cache.host, "redis"); + assert_eq!(config.cache.port, 6379u16); +} + +#[test] +fn test_nested_prefix_env_error() { + setup(); + + env::set_var("DB_HOST", "localhost"); + env::set_var("CACHE_HOST", "redis"); + // DB_PORT is missing + + let err = Config::init_from_env().err().unwrap(); + let expected_err = Error::EnvVarMissing { name: "PORT" }; + assert_eq!(err, expected_err); +} + +#[test] +fn test_nested_prefix_hashmap_error() { + setup(); + + let mut hashmap = HashMap::new(); + hashmap.insert("DB_HOST".to_string(), "localhost".to_string()); + hashmap.insert("CACHE_HOST".to_string(), "redis".to_string()); + // DB_PORT is missing + + let err = Config::init_from_hashmap(&hashmap).err().unwrap(); + let expected_err = Error::EnvVarMissing { name: "PORT" }; + assert_eq!(err, expected_err); +} + +#[test] +fn test_multiple_nested_prefix_env() { + setup(); + + env::set_var("DB1_HOST", "primary"); + env::set_var("DB1_PORT", "5432"); + env::set_var("DB2_HOST", "replica"); + env::set_var("DB2_PORT", "5433"); + + let config = MultiConfig::init_from_env().unwrap(); + assert_eq!(config.db1.host, "primary"); + assert_eq!(config.db1.port, 5432u16); + assert_eq!(config.db2.host, "replica"); + assert_eq!(config.db2.port, 5433u16); +} + +#[test] +fn test_multiple_nested_prefix_hashmap() { + setup(); + + let mut hashmap = HashMap::new(); + hashmap.insert("DB1_HOST".to_string(), "primary".to_string()); + hashmap.insert("DB1_PORT".to_string(), "5432".to_string()); + hashmap.insert("DB2_HOST".to_string(), "replica".to_string()); + hashmap.insert("DB2_PORT".to_string(), "5433".to_string()); + + let config = MultiConfig::init_from_hashmap(&hashmap).unwrap(); + assert_eq!(config.db1.host, "primary"); + assert_eq!(config.db1.port, 5432u16); + assert_eq!(config.db2.host, "replica"); + assert_eq!(config.db2.port, 5433u16); +} \ No newline at end of file