Skip to main content

hyperi_rustlib/config/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/config/mod.rs
3// Purpose:   8-layer configuration cascade
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Configuration management with 8-layer cascade.
10//!
11//! Provides a hierarchical configuration system matching hyperi-pylib (Python)
12//! and hyperi-golib (Go). Configuration is loaded from multiple sources with
13//! clear priority ordering.
14//!
15//! ## Cascade Priority (highest to lowest)
16//!
17//! 1. CLI arguments (via clap integration)
18//! 2. Environment variables (with configurable prefix)
19//! 3. `.env` file (loaded via dotenvy)
20//! 4. PostgreSQL (optional, via `config-postgres` feature)
21//! 5. `settings.{env}.yaml` (environment-specific)
22//! 6. `settings.yaml` (base settings)
23//! 7. `defaults.yaml`
24//! 8. Hard-coded defaults
25//!
26//! ## How .env Files Work in the Cascade
27//!
28//! `dotenvy::dotenv()` loads `.env` into the process environment, so `.env`
29//! values are read at layer 2 alongside real env vars. Real env vars win:
30//! `dotenvy` does NOT overwrite existing variables.
31//!
32//! ## Environment Variable Naming
33//!
34//! Use the `env_compat` module for standardised environment variable names
35//! with legacy alias support and deprecation warnings.
36//!
37//! ## Example
38//!
39//! ```rust,no_run
40//! use hyperi_rustlib::config::{self, ConfigOptions};
41//!
42//! // Initialise with env prefix
43//! config::setup(ConfigOptions {
44//!     env_prefix: "MYAPP".into(),
45//!     ..Default::default()
46//! }).unwrap();
47//!
48//! // Access configuration
49//! let cfg = config::get();
50//! let host = cfg.get_string("database.host").unwrap_or_default();
51//! let port = cfg.get_int("database.port").unwrap_or(5432);
52//! ```
53
54pub mod env_compat;
55pub mod flat_env;
56pub mod registry;
57pub mod sensitive;
58
59#[cfg(feature = "config-reload")]
60pub mod reloader;
61
62#[cfg(feature = "config-reload")]
63pub mod shared;
64
65#[cfg(feature = "config-postgres")]
66pub mod postgres;
67
68use std::path::PathBuf;
69use std::sync::OnceLock;
70use std::time::Duration;
71
72use figment::Figment;
73use figment::providers::{Env, Format, Serialized, Yaml};
74use serde::de::DeserializeOwned;
75use thiserror::Error;
76
77use crate::env::get_app_env;
78
79#[cfg(feature = "config-postgres")]
80use self::postgres::{PostgresConfig, PostgresConfigError, PostgresConfigSource};
81
82/// Global configuration singleton.
83static CONFIG: OnceLock<Config> = OnceLock::new();
84
85/// Configuration errors.
86#[derive(Debug, Error)]
87pub enum ConfigError {
88    /// Failed to load configuration file.
89    #[error("failed to load config file '{path}': {message}")]
90    LoadError { path: PathBuf, message: String },
91
92    /// Failed to extract configuration value.
93    #[error("failed to extract config: {0}")]
94    ExtractError(#[from] figment::Error),
95
96    /// Missing required configuration key.
97    #[error("missing required config key: {0}")]
98    MissingKey(String),
99
100    /// Invalid configuration value.
101    #[error("invalid config value for '{key}': {reason}")]
102    InvalidValue { key: String, reason: String },
103
104    /// Configuration already initialised.
105    #[error("configuration already initialised")]
106    AlreadyInitialised,
107
108    /// Configuration not initialised.
109    #[error("configuration not initialised - call config::setup() first")]
110    NotInitialised,
111
112    /// PostgreSQL config error.
113    #[cfg(feature = "config-postgres")]
114    #[error("PostgreSQL config error: {0}")]
115    Postgres(#[from] PostgresConfigError),
116}
117
118/// Configuration options.
119#[derive(Debug, Clone)]
120pub struct ConfigOptions {
121    /// Environment variable prefix (e.g., "MYAPP" for MYAPP_DATABASE_HOST).
122    pub env_prefix: String,
123
124    /// Override the detected app environment (dev, staging, prod).
125    pub app_env: Option<String>,
126
127    /// Application name for user-scoped config discovery.
128    ///
129    /// When set, enables searching `~/.config/{app_name}/` for config files.
130    /// Falls back to `APP_NAME` or `HYPERI_LIB_APP_NAME` environment variables
131    /// if not explicitly provided.
132    ///
133    /// Default: None (user config directory not searched)
134    pub app_name: Option<String>,
135
136    /// Explicit config file (e.g. from `--config <path>`).
137    ///
138    /// When set, the named YAML file is merged ABOVE the discovered
139    /// `defaults`/`settings`/`settings.{env}` files but BELOW environment
140    /// variables -- it is an explicit override that still yields to ENV. This
141    /// is distinct from [`config_paths`](Self::config_paths), which are
142    /// DIRECTORIES searched for the standard base names. A `--config` flag
143    /// names a FILE, so it cannot be ingested via directory discovery.
144    ///
145    /// Default: None (no explicit file merged)
146    pub config_file: Option<PathBuf>,
147
148    /// Additional paths to search for config files.
149    pub config_paths: Vec<PathBuf>,
150
151    /// Whether to load `.env` files.
152    ///
153    /// When enabled, loads `.env` files in this order (lowest to highest priority):
154    /// 1. `~/.env` (home directory - global defaults)
155    /// 2. Project `.env` (current directory - project overrides)
156    ///
157    /// Later files override earlier ones. Real environment variables always
158    /// take precedence over `.env` values.
159    pub load_dotenv: bool,
160
161    /// Whether to load home directory `.env` file (`~/.env`).
162    ///
163    /// Only applies when `load_dotenv` is true.
164    /// Default: false (opt-in, matching hyperi-pylib)
165    pub load_home_dotenv: bool,
166
167    /// PostgreSQL config source (optional, requires `config-postgres` feature).
168    #[cfg(feature = "config-postgres")]
169    pub postgres: Option<PostgresConfigSource>,
170}
171
172impl Default for ConfigOptions {
173    fn default() -> Self {
174        Self {
175            env_prefix: String::new(),
176            app_env: None,
177            app_name: None,
178            config_file: None,
179            config_paths: Vec::new(),
180            load_dotenv: true,
181            load_home_dotenv: false,
182            #[cfg(feature = "config-postgres")]
183            postgres: None,
184        }
185    }
186}
187
188/// Configuration manager wrapping Figment.
189#[derive(Debug)]
190pub struct Config {
191    figment: Figment,
192    env_prefix: String,
193}
194
195impl Config {
196    /// Resolve the effective app name from explicit value or environment.
197    fn resolve_app_name(explicit: Option<&str>) -> Option<String> {
198        explicit
199            .map(String::from)
200            .or_else(|| std::env::var("APP_NAME").ok())
201            .or_else(|| std::env::var("HYPERI_LIB_APP_NAME").ok())
202    }
203
204    /// Create a new configuration with the given options.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error if configuration loading fails.
209    pub fn new(opts: ConfigOptions) -> Result<Self, ConfigError> {
210        let app_env = opts.app_env.unwrap_or_else(get_app_env);
211        let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
212        let app_name_ref = resolved_app_name.as_deref();
213
214        // Load .env files (home = global defaults, project = overrides).
215        // Real env vars always win -- dotenvy doesn't overwrite.
216        if opts.load_dotenv {
217            Self::load_dotenv_cascade(opts.load_home_dotenv);
218        }
219
220        // Build the cascade (lowest to highest priority)
221        let mut figment = Figment::new();
222
223        // 7. Hard-coded defaults (lowest priority)
224        figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
225
226        // 6. defaults.yaml
227        for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
228            figment = figment.merge(Yaml::file(&path));
229        }
230
231        // 5. settings.yaml
232        for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
233            figment = figment.merge(Yaml::file(&path));
234        }
235
236        // 4. settings.{env}.yaml
237        let env_settings = format!("settings.{app_env}");
238        for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
239            figment = figment.merge(Yaml::file(&path));
240        }
241
242        // 3b. Explicit `--config <file>`. Merged ABOVE the discovered files
243        // (it is a deliberate override) but BELOW ENV (operators can still
244        // override per-key from the environment). Same Yaml provider as the
245        // discovery layers, so figment's later-wins merge gives it priority
246        // over everything above.
247        if let Some(ref path) = opts.config_file {
248            figment = figment.merge(Yaml::file(path));
249        }
250
251        // 3. .env file values are already loaded into env vars
252
253        // 2. Environment variables (with prefix)
254        // Keys are lowercased: TEST_DATABASE_HOST -> database_host
255        // Use double underscore for nesting: TEST_DATABASE__HOST -> database.host
256        if !opts.env_prefix.is_empty() {
257            figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
258        }
259
260        // 1. CLI args would be merged by the application via merge_cli()
261
262        Ok(Self {
263            figment,
264            env_prefix: opts.env_prefix,
265        })
266    }
267
268    /// Create a new configuration with async loading (for PostgreSQL support).
269    ///
270    /// PostgreSQL sits above file-based config in the cascade, so database
271    /// values override file values.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if configuration loading fails.
276    #[cfg(feature = "config-postgres")]
277    pub async fn new_async(opts: ConfigOptions) -> Result<Self, ConfigError> {
278        let app_env = opts.app_env.clone().unwrap_or_else(get_app_env);
279        let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
280        let app_name_ref = resolved_app_name.as_deref();
281
282        // Load .env files in cascade order (lowest to highest priority)
283        if opts.load_dotenv {
284            Self::load_dotenv_cascade(opts.load_home_dotenv);
285        }
286
287        // Determine PostgreSQL config source
288        let pg_source = opts
289            .postgres
290            .clone()
291            .unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
292
293        // Load PostgreSQL config (async)
294        let pg_config = PostgresConfig::load(&pg_source).await?;
295
296        // Build the cascade (lowest to highest priority)
297        let mut figment = Figment::new();
298
299        // 8. Hard-coded defaults (lowest priority)
300        figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
301
302        // 7. defaults.yaml
303        for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
304            figment = figment.merge(Yaml::file(&path));
305        }
306
307        // 6. settings.yaml
308        for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
309            figment = figment.merge(Yaml::file(&path));
310        }
311
312        // 5. settings.{env}.yaml
313        let env_settings = format!("settings.{app_env}");
314        for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
315            figment = figment.merge(Yaml::file(&path));
316        }
317
318        // 4b. Explicit `--config <file>`. A file-based source, so it sits with
319        // the discovered files: ABOVE them (deliberate override), BELOW the
320        // PostgreSQL layer and ENV. Same Yaml provider as the discovery layers.
321        if let Some(ref path) = opts.config_file {
322            figment = figment.merge(Yaml::file(path));
323        }
324
325        // 4. PostgreSQL config (above files, below .env). Figment merges are
326        // additive, later wins -- cascade position alone sets priority.
327        if let Some(ref pg) = pg_config {
328            let nested = pg.to_nested();
329            figment = figment.merge(Serialized::defaults(nested));
330        }
331
332        // 3. .env file values are already loaded into env vars
333
334        // 2. Environment variables (with prefix)
335        if !opts.env_prefix.is_empty() {
336            figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
337        }
338
339        // 1. CLI args would be merged by the application via merge_cli()
340
341        Ok(Self {
342            figment,
343            env_prefix: opts.env_prefix,
344        })
345    }
346
347    /// Load `.env` files: `~/.env` (global defaults) then project `.env`
348    /// (overrides). `dotenvy` doesn't overwrite, so load in reverse --
349    /// project first wins, home only fills gaps. Real env vars beat both.
350    fn load_dotenv_cascade(load_home: bool) {
351        use tracing::debug;
352
353        // Project .env first -- these values take precedence.
354        match dotenvy::dotenv() {
355            Ok(path) => {
356                debug!(path = %path.display(), "Loaded project .env file");
357            }
358            Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
359                // No project .env, that's fine
360            }
361            Err(e) => {
362                debug!(error = %e, "Failed to load project .env file");
363            }
364        }
365
366        // Load home directory .env (only fills in missing values)
367        if load_home && let Some(home) = dirs::home_dir() {
368            let home_env = home.join(".env");
369            if home_env.exists() {
370                match dotenvy::from_path(&home_env) {
371                    Ok(()) => {
372                        debug!(path = %home_env.display(), "Loaded home .env file");
373                    }
374                    Err(e) => {
375                        debug!(path = %home_env.display(), error = %e, "Failed to load home .env file");
376                    }
377                }
378            }
379        }
380    }
381
382    /// Find config files with the given base name.
383    ///
384    /// Search order (all locations merged, later overrides earlier within
385    /// the same cascade layer):
386    /// 1. Current directory: `./{name}.yaml`
387    /// 2. Project config subdir: `./config/{name}.yaml`
388    /// 3. Container mount: `/config/{name}.yaml` (always checked)
389    /// 4. User config: `~/.config/{app_name}/{name}.yaml` (when app_name set)
390    /// 5. Extra paths from `ConfigOptions::config_paths`
391    fn find_config_files(
392        base_name: &str,
393        extra_paths: &[PathBuf],
394        app_name: Option<&str>,
395    ) -> Vec<PathBuf> {
396        let mut files = Vec::new();
397        let extensions = ["yaml", "yml"];
398
399        // 1. Current directory
400        for ext in &extensions {
401            let path = PathBuf::from(format!("{base_name}.{ext}"));
402            if path.exists() {
403                files.push(path);
404                break;
405            }
406        }
407
408        // 2. Project config subdirectory
409        for ext in &extensions {
410            let path = PathBuf::from(format!("config/{base_name}.{ext}"));
411            if path.exists() {
412                files.push(path);
413                break;
414            }
415        }
416
417        // 3. Container config mount (/config/)
418        let container_config = PathBuf::from("/config");
419        if container_config.is_dir() {
420            for ext in &extensions {
421                let path = container_config.join(format!("{base_name}.{ext}"));
422                if path.exists() {
423                    files.push(path);
424                    break;
425                }
426            }
427        }
428
429        // 4. User config directory (~/.config/{app_name}/)
430        if let Some(name) = app_name
431            && let Some(config_dir) = dirs::config_dir()
432        {
433            let user_config = config_dir.join(name);
434            if user_config.is_dir() {
435                for ext in &extensions {
436                    let path = user_config.join(format!("{base_name}.{ext}"));
437                    if path.exists() {
438                        files.push(path);
439                        break;
440                    }
441                }
442            }
443        }
444
445        // 5. Extra paths (from ConfigOptions::config_paths)
446        for base in extra_paths {
447            for ext in &extensions {
448                let path = base.join(format!("{base_name}.{ext}"));
449                if path.exists() {
450                    files.push(path);
451                    break;
452                }
453            }
454        }
455
456        files
457    }
458
459    /// Merge CLI arguments into the configuration.
460    ///
461    /// Call this after parsing CLI args to add them as highest priority.
462    #[must_use]
463    pub fn merge_cli<T: serde::Serialize>(mut self, cli_args: T) -> Self {
464        self.figment = self.figment.merge(Serialized::defaults(cli_args));
465        self
466    }
467
468    /// Get a string value.
469    #[must_use]
470    pub fn get_string(&self, key: &str) -> Option<String> {
471        self.figment.extract_inner::<String>(key).ok()
472    }
473
474    /// Get an integer value.
475    #[must_use]
476    pub fn get_int(&self, key: &str) -> Option<i64> {
477        self.figment.extract_inner::<i64>(key).ok()
478    }
479
480    /// Get a float value.
481    #[must_use]
482    pub fn get_float(&self, key: &str) -> Option<f64> {
483        self.figment.extract_inner::<f64>(key).ok()
484    }
485
486    /// Get a boolean value.
487    #[must_use]
488    pub fn get_bool(&self, key: &str) -> Option<bool> {
489        self.figment.extract_inner::<bool>(key).ok()
490    }
491
492    /// Get a duration value (parses strings like "30s", "5m", "1h").
493    #[must_use]
494    pub fn get_duration(&self, key: &str) -> Option<Duration> {
495        let value = self.get_string(key)?;
496        parse_duration(&value)
497    }
498
499    /// Get a list of strings.
500    #[must_use]
501    pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
502        self.figment.extract_inner::<Vec<String>>(key).ok()
503    }
504
505    /// Check if a key exists.
506    #[must_use]
507    pub fn contains(&self, key: &str) -> bool {
508        self.figment.find_value(key).is_ok()
509    }
510
511    /// Deserialise the entire configuration into a typed struct.
512    ///
513    /// # Errors
514    ///
515    /// Returns an error if deserialisation fails.
516    pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
517        self.figment.extract().map_err(ConfigError::ExtractError)
518    }
519
520    /// Deserialise a specific key into a typed struct.
521    ///
522    /// # Errors
523    ///
524    /// Returns an error if deserialisation fails.
525    pub fn unmarshal_key<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
526        self.figment
527            .extract_inner(key)
528            .map_err(ConfigError::ExtractError)
529    }
530
531    /// Deserialise a specific key, warning if it is present but malformed.
532    ///
533    /// Cascade `from_cascade()` impls treat an ABSENT key as "use the default"
534    /// (silent -- a default-ON subsystem relies on this). The pre-2.8.11 path
535    /// also swallowed a present-but-MALFORMED key (typo, type mismatch) the same
536    /// way, so a `worker_pool: { min_thraeds: 7 }` typo silently defaulted with
537    /// no signal. This helper splits the two:
538    ///
539    /// - key ABSENT -> `None`, no log (caller falls back to default)
540    /// - key PRESENT but fails to deserialise -> `tracing::warn!` naming the key
541    ///   and error, then `None` (caller still falls back to default, but the
542    ///   misconfiguration is now observable)
543    /// - key PRESENT and valid -> `Some(value)`
544    #[must_use]
545    pub fn unmarshal_key_or_warn<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
546        match self.figment.extract_inner::<T>(key) {
547            Ok(value) => Some(value),
548            Err(_) if self.figment.find_value(key).is_err() => {
549                // Key genuinely absent -- silent, caller uses its default.
550                None
551            }
552            Err(e) => {
553                tracing::warn!(
554                    config_key = key,
555                    error = %e,
556                    "config section present but failed to deserialise; using defaults \
557                     (check for a typo or type mismatch in this section)"
558                );
559                None
560            }
561        }
562    }
563
564    /// Deserialise + auto-register a key, warning if present but malformed.
565    ///
566    /// The registry-aware sibling of
567    /// [`unmarshal_key_or_warn`](Self::unmarshal_key_or_warn): on a present,
568    /// valid key it records the section in the [`registry`] (same as
569    /// [`unmarshal_key_registered`](Self::unmarshal_key_registered)) and returns
570    /// `Some(value)`. An ABSENT key returns `None` silently; a PRESENT but
571    /// malformed key warns and returns `None`. The section is only registered
572    /// on success.
573    #[must_use]
574    pub fn unmarshal_key_registered_or_warn<T>(&self, key: &str) -> Option<T>
575    where
576        T: DeserializeOwned + serde::Serialize + Default + 'static,
577    {
578        let value: T = self.unmarshal_key_or_warn(key)?;
579        registry::register::<T>(key, &value);
580        Some(value)
581    }
582
583    /// Deserialise a specific key and auto-register it in the config registry.
584    ///
585    /// Same as [`unmarshal_key`](Self::unmarshal_key) but also records the
586    /// section in the global [`registry`] for reflection. The type must
587    /// implement `Serialize + Default` so the registry can capture both
588    /// the effective and default values.
589    ///
590    /// # Errors
591    ///
592    /// Returns an error if deserialisation fails. The section is only
593    /// registered on success.
594    pub fn unmarshal_key_registered<T>(&self, key: &str) -> Result<T, ConfigError>
595    where
596        T: DeserializeOwned + serde::Serialize + Default + 'static,
597    {
598        let value: T = self.unmarshal_key(key)?;
599        registry::register::<T>(key, &value);
600        Ok(value)
601    }
602
603    /// Get the environment variable prefix.
604    #[must_use]
605    pub fn env_prefix(&self) -> &str {
606        &self.env_prefix
607    }
608}
609
610/// Hard-coded default values (lowest priority in cascade).
611#[derive(Debug, serde::Serialize)]
612struct HardcodedDefaults {
613    log_level: String,
614    log_format: String,
615}
616
617impl Default for HardcodedDefaults {
618    fn default() -> Self {
619        Self {
620            log_level: "info".to_string(),
621            log_format: "auto".to_string(),
622        }
623    }
624}
625
626/// Parse a duration string like "30s", "5m", "1h", "2h30m".
627fn parse_duration(s: &str) -> Option<Duration> {
628    let s = s.trim().to_lowercase();
629
630    // Try simple formats first
631    if let Some(secs) = s.strip_suffix('s') {
632        return secs.parse::<u64>().ok().map(Duration::from_secs);
633    }
634    if let Some(mins) = s.strip_suffix('m') {
635        return mins
636            .parse::<u64>()
637            .ok()
638            .map(|m| Duration::from_secs(m * 60));
639    }
640    if let Some(hours) = s.strip_suffix('h') {
641        return hours
642            .parse::<u64>()
643            .ok()
644            .map(|h| Duration::from_secs(h * 3600));
645    }
646
647    // Try parsing as seconds
648    s.parse::<u64>().ok().map(Duration::from_secs)
649}
650
651// Global singleton functions
652
653/// Initialise the global configuration.
654///
655/// # Errors
656///
657/// Returns an error if configuration loading fails or if already initialised.
658pub fn setup(opts: ConfigOptions) -> Result<(), ConfigError> {
659    let config = Config::new(opts)?;
660    CONFIG
661        .set(config)
662        .map_err(|_| ConfigError::AlreadyInitialised)
663}
664
665/// Initialise the global configuration with async loading (for PostgreSQL support).
666///
667/// # Errors
668///
669/// Returns an error if configuration loading fails or if already initialised.
670#[cfg(feature = "config-postgres")]
671pub async fn setup_async(opts: ConfigOptions) -> Result<(), ConfigError> {
672    let config = Config::new_async(opts).await?;
673    CONFIG
674        .set(config)
675        .map_err(|_| ConfigError::AlreadyInitialised)
676}
677
678/// Get the global configuration.
679///
680/// # Panics
681///
682/// Panics if configuration has not been initialised.
683#[must_use]
684pub fn get() -> &'static Config {
685    CONFIG
686        .get()
687        .expect("configuration not initialised - call config::setup() first")
688}
689
690/// Try to get the global configuration.
691#[must_use]
692pub fn try_get() -> Option<&'static Config> {
693    CONFIG.get()
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn test_parse_duration_seconds() {
702        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
703        assert_eq!(parse_duration("1s"), Some(Duration::from_secs(1)));
704    }
705
706    #[test]
707    fn test_parse_duration_minutes() {
708        assert_eq!(parse_duration("5m"), Some(Duration::from_mins(5)));
709        assert_eq!(parse_duration("1m"), Some(Duration::from_mins(1)));
710    }
711
712    #[test]
713    fn test_parse_duration_hours() {
714        assert_eq!(parse_duration("1h"), Some(Duration::from_hours(1)));
715        assert_eq!(parse_duration("2h"), Some(Duration::from_hours(2)));
716    }
717
718    #[test]
719    fn test_parse_duration_plain_number() {
720        assert_eq!(parse_duration("60"), Some(Duration::from_mins(1)));
721    }
722
723    #[test]
724    fn test_config_options_default() {
725        let opts = ConfigOptions::default();
726        assert!(opts.env_prefix.is_empty());
727        assert!(opts.app_env.is_none());
728        assert!(opts.app_name.is_none());
729        assert!(opts.config_file.is_none());
730        assert!(opts.config_paths.is_empty());
731        assert!(opts.load_dotenv);
732        assert!(!opts.load_home_dotenv);
733    }
734
735    #[test]
736    fn test_config_new() {
737        let config = Config::new(ConfigOptions::default());
738        assert!(config.is_ok());
739    }
740
741    #[test]
742    fn test_config_hardcoded_defaults() {
743        let config = Config::new(ConfigOptions::default()).unwrap();
744
745        // Should have hardcoded defaults
746        assert_eq!(config.get_string("log_level"), Some("info".to_string()));
747        assert_eq!(config.get_string("log_format"), Some("auto".to_string()));
748    }
749
750    #[test]
751    fn test_config_file_is_merged() {
752        // An explicit config_file must be ingested (the run_app bug was that
753        // a --config file was never merged into the cascade at all).
754        let dir = std::env::temp_dir().join(format!("rustlib-cfgfile-{}", std::process::id()));
755        std::fs::create_dir_all(&dir).unwrap();
756        let file = dir.join("explicit.yaml");
757        std::fs::write(&file, "log_level: warn\ncustom_key: from_file\n").unwrap();
758
759        let config = Config::new(ConfigOptions {
760            config_file: Some(file.clone()),
761            ..Default::default()
762        })
763        .unwrap();
764
765        // Explicit file overrides the hard-coded default (log_level=info).
766        assert_eq!(config.get_string("log_level"), Some("warn".to_string()));
767        // And a key that exists only in the file is visible.
768        assert_eq!(
769            config.get_string("custom_key"),
770            Some("from_file".to_string())
771        );
772
773        std::fs::remove_dir_all(&dir).ok();
774    }
775
776    #[test]
777    fn test_config_file_loses_to_env() {
778        // The explicit file must merge BELOW environment variables.
779        let dir = std::env::temp_dir().join(format!("rustlib-cfgenv-{}", std::process::id()));
780        std::fs::create_dir_all(&dir).unwrap();
781        let file = dir.join("explicit.yaml");
782        std::fs::write(&file, "host: from_file\n").unwrap();
783
784        temp_env::with_var("TESTF_HOST", Some("from_env"), || {
785            let config = Config::new(ConfigOptions {
786                env_prefix: "TESTF".into(),
787                config_file: Some(file.clone()),
788                ..Default::default()
789            })
790            .unwrap();
791            // ENV wins over the explicit file.
792            assert_eq!(config.get_string("host"), Some("from_env".to_string()));
793        });
794
795        std::fs::remove_dir_all(&dir).ok();
796    }
797
798    #[test]
799    fn test_unmarshal_key_or_warn_absent_is_none() {
800        let config = Config::new(ConfigOptions::default()).unwrap();
801        // Absent key -> None (silent; caller uses its default).
802        let v: Option<i64> = config.unmarshal_key_or_warn("definitely_absent_key");
803        assert!(v.is_none());
804    }
805
806    #[test]
807    fn test_unmarshal_key_or_warn_present_valid_is_some() {
808        let config = Config::new(ConfigOptions::default()).unwrap();
809        // log_level is a hard-coded default String present in the cascade.
810        let v: Option<String> = config.unmarshal_key_or_warn("log_level");
811        assert_eq!(v, Some("info".to_string()));
812    }
813
814    #[test]
815    fn test_unmarshal_key_or_warn_present_malformed_is_none() {
816        // log_level is present as a String -- extracting it as i64 is a present
817        // -but-malformed case: helper returns None (and warns), never panics.
818        let config = Config::new(ConfigOptions::default()).unwrap();
819        let v: Option<i64> = config.unmarshal_key_or_warn("log_level");
820        assert!(v.is_none());
821    }
822
823    #[test]
824    fn test_config_env_override() {
825        // Env vars use double underscore for nesting: PREFIX_KEY__SUBKEY -> key.subkey
826        // For flat keys, just use PREFIX_KEY -> key
827        temp_env::with_var("TEST_HOST", Some("testhost"), || {
828            let config = Config::new(ConfigOptions {
829                env_prefix: "TEST".into(),
830                ..Default::default()
831            })
832            .unwrap();
833            assert_eq!(config.get_string("host"), Some("testhost".to_string()));
834        });
835    }
836}