1use std::str::FromStr;
38
39#[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#[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#[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#[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#[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
121pub trait ApplyFlatEnv {
131 fn apply_flat_env(&mut self, prefix: &str);
137
138 #[must_use]
143 fn env_var_docs(prefix: &str) -> Vec<EnvVarDoc>
144 where
145 Self: Sized,
146 {
147 let _ = prefix; Vec::new()
149 }
150}
151
152pub trait Normalize {
161 fn normalize(&mut self) {}
163}
164
165#[derive(Debug, Clone)]
167pub struct EnvVarDoc {
168 pub name: String,
170 pub field_path: String,
172 pub type_hint: &'static str,
174 pub sensitive: bool,
176}
177
178pub 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 let mut opts = super::ConfigOptions {
202 env_prefix: env_prefix.to_string(),
203 ..Default::default()
204 };
205
206 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 opts.config_paths.push(std::path::PathBuf::from("."));
213 } else {
214 opts.config_paths.push(parent.to_path_buf());
215 }
216 }
217 }
218
219 let cfg = super::Config::new(opts)?;
221 let mut config: T = cfg.unmarshal().unwrap_or_default();
222
223 config.apply_flat_env(env_prefix);
225
226 config.normalize();
228
229 Ok(config)
230}
231
232#[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 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(); }
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}