Skip to main content

hyperi_rustlib/config/
flat_env.rs

1// Project:   hyperi-rustlib
2// File:      src/config/flat_env.rs
3// Purpose:   Flat environment variable override helpers for K8s-friendly config
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Flat environment variable override helpers.
10//!
11//! DFE services running in Kubernetes receive configuration via flat env vars
12//! set by dfe-engine through Helm. These use single underscores as separators
13//! (e.g., `DFE_LOADER_KAFKA_BROKERS`), which differs from figment's double-underscore
14//! convention for nested keys.
15//!
16//! This module provides:
17//! - Runtime helper functions for reading flat env vars with type conversion
18//! - The [`ApplyFlatEnv`] trait for applying overrides to config structs
19//! - The [`Normalize`] trait for config normalisation after all sources merge
20//! - A generic [`load_config`] function that orchestrates the full cascade
21//!
22//! ## Usage
23//!
24//! The helper functions are designed to be called by `#[derive(FlatEnvOverrides)]`
25//! generated code, but are also usable standalone:
26//!
27//! ```rust
28//! use hyperi_rustlib::config::flat_env::*;
29//!
30//! # std::env::set_var("MYAPP_HOST", "example.com");
31//! if let Some(host) = flat_env_string("MYAPP", "HOST") {
32//!     println!("Host override: {host}");
33//! }
34//! # std::env::remove_var("MYAPP_HOST");
35//! ```
36
37use std::str::FromStr;
38
39// ---------------------------------------------------------------------------
40// Runtime helper functions
41// ---------------------------------------------------------------------------
42
43/// Read a flat env var `{PREFIX}_{SUFFIX}` as a `String`.
44///
45/// Returns `None` if the variable is unset or empty.
46/// Logs at debug level when an override is applied.
47#[must_use]
48pub fn flat_env_string(prefix: &str, suffix: &str) -> Option<String> {
49    let key = format!("{prefix}_{suffix}");
50    match std::env::var(&key) {
51        Ok(v) if !v.is_empty() => {
52            tracing::debug!(env_var = %key, "flat env override applied");
53            Some(v)
54        }
55        _ => None,
56    }
57}
58
59/// Read a flat env var as a comma-separated list.
60///
61/// Trims whitespace from each element and filters out empty strings.
62/// Returns `None` if the variable is unset or empty.
63#[must_use]
64pub fn flat_env_list(prefix: &str, suffix: &str) -> Option<Vec<String>> {
65    flat_env_string(prefix, suffix).map(|v| {
66        v.split(',')
67            .map(|s| s.trim().to_string())
68            .filter(|s| !s.is_empty())
69            .collect()
70    })
71}
72
73/// Read a flat env var as a `bool`.
74///
75/// Accepts (case-insensitive): `true`, `1`, `yes` as true; `false`, `0`, `no` as false.
76/// Logs a warning and returns `None` for unrecognised values.
77#[must_use]
78pub fn flat_env_bool(prefix: &str, suffix: &str) -> Option<bool> {
79    flat_env_string(prefix, suffix).and_then(|v| match v.to_lowercase().as_str() {
80        "true" | "1" | "yes" => Some(true),
81        "false" | "0" | "no" => Some(false),
82        _ => {
83            let key = format!("{prefix}_{suffix}");
84            tracing::warn!(env_var = %key, value = %v, "invalid bool value, ignoring");
85            None
86        }
87    })
88}
89
90/// Read a flat env var and parse via [`FromStr`].
91///
92/// Returns `None` if the variable is unset, empty, or fails to parse.
93/// Logs a warning on parse failure.
94#[must_use]
95pub fn flat_env_parsed<T: FromStr>(prefix: &str, suffix: &str) -> Option<T> {
96    flat_env_string(prefix, suffix).and_then(|v| {
97        v.parse::<T>().ok().or_else(|| {
98            let key = format!("{prefix}_{suffix}");
99            tracing::warn!(env_var = %key, value = %v, "failed to parse, ignoring");
100            None
101        })
102    })
103}
104
105/// Read a flat env var as a `String`, masking the value in debug output.
106///
107/// Behaves identically to [`flat_env_string`] but does not include the
108/// value in log messages. Use for passwords, tokens, and API keys.
109#[must_use]
110pub fn flat_env_string_sensitive(prefix: &str, suffix: &str) -> Option<String> {
111    let key = format!("{prefix}_{suffix}");
112    match std::env::var(&key) {
113        Ok(v) if !v.is_empty() => {
114            tracing::debug!(env_var = %key, "flat env override applied (sensitive, value masked)");
115            Some(v)
116        }
117        _ => None,
118    }
119}
120
121// ---------------------------------------------------------------------------
122// Traits
123// ---------------------------------------------------------------------------
124
125/// Trait for flat environment variable override application.
126///
127/// Implemented by the `#[derive(FlatEnvOverrides)]` macro, or manually.
128/// Each implementation reads `{prefix}_{FIELD_NAME}` env vars and applies
129/// them to the struct fields, overriding values from YAML/figment.
130pub trait ApplyFlatEnv {
131    /// Apply flat environment variable overrides to this config struct.
132    ///
133    /// The `prefix` is prepended to each field's suffix to form the full
134    /// env var name. For nested structs marked with `#[env_section]`, the
135    /// field name is appended to the prefix before recursing.
136    fn apply_flat_env(&mut self, prefix: &str);
137
138    /// Generate documentation for all supported env vars.
139    ///
140    /// Returns a list of [`EnvVarDoc`] entries describing each env var
141    /// this struct accepts. Used by `emit-env-docs` CLI subcommands.
142    #[must_use]
143    fn env_var_docs(prefix: &str) -> Vec<EnvVarDoc>
144    where
145        Self: Sized,
146    {
147        let _ = prefix; // default implementation returns empty
148        Vec::new()
149    }
150}
151
152/// Trait for config normalisation after all sources are merged.
153///
154/// Normalisation handles implied settings that should apply regardless of
155/// how a value arrived (YAML, env var, CLI arg). For example, setting SASL
156/// credentials implies `sasl.enabled = true`.
157///
158/// Called after: YAML -> figment env -> flat env overrides.
159/// Called before: `validate()`.
160pub trait Normalize {
161    /// Normalise config: infer implied settings, set defaults based on other fields.
162    fn normalize(&mut self) {}
163}
164
165/// Documentation for a single env var (generated by derive macro).
166#[derive(Debug, Clone)]
167pub struct EnvVarDoc {
168    /// Full env var name (e.g., `DFE_LOADER_KAFKA_BROKERS`).
169    pub name: String,
170    /// Rust field path (e.g., `kafka.brokers`).
171    pub field_path: String,
172    /// Type hint for documentation (e.g., `"string"`, `"list"`, `"bool"`, `"u64"`).
173    pub type_hint: &'static str,
174    /// Whether the value is sensitive (should be masked in docs/logs).
175    pub sensitive: bool,
176}
177
178// ---------------------------------------------------------------------------
179// Generic config loader
180// ---------------------------------------------------------------------------
181
182/// Load config with full cascade: YAML -> figment env -> flat env -> normalise.
183///
184/// This replaces the copy-pasted `Config::load()` functions across DFE services.
185/// It orchestrates:
186/// 1. `.env` loading (via dotenvy, handled by `config::setup`)
187/// 2. YAML file discovery and loading
188/// 3. Figment env var merging (double-underscore nesting)
189/// 4. Flat env overrides (single-underscore, K8s-friendly)
190/// 5. Normalisation (infer implied settings)
191///
192/// # Errors
193///
194/// Returns a [`ConfigError`](super::ConfigError) if config loading or
195/// deserialisation fails.
196pub fn load_config<T>(config_path: Option<&str>, env_prefix: &str) -> Result<T, super::ConfigError>
197where
198    T: Default + serde::de::DeserializeOwned + ApplyFlatEnv + Normalize,
199{
200    // Build config options
201    let mut opts = super::ConfigOptions {
202        env_prefix: env_prefix.to_string(),
203        ..Default::default()
204    };
205
206    // Add explicit config path if provided
207    if let Some(path) = config_path {
208        let path_buf = std::path::PathBuf::from(path);
209        if let Some(parent) = path_buf.parent() {
210            if parent.as_os_str().is_empty() {
211                // Relative path with no directory component — use current dir
212                opts.config_paths.push(std::path::PathBuf::from("."));
213            } else {
214                opts.config_paths.push(parent.to_path_buf());
215            }
216        }
217    }
218
219    // Load via rustlib cascade (dotenv + YAML + figment env)
220    let cfg = super::Config::new(opts)?;
221    let mut config: T = cfg.unmarshal().unwrap_or_default();
222
223    // Apply flat env overrides (single-underscore, K8s-friendly)
224    config.apply_flat_env(env_prefix);
225
226    // Normalise (infer implied settings)
227    config.normalize();
228
229    Ok(config)
230}
231
232// ---------------------------------------------------------------------------
233// Tests
234// ---------------------------------------------------------------------------
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_flat_env_string() {
242        temp_env::with_var("TEST_PREFIX_MY_FIELD", Some("hello"), || {
243            assert_eq!(
244                flat_env_string("TEST_PREFIX", "MY_FIELD"),
245                Some("hello".to_string())
246            );
247        });
248    }
249
250    #[test]
251    fn test_flat_env_string_empty() {
252        temp_env::with_var("TEST_PREFIX_EMPTY", Some(""), || {
253            assert_eq!(flat_env_string("TEST_PREFIX", "EMPTY"), None);
254        });
255    }
256
257    #[test]
258    fn test_flat_env_string_missing() {
259        assert_eq!(flat_env_string("NONEXISTENT_PREFIX", "FIELD"), None);
260    }
261
262    #[test]
263    fn test_flat_env_list() {
264        temp_env::with_var("TEST_PREFIX_ITEMS", Some("a, b, c"), || {
265            assert_eq!(
266                flat_env_list("TEST_PREFIX", "ITEMS"),
267                Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
268            );
269        });
270    }
271
272    #[test]
273    fn test_flat_env_list_single() {
274        temp_env::with_var("TEST_PREFIX_SINGLE", Some("only"), || {
275            assert_eq!(
276                flat_env_list("TEST_PREFIX", "SINGLE"),
277                Some(vec!["only".to_string()])
278            );
279        });
280    }
281
282    #[test]
283    fn test_flat_env_list_with_empty_elements() {
284        temp_env::with_var("TEST_PREFIX_SPARSE", Some("a,,b, ,c"), || {
285            assert_eq!(
286                flat_env_list("TEST_PREFIX", "SPARSE"),
287                Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
288            );
289        });
290    }
291
292    #[test]
293    fn test_flat_env_bool_true_variants() {
294        temp_env::with_var("TEST_PREFIX_FLAG", Some("true"), || {
295            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(true));
296        });
297        temp_env::with_var("TEST_PREFIX_FLAG", Some("1"), || {
298            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(true));
299        });
300        temp_env::with_var("TEST_PREFIX_FLAG", Some("yes"), || {
301            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(true));
302        });
303        temp_env::with_var("TEST_PREFIX_FLAG", Some("YES"), || {
304            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(true));
305        });
306        temp_env::with_var("TEST_PREFIX_FLAG", Some("True"), || {
307            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(true));
308        });
309    }
310
311    #[test]
312    fn test_flat_env_bool_false_variants() {
313        temp_env::with_var("TEST_PREFIX_FLAG", Some("false"), || {
314            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(false));
315        });
316        temp_env::with_var("TEST_PREFIX_FLAG", Some("0"), || {
317            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(false));
318        });
319        temp_env::with_var("TEST_PREFIX_FLAG", Some("no"), || {
320            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), Some(false));
321        });
322    }
323
324    #[test]
325    fn test_flat_env_bool_invalid() {
326        temp_env::with_var("TEST_PREFIX_FLAG", Some("maybe"), || {
327            assert_eq!(flat_env_bool("TEST_PREFIX", "FLAG"), None);
328        });
329    }
330
331    #[test]
332    fn test_flat_env_parsed_u64() {
333        temp_env::with_var("TEST_PREFIX_PORT", Some("8080"), || {
334            assert_eq!(flat_env_parsed::<u64>("TEST_PREFIX", "PORT"), Some(8080));
335        });
336    }
337
338    #[test]
339    fn test_flat_env_parsed_u16() {
340        temp_env::with_var("TEST_PREFIX_SMALL_PORT", Some("443"), || {
341            assert_eq!(
342                flat_env_parsed::<u16>("TEST_PREFIX", "SMALL_PORT"),
343                Some(443)
344            );
345        });
346    }
347
348    #[test]
349    fn test_flat_env_parsed_f64() {
350        temp_env::with_var("TEST_PREFIX_RATIO", Some("0.75"), || {
351            assert_eq!(flat_env_parsed::<f64>("TEST_PREFIX", "RATIO"), Some(0.75));
352        });
353    }
354
355    #[test]
356    fn test_flat_env_parsed_invalid() {
357        temp_env::with_var("TEST_PREFIX_PORT", Some("not_a_number"), || {
358            assert_eq!(flat_env_parsed::<u64>("TEST_PREFIX", "PORT"), None);
359        });
360    }
361
362    #[test]
363    fn test_flat_env_parsed_missing() {
364        assert_eq!(flat_env_parsed::<u64>("NONEXISTENT_PREFIX", "PORT"), None);
365    }
366
367    #[test]
368    fn test_flat_env_sensitive() {
369        temp_env::with_var("TEST_PREFIX_SECRET", Some("s3cr3t"), || {
370            assert_eq!(
371                flat_env_string_sensitive("TEST_PREFIX", "SECRET"),
372                Some("s3cr3t".to_string())
373            );
374        });
375    }
376
377    #[test]
378    fn test_flat_env_sensitive_empty() {
379        temp_env::with_var("TEST_PREFIX_SECRET", Some(""), || {
380            assert_eq!(flat_env_string_sensitive("TEST_PREFIX", "SECRET"), None);
381        });
382    }
383
384    #[test]
385    fn test_flat_env_sensitive_missing() {
386        assert_eq!(
387            flat_env_string_sensitive("NONEXISTENT_PREFIX", "SECRET"),
388            None
389        );
390    }
391
392    #[test]
393    fn test_apply_flat_env_trait() {
394        struct TestConfig {
395            value: String,
396        }
397        impl ApplyFlatEnv for TestConfig {
398            fn apply_flat_env(&mut self, prefix: &str) {
399                if let Some(v) = flat_env_string(prefix, "VALUE") {
400                    self.value = v;
401                }
402            }
403        }
404        let mut config = TestConfig {
405            value: "default".into(),
406        };
407        temp_env::with_var("MY_PREFIX_VALUE", Some("overridden"), || {
408            config.apply_flat_env("MY_PREFIX");
409        });
410        assert_eq!(config.value, "overridden");
411    }
412
413    #[test]
414    fn test_apply_flat_env_no_override() {
415        struct TestConfig {
416            value: String,
417        }
418        impl ApplyFlatEnv for TestConfig {
419            fn apply_flat_env(&mut self, prefix: &str) {
420                if let Some(v) = flat_env_string(prefix, "VALUE") {
421                    self.value = v;
422                }
423            }
424        }
425        let mut config = TestConfig {
426            value: "default".into(),
427        };
428        // No env var set — value should remain unchanged
429        config.apply_flat_env("ABSENT_PREFIX");
430        assert_eq!(config.value, "default");
431    }
432
433    #[test]
434    fn test_normalize_trait_default() {
435        struct TestConfig;
436        impl Normalize for TestConfig {}
437        let mut config = TestConfig;
438        config.normalize(); // default is no-op, should not panic
439    }
440
441    #[test]
442    fn test_normalize_trait_custom() {
443        struct TestConfig {
444            username: String,
445            auth_enabled: bool,
446        }
447        impl Normalize for TestConfig {
448            fn normalize(&mut self) {
449                if !self.username.is_empty() {
450                    self.auth_enabled = true;
451                }
452            }
453        }
454        let mut config = TestConfig {
455            username: "admin".into(),
456            auth_enabled: false,
457        };
458        config.normalize();
459        assert!(config.auth_enabled);
460    }
461
462    #[test]
463    fn test_env_var_doc() {
464        let doc = EnvVarDoc {
465            name: "DFE_LOADER_KAFKA_BROKERS".to_string(),
466            field_path: "kafka.brokers".to_string(),
467            type_hint: "list",
468            sensitive: false,
469        };
470        assert_eq!(doc.name, "DFE_LOADER_KAFKA_BROKERS");
471        assert_eq!(doc.field_path, "kafka.brokers");
472        assert_eq!(doc.type_hint, "list");
473        assert!(!doc.sensitive);
474    }
475
476    #[test]
477    fn test_env_var_docs_default() {
478        struct TestConfig;
479        impl ApplyFlatEnv for TestConfig {
480            fn apply_flat_env(&mut self, _prefix: &str) {}
481        }
482        let docs = TestConfig::env_var_docs("TEST");
483        assert!(docs.is_empty());
484    }
485
486    #[test]
487    fn test_flat_env_list_missing() {
488        assert_eq!(flat_env_list("NONEXISTENT_PREFIX", "ITEMS"), None);
489    }
490
491    #[test]
492    fn test_flat_env_bool_missing() {
493        assert_eq!(flat_env_bool("NONEXISTENT_PREFIX", "FLAG"), None);
494    }
495}