diff --git a/Cargo.toml b/Cargo.toml index 3272b1f..9f750a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,12 @@ name = "gonfig" version = "0.1.7" edition = "2021" -authors = ["Vasanthkumar Kalaiselvan"] +authors = ["Vasanthkumar Kalaiselvan<0xvasanth@gmail.com>"] description = "A unified configuration management library for Rust that seamlessly integrates environment variables, config files, and CLI arguments" license = "MIT OR Apache-2.0" -repository = "https://github.com/itsparser/gonfig" +repository = "https://github.com/0xvasanth/gonfig" documentation = "https://docs.rs/gonfig" -homepage = "https://github.com/itsparser/gonfig" +homepage = "https://github.com/0xvasanth/gonfig" readme = "README.md" keywords = ["config", "configuration", "cli", "environment", "settings"] categories = ["config", "command-line-interface"] diff --git a/examples/issue_18_verification.rs b/examples/issue_18_verification.rs new file mode 100644 index 0000000..93af783 --- /dev/null +++ b/examples/issue_18_verification.rs @@ -0,0 +1,233 @@ +use gonfig::{ConfigBuilder, ConfigFormat, Environment, MergeStrategy}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use tempfile::NamedTempFile; + +/// Multi-level nested configuration structure +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct AppConfig { + service: ServiceConfig, + http: HttpConfig, + database: DatabaseConfig, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct ServiceConfig { + name: String, + version: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct HttpConfig { + host: String, + port: u16, + timeout: u32, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct DatabaseConfig { + host: String, + port: u16, + name: String, + pool: PoolConfig, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct PoolConfig { + minsize: u32, + maxsize: u32, +} + +fn main() -> gonfig::Result<()> { + println!("=== Issue #18 Verification: Nested Environment Variable Override ===\n"); + + // Create a temporary config file with nested values + let mut config_file = NamedTempFile::new().expect("Failed to create temp file"); + writeln!( + config_file, + r#" +service: + name: "MyApp" + version: "1.0.0" + +http: + host: "127.0.0.1" + port: 3000 + timeout: 30 + +database: + host: "localhost" + port: 5432 + name: "production_db" + pool: + minsize: 5 + maxsize: 20 +"# + ) + .expect("Failed to write config"); + config_file.flush().expect("Failed to flush"); + + println!("1. Loading configuration FROM FILE ONLY:"); + println!(" Config file path: {:?}", config_file.path()); + + let config_file_only: AppConfig = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(config_file.path(), ConfigFormat::Yaml)? + .build()?; + + println!( + " Service: {} v{}", + config_file_only.service.name, config_file_only.service.version + ); + println!( + " HTTP: {}:{} (timeout: {}s)", + config_file_only.http.host, config_file_only.http.port, config_file_only.http.timeout + ); + println!( + " Database: {}:{}/{}", + config_file_only.database.host, + config_file_only.database.port, + config_file_only.database.name + ); + println!( + " Pool: min={}, max={}", + config_file_only.database.pool.minsize, config_file_only.database.pool.maxsize + ); + + // Verify file values + assert_eq!( + config_file_only.http.port, 3000, + "File config should have port 3000" + ); + assert_eq!( + config_file_only.database.pool.maxsize, 20, + "File config should have maxsize 20" + ); + + println!("\n2. Setting ENVIRONMENT VARIABLES to override nested values:"); + std::env::set_var("APP_HTTP_PORT", "9000"); + std::env::set_var("APP_HTTP_TIMEOUT", "60"); + std::env::set_var("APP_DATABASE_POOL_MAXSIZE", "50"); + std::env::set_var("APP_DATABASE_NAME", "staging_db"); + + println!(" APP_HTTP_PORT=9000"); + println!(" APP_HTTP_TIMEOUT=60"); + println!(" APP_DATABASE_POOL_MAXSIZE=50"); + println!(" APP_DATABASE_NAME=staging_db"); + + println!("\n3. Loading configuration WITH NESTED ENV OVERRIDE:"); + let config_with_env: AppConfig = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(config_file.path(), ConfigFormat::Yaml)? + .with_env_custom(Environment::new().with_prefix("APP").nested(true)) + .build()?; + + println!( + " Service: {} v{}", + config_with_env.service.name, config_with_env.service.version + ); + println!( + " HTTP: {}:{} (timeout: {}s)", + config_with_env.http.host, config_with_env.http.port, config_with_env.http.timeout + ); + println!( + " Database: {}:{}/{}", + config_with_env.database.host, config_with_env.database.port, config_with_env.database.name + ); + println!( + " Pool: min={}, max={}", + config_with_env.database.pool.minsize, config_with_env.database.pool.maxsize + ); + + println!("\n4. VERIFICATION RESULTS:"); + + // Critical assertion: env vars should override file values + if config_with_env.http.port == 9000 { + println!(" ✅ PASS: HTTP port overridden by env (9000)"); + } else { + println!( + " ❌ FAIL: HTTP port NOT overridden (expected 9000, got {})", + config_with_env.http.port + ); + panic!("Issue #18 NOT FIXED: Environment variable failed to override nested config value"); + } + + if config_with_env.http.timeout == 60 { + println!(" ✅ PASS: HTTP timeout overridden by env (60)"); + } else { + println!( + " ❌ FAIL: HTTP timeout NOT overridden (expected 60, got {})", + config_with_env.http.timeout + ); + panic!("Issue #18 NOT FIXED: Environment variable failed to override nested config value"); + } + + if config_with_env.database.pool.maxsize == 50 { + println!(" ✅ PASS: Database pool maxsize overridden by env (50)"); + } else { + println!( + " ❌ FAIL: Database pool maxsize NOT overridden (expected 50, got {})", + config_with_env.database.pool.maxsize + ); + panic!("Issue #18 NOT FIXED: Environment variable failed to override deeply nested config value"); + } + + if config_with_env.database.name == "staging_db" { + println!(" ✅ PASS: Database name overridden by env (staging_db)"); + } else { + println!( + " ❌ FAIL: Database name NOT overridden (expected staging_db, got {})", + config_with_env.database.name + ); + panic!("Issue #18 NOT FIXED: Environment variable failed to override nested config value"); + } + + // Verify non-overridden values remain from file + assert_eq!( + config_with_env.http.host, "127.0.0.1", + "Non-overridden values should remain from file" + ); + assert_eq!( + config_with_env.service.name, "MyApp", + "Non-overridden values should remain from file" + ); + println!(" ✅ PASS: Non-overridden values preserved from config file"); + + println!("\n5. Testing WITHOUT nested mode (backward compatibility):"); + let config_flat: Result = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(config_file.path(), ConfigFormat::Yaml)? + .with_env_custom(Environment::new().with_prefix("APP").nested(false)) + .build(); + + match config_flat { + Ok(cfg) => { + println!(" Config loaded with nested=false"); + println!( + " HTTP port: {} (should be from file: 3000)", + cfg.http.port + ); + if cfg.http.port == 3000 { + println!( + " ✅ PASS: Backward compatibility maintained - nested=false keeps flat keys" + ); + } + } + Err(e) => { + println!(" Note: Config might fail without nested mode (expected): {e}"); + } + } + + println!("\n=== CONCLUSION ==="); + println!("✅ Issue #18 is FIXED!"); + println!(" Environment variables now properly override nested config file values"); + println!(" when using .nested(true) with Deep merge strategy."); + + // Clean up + std::env::remove_var("APP_HTTP_PORT"); + std::env::remove_var("APP_HTTP_TIMEOUT"); + std::env::remove_var("APP_DATABASE_POOL_MAXSIZE"); + std::env::remove_var("APP_DATABASE_NAME"); + + Ok(()) +} diff --git a/src/environment.rs b/src/environment.rs index 35e6923..4308ec2 100644 --- a/src/environment.rs +++ b/src/environment.rs @@ -56,6 +56,7 @@ pub struct Environment { case_sensitive: bool, overrides: HashMap, field_mappings: HashMap, + nested: bool, } impl Default for Environment { @@ -66,6 +67,7 @@ impl Default for Environment { case_sensitive: false, overrides: HashMap::new(), field_mappings: HashMap::new(), + nested: false, } } } @@ -194,6 +196,29 @@ impl Environment { self } + /// Enable nested mode to convert flat environment variable keys into nested structures. + /// + /// When enabled, environment variables with the configured separator (default: `_`) will be split + /// into nested paths. For example, `APP_HTTP_PORT=9000` becomes `{"http": {"port": 9000}}`. + /// + /// This is essential for properly overriding nested configuration file values with + /// environment variables when using the Deep merge strategy. + /// + /// # Examples + /// + /// ```rust + /// use gonfig::{Environment, ConfigBuilder, MergeStrategy}; + /// + /// // With nested=true, APP_HTTP_PORT will override http.port in config file + /// let env = Environment::new() + /// .with_prefix("APP") + /// .nested(true); + /// ``` + pub fn nested(mut self, nested: bool) -> Self { + self.nested = nested; + self + } + fn build_env_key(&self, path: &[&str]) -> String { let mut parts = Vec::new(); @@ -242,6 +267,43 @@ impl Environment { json!(value) } + /// Recursively insert a value into a nested map structure based on a path of keys. + /// + /// This helper function takes a flat key path (e.g., ["http", "server", "port"]) + /// and creates the necessary nested structure in the map, inserting the value + /// at the deepest level. + fn insert_nested(map: &mut Map, parts: &[&str], value: Value) { + if parts.is_empty() { + return; + } + + if parts.len() == 1 { + // Base case: insert the value at this key + map.insert(parts[0].to_string(), value); + return; + } + + // Recursive case: get or create the nested object + let key = parts[0].to_string(); + match map.entry(key) { + serde_json::map::Entry::Occupied(mut occ) => { + if let Value::Object(ref mut nested) = occ.get_mut() { + Self::insert_nested(nested, &parts[1..], value); + } else { + // Replace non-object with a new object containing the nested value + let mut new_map = Map::new(); + Self::insert_nested(&mut new_map, &parts[1..], value); + *occ.get_mut() = Value::Object(new_map); + } + } + serde_json::map::Entry::Vacant(vac) => { + let mut new_map = Map::new(); + Self::insert_nested(&mut new_map, &parts[1..], value); + vac.insert(Value::Object(new_map)); + } + } + } + pub fn collect_for_struct( &self, struct_name: &str, @@ -303,7 +365,13 @@ impl Environment { if key_check.starts_with(&prefix_str) { let trimmed = key_check[prefix_str.len()..].trim_start_matches(&self.separator); - flat_map.insert(trimmed.to_lowercase(), Self::parse_env_value(&value)); + // Keep case for nested mode, lowercase for flat mode + let key_for_map = if self.nested { + trimmed.to_string() + } else { + trimmed.to_lowercase() + }; + flat_map.insert(key_for_map, Self::parse_env_value(&value)); } } else { flat_map.insert(key.to_lowercase(), Self::parse_env_value(&value)); @@ -327,10 +395,13 @@ impl Environment { if key_check.starts_with(&prefix_str) { let trimmed = key_check[prefix_str.len()..].trim_start_matches(&self.separator); - flat_map.insert( - trimmed.to_lowercase(), - Self::parse_env_value(override_value), - ); + // Keep case for nested mode, lowercase for flat mode + let key_for_map = if self.nested { + trimmed.to_string() + } else { + trimmed.to_lowercase() + }; + flat_map.insert(key_for_map, Self::parse_env_value(override_value)); } } else { flat_map.insert( @@ -340,10 +411,28 @@ impl Environment { } } - // Keep keys flat (don't create nested structure) + // Convert flat keys into nested structures if enabled let mut result = Map::new(); for (key, value) in flat_map { - result.insert(key, value); + if self.nested { + // Split on separator to create nested structure + let parts: Vec<&str> = key.split(&self.separator).collect(); + if parts.len() == 1 { + // Single part, insert directly (lowercase it) + result.insert(key.to_lowercase(), value); + } else { + // Multiple parts, create nested structure + // Lowercase each part individually + let lowercase_parts: Vec = + parts.iter().map(|p| p.to_lowercase()).collect(); + let lowercase_parts_refs: Vec<&str> = + lowercase_parts.iter().map(|s| s.as_str()).collect(); + Self::insert_nested(&mut result, &lowercase_parts_refs, value); + } + } else { + // Keep keys flat (backward compatible behavior) + result.insert(key.to_lowercase(), value); + } } Ok(Value::Object(result)) diff --git a/tests/issue_18_nested_env_test.rs b/tests/issue_18_nested_env_test.rs new file mode 100644 index 0000000..0d9f4dd --- /dev/null +++ b/tests/issue_18_nested_env_test.rs @@ -0,0 +1,261 @@ +/// Test for Issue #18: Environment variables should override nested config file values +/// +/// This test verifies that environment variables with nested paths (using separators) +/// properly override corresponding nested values from configuration files when using +/// the Deep merge strategy and nested mode. +use gonfig::{ConfigBuilder, ConfigFormat, Environment, MergeStrategy}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::io::Write; +use tempfile::NamedTempFile; + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct TestConfig { + service: ServiceInfo, + http: HttpSettings, + database: DatabaseSettings, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct ServiceInfo { + name: String, + version: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct HttpSettings { + host: String, + port: u16, + timeout: u32, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct DatabaseSettings { + host: String, + port: u16, + name: String, + pool: PoolSettings, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq)] +struct PoolSettings { + minsize: u32, + maxsize: u32, +} + +#[test] +fn test_issue_18_basic_nested_override() -> Result<(), Box> { + // Create temp config file with nested structure + let mut file = NamedTempFile::new()?; + writeln!( + file, + r#" +service: + name: "TestApp" + version: "1.0.0" +http: + host: "127.0.0.1" + port: 3000 + timeout: 30 +database: + host: "localhost" + port: 5432 + name: "prod_db" + pool: + minsize: 5 + maxsize: 20 +"# + )?; + file.flush()?; + + // Set environment variables to override nested values + env::set_var("APP_HTTP_PORT", "9000"); + env::set_var("APP_DATABASE_NAME", "test_db"); + + let config: TestConfig = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(file.path(), ConfigFormat::Yaml)? + .with_env_custom(Environment::new().with_prefix("APP").nested(true)) + .build()?; + + // Verify env vars overrode config file values + assert_eq!( + config.http.port, 9000, + "HTTP port should be overridden by env var" + ); + assert_eq!( + config.database.name, "test_db", + "Database name should be overridden by env var" + ); + + // Verify non-overridden values remain from file + assert_eq!(config.http.host, "127.0.0.1"); + assert_eq!(config.http.timeout, 30); + assert_eq!(config.service.name, "TestApp"); + + // Cleanup + env::remove_var("APP_HTTP_PORT"); + env::remove_var("APP_DATABASE_NAME"); + + Ok(()) +} + +#[test] +fn test_issue_18_deep_nested_override() -> Result<(), Box> { + // Test 3-level nesting: database.pool.maxsize + let mut file = NamedTempFile::new()?; + writeln!( + file, + r#" +service: + name: "TestApp" + version: "1.0.0" +http: + host: "127.0.0.1" + port: 3000 + timeout: 30 +database: + host: "localhost" + port: 5432 + name: "prod_db" + pool: + minsize: 5 + maxsize: 20 +"# + )?; + file.flush()?; + + // Set env var for deeply nested value + env::set_var("APP_DATABASE_POOL_MAXSIZE", "100"); + + let config: TestConfig = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(file.path(), ConfigFormat::Yaml)? + .with_env_custom(Environment::new().with_prefix("APP").nested(true)) + .build()?; + + // Verify deep nested override + assert_eq!( + config.database.pool.maxsize, 100, + "Deeply nested pool maxsize should be overridden" + ); + assert_eq!( + config.database.pool.minsize, 5, + "Non-overridden nested value should remain" + ); + + // Cleanup + env::remove_var("APP_DATABASE_POOL_MAXSIZE"); + + Ok(()) +} + +#[test] +fn test_issue_18_multiple_nested_overrides() -> Result<(), Box> { + // Test multiple env vars overriding different nested levels + let mut file = NamedTempFile::new()?; + writeln!( + file, + r#" +service: + name: "TestApp" + version: "1.0.0" +http: + host: "127.0.0.1" + port: 3000 + timeout: 30 +database: + host: "localhost" + port: 5432 + name: "prod_db" + pool: + minsize: 5 + maxsize: 20 +"# + )?; + file.flush()?; + + // Override multiple nested values at different levels + env::set_var("APP_SERVICE_VERSION", "2.0.0"); + env::set_var("APP_HTTP_PORT", "8080"); + env::set_var("APP_HTTP_TIMEOUT", "60"); + env::set_var("APP_DATABASE_HOST", "db.example.com"); + env::set_var("APP_DATABASE_POOL_MINSIZE", "10"); + env::set_var("APP_DATABASE_POOL_MAXSIZE", "50"); + + let config: TestConfig = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(file.path(), ConfigFormat::Yaml)? + .with_env_custom(Environment::new().with_prefix("APP").nested(true)) + .build()?; + + // Verify all overrides + assert_eq!(config.service.version, "2.0.0"); + assert_eq!(config.http.port, 8080); + assert_eq!(config.http.timeout, 60); + assert_eq!(config.database.host, "db.example.com"); + assert_eq!(config.database.pool.minsize, 10); + assert_eq!(config.database.pool.maxsize, 50); + + // Verify non-overridden values + assert_eq!(config.service.name, "TestApp"); + assert_eq!(config.http.host, "127.0.0.1"); + + // Cleanup + env::remove_var("APP_SERVICE_VERSION"); + env::remove_var("APP_HTTP_PORT"); + env::remove_var("APP_HTTP_TIMEOUT"); + env::remove_var("APP_DATABASE_HOST"); + env::remove_var("APP_DATABASE_POOL_MINSIZE"); + env::remove_var("APP_DATABASE_POOL_MAXSIZE"); + + Ok(()) +} + +#[test] +fn test_issue_18_backward_compatibility() -> Result<(), Box> { + // Verify that nested=false (default) maintains backward compatibility + let mut file = NamedTempFile::new()?; + writeln!( + file, + r#" +service: + name: "TestApp" + version: "1.0.0" +http: + host: "127.0.0.1" + port: 3000 + timeout: 30 +database: + host: "localhost" + port: 5432 + name: "prod_db" + pool: + minsize: 5 + maxsize: 20 +"# + )?; + file.flush()?; + + // Set env var that would override in nested mode + env::set_var("APP_HTTP_PORT", "9000"); + + // Build with nested=false (default) + let config: TestConfig = ConfigBuilder::new() + .with_merge_strategy(MergeStrategy::Deep) + .with_file_format(file.path(), ConfigFormat::Yaml)? + .with_env_custom(Environment::new().with_prefix("APP").nested(false)) + .build()?; + + // With nested=false, env var won't override nested config value + // because the environment variable "APP_HTTP_PORT" is interpreted as the flat key "http_port", which does not match the expected nested path "http.port" in the config structure + assert_eq!( + config.http.port, 3000, + "Port should remain from file when nested=false" + ); + + // Cleanup + env::remove_var("APP_HTTP_PORT"); + + Ok(()) +}