Skip to content

Commit 17731d2

Browse files
authored
Merge pull request #26 from 0xvasanth/feat/automatic-nested-loading
feat: Implement automatic nested struct loading
2 parents 36dc786 + 742e332 commit 17731d2

6 files changed

Lines changed: 548 additions & 58 deletions

File tree

gonfig_derive/src/lib.rs

Lines changed: 156 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -154,34 +154,60 @@ struct GonfigField {
154154
/// ```
155155
///
156156
/// ## `#[gonfig(nested)]`
157-
/// **[Experimental]** Marks a field as a nested configuration struct.
157+
/// Marks a field as a nested configuration struct that should be loaded automatically.
158158
///
159-
/// Currently, nested fields must be manually constructed. This attribute prevents
160-
/// the field from being loaded with the parent config.
159+
/// When a field is marked as nested, the macro automatically calls that field type's
160+
/// `from_gonfig()` method, allowing you to compose configuration from multiple structs
161+
/// with their own prefixes and loading logic.
162+
///
163+
/// **Requirements:**
164+
/// - Nested field types must derive `Gonfig`
165+
/// - Nested field types must implement `Default` or have `#[serde(default)]`
166+
/// - Parent struct must mark nested fields with `#[serde(default)]`
161167
///
162168
/// **Example:**
163169
/// ```rust,ignore
164-
/// #[derive(Gonfig, Deserialize)]
165-
/// #[Gonfig(env_prefix = "APP")]
166-
/// struct Config {
167-
/// #[gonfig(nested)]
168-
/// server: ServerConfig,
169-
/// }
170+
/// use gonfig::Gonfig;
171+
/// use serde::{Deserialize, Serialize};
170172
///
171-
/// #[derive(Gonfig, Deserialize)]
173+
/// #[derive(Debug, Deserialize, Gonfig)]
172174
/// #[Gonfig(env_prefix = "SERVER")]
175+
/// #[serde(default)]
173176
/// struct ServerConfig {
177+
/// #[gonfig(default = "127.0.0.1")]
174178
/// host: String,
179+
///
180+
/// #[gonfig(default = "8080")]
181+
/// port: u16,
175182
/// }
176183
///
177-
/// // Manual construction (automatic loading coming in future version):
178-
/// fn load_config() -> Result<Config> {
179-
/// let server = ServerConfig::from_gonfig()?;
180-
/// // Manually combine with parent struct
181-
/// Ok(Config { server })
184+
/// impl Default for ServerConfig {
185+
/// fn default() -> Self {
186+
/// Self { host: String::new(), port: 0 }
187+
/// }
182188
/// }
189+
///
190+
/// #[derive(Debug, Deserialize, Gonfig)]
191+
/// #[Gonfig(env_prefix = "APP")]
192+
/// struct AppConfig {
193+
/// #[gonfig(nested)]
194+
/// #[serde(default)] // Required for nested fields
195+
/// server: ServerConfig,
196+
///
197+
/// #[gonfig(default = "production")]
198+
/// environment: String,
199+
/// }
200+
///
201+
/// // Automatic loading - ServerConfig loads with SERVER_ prefix
202+
/// let config = AppConfig::from_gonfig()?;
203+
/// println!("Server: {}:{}", config.server.host, config.server.port);
183204
/// ```
184205
///
206+
/// **Environment Variables:**
207+
/// - `APP_ENVIRONMENT` → AppConfig.environment
208+
/// - `SERVER_HOST` → ServerConfig.host (nested struct uses its own prefix)
209+
/// - `SERVER_PORT` → ServerConfig.port
210+
///
185211
/// ## `#[skip]` or `#[skip_gonfig]`
186212
/// Exclude a field from configuration loading. Useful for non-serializable fields or
187213
/// fields that should only be set at runtime.
@@ -286,44 +312,50 @@ fn generate_gonfig_impl(opts: &GonfigOpts) -> proc_macro2::TokenStream {
286312
.expect("Only structs are supported")
287313
.fields;
288314

289-
// Separate regular fields from flattened fields
315+
// Separate regular fields from nested fields
290316
let mut regular_mappings = Vec::new();
291317
let mut default_mappings = Vec::new();
318+
let mut nested_fields = Vec::new();
319+
let mut all_fields = Vec::new(); // Track all fields for manual construction
292320

293321
for f in fields.iter().filter(|f| !f.skip_gonfig && !f.skip) {
294322
let field_name = f.ident.as_ref().unwrap();
295323
let field_str = field_name.to_string();
324+
let field_type = &f.ty;
296325

297-
// Skip nested fields - they should be loaded separately
298-
// TODO: Implement automatic nested struct loading with prefix composition
326+
// Collect nested fields for automatic loading
299327
if f.nested {
328+
nested_fields.push((field_name.clone(), field_type.clone()));
329+
all_fields.push((field_name.clone(), true)); // Mark as nested
300330
continue;
301331
}
302332

333+
all_fields.push((field_name.clone(), false)); // Mark as regular
334+
303335
// Note: flatten feature is not yet fully implemented
304336
// For now, treat all fields as regular fields
305337
{
306-
// Generate expected environment variable name
307-
let env_key = if let Some(custom_name) = &f.env_name {
308-
// Use custom name directly if provided
309-
custom_name.clone()
310-
} else if !env_prefix.is_empty() {
311-
// Use prefix + field name pattern
312-
format!("{}_{}", env_prefix, field_str.to_uppercase())
313-
} else {
314-
// Just field name in uppercase
315-
field_str.to_uppercase()
316-
};
317-
318338
// Generate CLI argument name (kebab-case)
319339
let cli_key = if let Some(custom_name) = &f.cli_name {
320340
custom_name.clone()
321341
} else {
322342
field_str.replace('_', "-")
323343
};
324344

345+
// Store field info for runtime env key computation
346+
// We can't pre-compute env_key because it depends on composed_prefix
347+
let custom_env_opt = if let Some(custom) = &f.env_name {
348+
quote! { Some(#custom.to_string()) }
349+
} else {
350+
quote! { None }
351+
};
352+
325353
regular_mappings.push(quote! {
326-
(#field_str.to_string(), #env_key.to_string(), #cli_key.to_string())
354+
(
355+
#field_str.to_string(),
356+
#custom_env_opt,
357+
#cli_key.to_string()
358+
)
327359
});
328360

329361
// Handle default values
@@ -335,15 +367,40 @@ fn generate_gonfig_impl(opts: &GonfigOpts) -> proc_macro2::TokenStream {
335367
}
336368
}
337369

370+
// Prepare nested field names and types for code generation
371+
let has_nested = !nested_fields.is_empty();
372+
let nested_field_names: Vec<_> = nested_fields.iter().map(|(name, _)| name).collect();
373+
let nested_field_types: Vec<_> = nested_fields.iter().map(|(_, ty)| ty).collect();
374+
338375
quote! {
339376
impl #impl_generics #name #ty_generics #where_clause {
340377
pub fn from_gonfig() -> ::gonfig::Result<Self> {
341-
Self::from_gonfig_with_builder(::gonfig::ConfigBuilder::new())
378+
Self::from_gonfig_with_parent_prefix("")
342379
}
343380

344-
pub fn from_gonfig_with_builder(mut builder: ::gonfig::ConfigBuilder) -> ::gonfig::Result<Self> {
345-
// Regular field mappings: (field_name, env_key, cli_key)
346-
let field_mappings: Vec<(String, String, String)> = vec![#(#regular_mappings),*];
381+
/// Load configuration with a parent prefix for hierarchical composition.
382+
/// When used as a nested config, the parent prefix is automatically prepended.
383+
pub fn from_gonfig_with_parent_prefix(parent_prefix: &str) -> ::gonfig::Result<Self> {
384+
Self::from_gonfig_with_builder_and_parent(::gonfig::ConfigBuilder::new(), parent_prefix)
385+
}
386+
387+
pub fn from_gonfig_with_builder(builder: ::gonfig::ConfigBuilder) -> ::gonfig::Result<Self> {
388+
Self::from_gonfig_with_builder_and_parent(builder, "")
389+
}
390+
391+
fn from_gonfig_with_builder_and_parent(mut builder: ::gonfig::ConfigBuilder, parent_prefix: &str) -> ::gonfig::Result<Self> {
392+
// Compose prefix: parent_prefix + current env_prefix
393+
let composed_prefix = if parent_prefix.is_empty() {
394+
#env_prefix.to_string()
395+
} else if #env_prefix.is_empty() {
396+
parent_prefix.to_string()
397+
} else {
398+
format!("{}_{}", parent_prefix, #env_prefix)
399+
};
400+
401+
// Regular field mappings: (field_name, custom_env_name, cli_key)
402+
// env_key will be computed at runtime using composed_prefix
403+
let field_mappings: Vec<(String, Option<String>, String)> = vec![#(#regular_mappings),*];
347404

348405
// Default value mappings: (field_name, default_value)
349406
let default_values: Vec<(String, String)> = vec![#(#default_mappings),*];
@@ -352,13 +409,21 @@ fn generate_gonfig_impl(opts: &GonfigOpts) -> proc_macro2::TokenStream {
352409
// Create custom environment source with field mappings
353410
let mut env = ::gonfig::Environment::new();
354411

355-
if !#env_prefix.is_empty() {
356-
env = env.with_prefix(#env_prefix);
412+
if !composed_prefix.is_empty() {
413+
env = env.with_prefix(&composed_prefix);
357414
}
358415

359416
// Apply field-level mappings for regular fields
360-
for (field_name, env_key, _cli_key) in &field_mappings {
361-
env = env.with_field_mapping(field_name, env_key);
417+
// Compute env_key at runtime using composed_prefix
418+
for (field_name, custom_env_name, _cli_key) in &field_mappings {
419+
let env_key = if let Some(custom) = custom_env_name {
420+
custom.clone()
421+
} else if !composed_prefix.is_empty() {
422+
format!("{}_{}", composed_prefix, field_name.to_uppercase())
423+
} else {
424+
field_name.to_uppercase()
425+
};
426+
env = env.with_field_mapping(field_name, &env_key);
362427
}
363428

364429
builder = builder.with_env_custom(env);
@@ -369,7 +434,7 @@ fn generate_gonfig_impl(opts: &GonfigOpts) -> proc_macro2::TokenStream {
369434
let mut cli = ::gonfig::Cli::from_args();
370435

371436
// Apply field-level CLI mappings for regular fields
372-
for (field_name, _env_key, cli_key) in &field_mappings {
437+
for (field_name, _custom_env_name, cli_key) in &field_mappings {
373438
cli = cli.with_field_mapping(field_name, cli_key);
374439
}
375440

@@ -411,27 +476,69 @@ fn generate_gonfig_impl(opts: &GonfigOpts) -> proc_macro2::TokenStream {
411476
builder = builder.with_defaults(::serde_json::Value::Object(defaults_json))?;
412477
}
413478

414-
// Build the final configuration with explicit type
415-
builder.build::<Self>()
479+
// Build the final configuration
480+
if #has_nested {
481+
// Struct has nested fields - load them automatically with composed prefix
482+
// Each nested struct inherits and composes the parent's prefix
483+
#(
484+
let #nested_field_names = <#nested_field_types>::from_gonfig_with_parent_prefix(&composed_prefix)?;
485+
)*
486+
487+
// Build config value for regular fields (excluding nested fields to avoid conflicts)
488+
let mut config_value = builder.build_value()?;
489+
490+
// Remove nested fields from config_value to avoid conflicts with regular field mapping
491+
if let ::serde_json::Value::Object(ref mut map) = config_value {
492+
#(
493+
map.remove(stringify!(#nested_field_names));
494+
)*
495+
}
496+
497+
// Deserialize into Self with nested fields temporarily set to default
498+
let mut result: Self = ::serde_json::from_value(config_value)
499+
.map_err(|e| ::gonfig::Error::Serialization(
500+
format!("Failed to deserialize config: {}", e)
501+
))?;
502+
503+
// Replace nested fields with loaded values
504+
#(
505+
result.#nested_field_names = #nested_field_names;
506+
)*
507+
508+
Ok(result)
509+
} else {
510+
// No nested fields - use simple deserialization
511+
builder.build::<Self>()
512+
}
416513
}
417514

418515
pub fn gonfig_builder() -> ::gonfig::ConfigBuilder {
419516
let mut builder = ::gonfig::ConfigBuilder::new();
420517

421-
// Regular field mappings: (field_name, env_key, cli_key)
422-
let field_mappings: Vec<(String, String, String)> = vec![#(#regular_mappings),*];
518+
// Regular field mappings: (field_name, custom_env_name, cli_key)
519+
let field_mappings: Vec<(String, Option<String>, String)> = vec![#(#regular_mappings),*];
520+
521+
// Use env_prefix directly (no parent composition in builder method)
522+
let prefix = #env_prefix;
423523

424524
if #allow_env {
425525
// Create custom environment source with field mappings
426526
let mut env = ::gonfig::Environment::new();
427527

428-
if !#env_prefix.is_empty() {
429-
env = env.with_prefix(#env_prefix);
528+
if !prefix.is_empty() {
529+
env = env.with_prefix(prefix);
430530
}
431531

432532
// Apply field-level mappings for regular fields
433-
for (field_name, env_key, _cli_key) in &field_mappings {
434-
env = env.with_field_mapping(field_name, env_key);
533+
for (field_name, custom_env_name, _cli_key) in &field_mappings {
534+
let env_key = if let Some(custom) = custom_env_name {
535+
custom.clone()
536+
} else if !prefix.is_empty() {
537+
format!("{}_{}", prefix, field_name.to_uppercase())
538+
} else {
539+
field_name.to_uppercase()
540+
};
541+
env = env.with_field_mapping(field_name, &env_key);
435542
}
436543

437544
builder = builder.with_env_custom(env);
@@ -442,7 +549,7 @@ fn generate_gonfig_impl(opts: &GonfigOpts) -> proc_macro2::TokenStream {
442549
let mut cli = ::gonfig::Cli::from_args();
443550

444551
// Apply field-level CLI mappings for regular fields
445-
for (field_name, _env_key, cli_key) in &field_mappings {
552+
for (field_name, _custom_env_name, cli_key) in &field_mappings {
446553
cli = cli.with_field_mapping(field_name, cli_key);
447554
}
448555

tests/issue_18_nested_env_test.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ struct PoolSettings {
4545

4646
#[test]
4747
fn test_issue_18_basic_nested_override() -> Result<(), Box<dyn std::error::Error>> {
48+
// Clean environment first to avoid test interference
49+
env::remove_var("APP_HTTP_PORT");
50+
env::remove_var("APP_DATABASE_NAME");
51+
env::remove_var("APP_DATABASE_POOL_MAXSIZE");
52+
env::remove_var("APP_SERVICE_VERSION");
53+
env::remove_var("APP_HTTP_TIMEOUT");
54+
env::remove_var("APP_DATABASE_HOST");
55+
env::remove_var("APP_DATABASE_POOL_MINSIZE");
56+
4857
// Create temp config file with nested structure
4958
let mut file = NamedTempFile::new()?;
5059
writeln!(
@@ -102,6 +111,15 @@ database:
102111

103112
#[test]
104113
fn test_issue_18_deep_nested_override() -> Result<(), Box<dyn std::error::Error>> {
114+
// Clean environment first to avoid test interference
115+
env::remove_var("APP_HTTP_PORT");
116+
env::remove_var("APP_DATABASE_NAME");
117+
env::remove_var("APP_DATABASE_POOL_MAXSIZE");
118+
env::remove_var("APP_SERVICE_VERSION");
119+
env::remove_var("APP_HTTP_TIMEOUT");
120+
env::remove_var("APP_DATABASE_HOST");
121+
env::remove_var("APP_DATABASE_POOL_MINSIZE");
122+
105123
// Test 3-level nesting: database.pool.maxsize
106124
let mut file = NamedTempFile::new()?;
107125
writeln!(
@@ -152,6 +170,15 @@ database:
152170

153171
#[test]
154172
fn test_issue_18_multiple_nested_overrides() -> Result<(), Box<dyn std::error::Error>> {
173+
// Clean environment first to avoid test interference
174+
env::remove_var("APP_HTTP_PORT");
175+
env::remove_var("APP_DATABASE_NAME");
176+
env::remove_var("APP_DATABASE_POOL_MAXSIZE");
177+
env::remove_var("APP_SERVICE_VERSION");
178+
env::remove_var("APP_HTTP_TIMEOUT");
179+
env::remove_var("APP_DATABASE_HOST");
180+
env::remove_var("APP_DATABASE_POOL_MINSIZE");
181+
155182
// Test multiple env vars overriding different nested levels
156183
let mut file = NamedTempFile::new()?;
157184
writeln!(

0 commit comments

Comments
 (0)