This is an automated email from the ASF dual-hosted git repository.

piotr pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 42a6ad72f fix(configs): support internally-tagged enums in ConfigEnv 
derive macro (#2631)
42a6ad72f is described below

commit 42a6ad72f939b5381fa070632e21f77668cdcee0
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Tue Jan 27 22:45:28 2026 +0100

    fix(configs): support internally-tagged enums in ConfigEnv derive macro 
(#2631)
    
    Add #[config_env(tag = "...")] attribute to generate env var mappings
    for serde's internal tag field.
    Also skip empty env var values to preserve Option<T> defaults as None.
---
 .github/workflows/_common.yml                      |   1 +
 Cargo.lock                                         |   2 +
 .../configs/src/configs_impl/typed_env_provider.rs |   4 +-
 core/configs_derive/src/config_env.rs              |  20 ++-
 core/connectors/runtime/src/configs/runtime.rs     |   1 +
 core/integration/Cargo.toml                        |   2 +
 core/integration/tests/config_provider/mod.rs      | 157 ++++++++++++++++++++-
 7 files changed, 183 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/_common.yml b/.github/workflows/_common.yml
index 9f8eccf2e..4cca1ea0b 100644
--- a/.github/workflows/_common.yml
+++ b/.github/workflows/_common.yml
@@ -102,6 +102,7 @@ jobs:
             metadata
             message_bus
             storage
+            configs
 
   license-headers:
     name: Check license headers
diff --git a/Cargo.lock b/Cargo.lock
index e87d41682..a19aa1bcd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4991,9 +4991,11 @@ dependencies = [
  "chrono",
  "compio",
  "configs",
+ "configs_derive",
  "ctor",
  "derive_more",
  "env_logger",
+ "figment",
  "futures",
  "humantime",
  "iggy",
diff --git a/core/configs/src/configs_impl/typed_env_provider.rs 
b/core/configs/src/configs_impl/typed_env_provider.rs
index dd8926985..154884f90 100644
--- a/core/configs/src/configs_impl/typed_env_provider.rs
+++ b/core/configs/src/configs_impl/typed_env_provider.rs
@@ -163,8 +163,8 @@ impl<T: ConfigEnvMappings> TypedEnvProvider<T> {
             };
 
             let env_value = match env::var(&env_name) {
-                Ok(val) => val,
-                Err(_) => continue,
+                Ok(val) if !val.is_empty() => val,
+                _ => continue,
             };
 
             let is_secret = mapping.is_secret
diff --git a/core/configs_derive/src/config_env.rs 
b/core/configs_derive/src/config_env.rs
index 1eee37014..cd6019f0e 100644
--- a/core/configs_derive/src/config_env.rs
+++ b/core/configs_derive/src/config_env.rs
@@ -46,6 +46,10 @@ pub struct ConfigEnvOpts {
     /// Metadata name for figment Provider (e.g., "iggy-server-config"). Only 
needed on root config.
     #[darling(default)]
     name: Option<String>,
+
+    /// Tag field name for internally-tagged serde enums (mirrors `#[serde(tag 
= "...")]`).
+    #[darling(default)]
+    tag: Option<String>,
 }
 
 /// Variant-level attributes for `#[config_env(...)]` on enums
@@ -134,6 +138,7 @@ fn generate_from_opts(opts: ConfigEnvOpts) -> TokenStream2 {
             impl_generics,
             ty_generics,
             where_clause,
+            opts.tag,
             variants,
         ),
     }
@@ -146,6 +151,7 @@ fn generate_enum_impl(
     impl_generics: syn::ImplGenerics,
     ty_generics: syn::TypeGenerics,
     where_clause: Option<&syn::WhereClause>,
+    tag: Option<String>,
     variants: Vec<VariantOpts>,
 ) -> TokenStream2 {
     // Collect inner types from newtype variants (single unnamed field)
@@ -164,7 +170,18 @@ fn generate_enum_impl(
         })
         .collect();
 
-    if variant_types.is_empty() {
+    let tag_mapping = tag.map(|tag_name| {
+        let env_segment = tag_name.to_uppercase();
+        quote! {
+            all_mappings.push(configs::EnvVarMapping {
+                env_name: #env_segment,
+                config_path: #tag_name,
+                is_secret: false,
+            });
+        }
+    });
+
+    if variant_types.is_empty() && tag_mapping.is_none() {
         return quote! {
             impl #impl_generics configs::ConfigEnvMappings for #enum_name 
#ty_generics #where_clause {
                 fn env_mappings() -> &'static [configs::EnvVarMapping] {
@@ -190,6 +207,7 @@ fn generate_enum_impl(
                 static MAPPINGS: 
std::sync::OnceLock<Vec<configs::EnvVarMapping>> = std::sync::OnceLock::new();
                 MAPPINGS.get_or_init(|| {
                     let mut all_mappings: Vec<configs::EnvVarMapping> = 
Vec::new();
+                    #tag_mapping
                     #(#extends)*
                     all_mappings
                 })
diff --git a/core/connectors/runtime/src/configs/runtime.rs 
b/core/connectors/runtime/src/configs/runtime.rs
index edcf9fcca..adbae2fb5 100644
--- a/core/connectors/runtime/src/configs/runtime.rs
+++ b/core/connectors/runtime/src/configs/runtime.rs
@@ -256,6 +256,7 @@ pub struct ResponseConfig {
 
 #[allow(clippy::large_enum_variant)]
 #[derive(Debug, Clone, Deserialize, Serialize, ConfigEnv)]
+#[config_env(tag = "config_type")]
 #[serde(tag = "config_type", rename_all = "lowercase")]
 pub enum ConnectorsConfig {
     Local(LocalConnectorsConfig),
diff --git a/core/integration/Cargo.toml b/core/integration/Cargo.toml
index 3e9cb5376..befafc249 100644
--- a/core/integration/Cargo.toml
+++ b/core/integration/Cargo.toml
@@ -34,9 +34,11 @@ bytes = { workspace = true }
 chrono = { workspace = true }
 compio = { workspace = true }
 configs = { workspace = true }
+configs_derive = { workspace = true }
 ctor = "0.6.3"
 derive_more = { workspace = true }
 env_logger = { workspace = true }
+figment = { workspace = true }
 futures = { workspace = true }
 humantime = { workspace = true }
 iggy = { workspace = true }
diff --git a/core/integration/tests/config_provider/mod.rs 
b/core/integration/tests/config_provider/mod.rs
index a705284f4..d96ba5fc7 100644
--- a/core/integration/tests/config_provider/mod.rs
+++ b/core/integration/tests/config_provider/mod.rs
@@ -17,8 +17,13 @@
  * under the License.
  */
 
-use configs::ConfigProvider;
+use configs::{ConfigEnvMappings, ConfigProvider, TypedEnvProvider};
+use configs_derive::ConfigEnv;
+use figment::providers::{Format, Toml};
+use figment::value::Dict;
+use figment::{Figment, Provider};
 use integration::file::get_root_path;
+use serde::{Deserialize, Serialize};
 use serial_test::serial;
 use server::configs::server::ServerConfig;
 use std::env;
@@ -527,3 +532,153 @@ async fn validate_four_node_cluster_config_env_override() 
{
         // IGGY_CLUSTER_NODE_OTHERS_2_PORTS_WEBSOCKET was not set
     }
 }
+
+#[derive(Debug, Clone, Deserialize, Serialize, ConfigEnv)]
+#[config_env(tag = "config_type")]
+#[serde(tag = "config_type", rename_all = "lowercase")]
+enum TestTaggedEnum {
+    Local(TestLocalConfig),
+    Http(TestHttpConfig),
+}
+
+#[derive(Debug, Default, Clone, Deserialize, Serialize, ConfigEnv)]
+#[serde(default)]
+struct TestLocalConfig {
+    pub config_dir: String,
+}
+
+#[derive(Debug, Default, Clone, Deserialize, Serialize, ConfigEnv)]
+#[serde(default)]
+struct TestHttpConfig {
+    pub base_url: String,
+}
+
+#[derive(Debug, Default, Clone, Deserialize, Serialize, ConfigEnv)]
+#[config_env(prefix = "TEST_")]
+#[serde(default)]
+struct TestRootConfig {
+    pub name: String,
+    pub nested: TestTaggedEnum,
+}
+
+impl Default for TestTaggedEnum {
+    fn default() -> Self {
+        Self::Local(TestLocalConfig::default())
+    }
+}
+
+#[test]
+fn validate_tagged_enum_generates_tag_mapping() {
+    let mappings = TestTaggedEnum::env_mappings();
+
+    let has_config_type_mapping = mappings
+        .iter()
+        .any(|m| m.config_path == "config_type" && m.env_name == 
"CONFIG_TYPE");
+
+    assert!(
+        has_config_type_mapping,
+        "Expected env mapping for 'config_type' tag field, but found: {:?}",
+        mappings
+            .iter()
+            .map(|m| format!("{}={}", m.env_name, m.config_path))
+            .collect::<Vec<_>>()
+    );
+}
+
+#[test]
+fn validate_nested_tagged_enum_has_prefixed_tag_mapping() {
+    let mappings = TestRootConfig::env_mappings();
+
+    println!("All mappings for TestRootConfig:");
+    for m in mappings {
+        println!("  {} -> {}", m.env_name, m.config_path);
+    }
+
+    let has_nested_config_type = mappings
+        .iter()
+        .any(|m| m.config_path == "nested.config_type" && m.env_name == 
"TEST_NESTED_CONFIG_TYPE");
+
+    assert!(
+        has_nested_config_type,
+        "Expected nested tag mapping 'TEST_NESTED_CONFIG_TYPE' -> 
'nested.config_type', but found: {:?}",
+        mappings
+            .iter()
+            .map(|m| format!("{}={}", m.env_name, m.config_path))
+            .collect::<Vec<_>>()
+    );
+}
+
+#[serial]
+#[tokio::test]
+async fn validate_tagged_enum_deserialization_with_figment() {
+    // TOML with "local" variant (mirrors connectors/runtime/config.toml)
+    let toml_content = r#"
+        [nested]
+        config_type = "local"
+        config_dir = "/some/path"
+    "#;
+
+    unsafe {
+        env::set_var("TEST_NESTED_CONFIG_TYPE", "http");
+        env::set_var("TEST_NESTED_BASE_URL", "http://example.com";);
+    }
+
+    struct TestEnvProvider;
+    impl Provider for TestEnvProvider {
+        fn metadata(&self) -> figment::Metadata {
+            figment::Metadata::named("test-env")
+        }
+        fn data(&self) -> Result<figment::value::Map<figment::Profile, Dict>, 
figment::Error> {
+            let provider: TypedEnvProvider<TestRootConfig> = 
TypedEnvProvider::from_config("TEST_");
+            provider.data()
+        }
+    }
+
+    let config_result: Result<TestRootConfig, figment::Error> = Figment::new()
+        .merge(Toml::string(toml_content))
+        .merge(TestEnvProvider)
+        .extract();
+
+    unsafe {
+        env::remove_var("TEST_NESTED_CONFIG_TYPE");
+        env::remove_var("TEST_NESTED_BASE_URL");
+    }
+
+    match config_result {
+        Ok(config) => {
+            println!("Config loaded successfully: {:?}", config);
+            match config.nested {
+                TestTaggedEnum::Http(http) => {
+                    assert_eq!(http.base_url, "http://example.com";);
+                }
+                TestTaggedEnum::Local(_) => {
+                    panic!("Expected Http variant but got Local");
+                }
+            }
+        }
+        Err(e) => {
+            panic!("Failed to load config: {}", e);
+        }
+    }
+}
+
+#[test]
+fn debug_print_test_root_config_mappings() {
+    println!("\n=== TestRootConfig env_mappings() ===");
+    for m in TestRootConfig::env_mappings() {
+        println!("  {} -> {}", m.env_name, m.config_path);
+    }
+
+    println!("\n=== TestTaggedEnum env_mappings() ===");
+    for m in TestTaggedEnum::env_mappings() {
+        println!("  {} -> {}", m.env_name, m.config_path);
+    }
+
+    let has_tag = TestRootConfig::env_mappings()
+        .iter()
+        .any(|m| m.env_name == "TEST_NESTED_CONFIG_TYPE" && m.config_path == 
"nested.config_type");
+    assert!(
+        has_tag,
+        "Missing TEST_NESTED_CONFIG_TYPE -> nested.config_type mapping"
+    );
+}

Reply via email to