Skip to main content

hyperi_rustlib/config/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/config/mod.rs
3// Purpose:   7-layer configuration cascade
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
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//! The `.env` file is loaded early in the cascade using `dotenvy::dotenv()`.
29//! This populates the process environment, so `.env` values become available
30//! via `std::env::var()`. The cascade then reads environment variables at
31//! layer 2, which includes both real environment variables AND `.env` values.
32//!
33//! **Important**: Real environment variables take precedence over `.env` values
34//! because `dotenvy` does NOT overwrite existing environment variables.
35//!
36//! ```text
37//! Priority (highest wins):
38//! ┌─────────────────────────────────────────────────────────────┐
39//! │ 1. CLI arguments (merged via merge_cli())                   │
40//! ├─────────────────────────────────────────────────────────────┤
41//! │ 2. Environment variables (PREFIX_KEY)                       │
42//! │    ↑ Includes .env values (loaded into env by dotenvy)      │
43//! │    ↑ Real env vars win over .env (dotenvy doesn't overwrite)│
44//! ├─────────────────────────────────────────────────────────────┤
45//! │ 3. PostgreSQL config (if config-postgres feature enabled)   │
46//! ├─────────────────────────────────────────────────────────────┤
47//! │ 4. settings.{env}.yaml (e.g., settings.production.yaml)     │
48//! ├─────────────────────────────────────────────────────────────┤
49//! │ 5. settings.yaml                                            │
50//! ├─────────────────────────────────────────────────────────────┤
51//! │ 6. defaults.yaml                                            │
52//! ├─────────────────────────────────────────────────────────────┤
53//! │ 7. Hard-coded defaults (lowest priority)                    │
54//! └─────────────────────────────────────────────────────────────┘
55//! ```
56//!
57//! ## Environment Variable Naming
58//!
59//! Use the `env_compat` module for standardised environment variable names
60//! with legacy alias support and deprecation warnings.
61//!
62//! ## Example
63//!
64//! ```rust,no_run
65//! use hyperi_rustlib::config::{self, ConfigOptions};
66//!
67//! // Initialise with env prefix
68//! config::setup(ConfigOptions {
69//!     env_prefix: "MYAPP".into(),
70//!     ..Default::default()
71//! }).unwrap();
72//!
73//! // Access configuration
74//! let cfg = config::get();
75//! let host = cfg.get_string("database.host").unwrap_or_default();
76//! let port = cfg.get_int("database.port").unwrap_or(5432);
77//! ```
78
79pub mod env_compat;
80pub mod flat_env;
81pub mod registry;
82pub mod sensitive;
83
84#[cfg(feature = "config-reload")]
85pub mod reloader;
86
87#[cfg(feature = "config-reload")]
88pub mod shared;
89
90#[cfg(feature = "config-postgres")]
91pub mod postgres;
92
93use std::path::PathBuf;
94use std::sync::OnceLock;
95use std::time::Duration;
96
97use figment::Figment;
98use figment::providers::{Env, Format, Serialized, Yaml};
99use serde::de::DeserializeOwned;
100use thiserror::Error;
101
102use crate::env::get_app_env;
103
104#[cfg(feature = "config-postgres")]
105use self::postgres::{PostgresConfig, PostgresConfigError, PostgresConfigSource};
106
107/// Global configuration singleton.
108static CONFIG: OnceLock<Config> = OnceLock::new();
109
110/// Configuration errors.
111#[derive(Debug, Error)]
112pub enum ConfigError {
113    /// Failed to load configuration file.
114    #[error("failed to load config file '{path}': {message}")]
115    LoadError { path: PathBuf, message: String },
116
117    /// Failed to extract configuration value.
118    #[error("failed to extract config: {0}")]
119    ExtractError(#[from] figment::Error),
120
121    /// Missing required configuration key.
122    #[error("missing required config key: {0}")]
123    MissingKey(String),
124
125    /// Invalid configuration value.
126    #[error("invalid config value for '{key}': {reason}")]
127    InvalidValue { key: String, reason: String },
128
129    /// Configuration already initialised.
130    #[error("configuration already initialised")]
131    AlreadyInitialised,
132
133    /// Configuration not initialised.
134    #[error("configuration not initialised - call config::setup() first")]
135    NotInitialised,
136
137    /// PostgreSQL config error.
138    #[cfg(feature = "config-postgres")]
139    #[error("PostgreSQL config error: {0}")]
140    Postgres(#[from] PostgresConfigError),
141}
142
143/// Configuration options.
144#[derive(Debug, Clone)]
145pub struct ConfigOptions {
146    /// Environment variable prefix (e.g., "MYAPP" for MYAPP_DATABASE_HOST).
147    pub env_prefix: String,
148
149    /// Override the detected app environment (dev, staging, prod).
150    pub app_env: Option<String>,
151
152    /// Application name for user-scoped config discovery.
153    ///
154    /// When set, enables searching `~/.config/{app_name}/` for config files.
155    /// Falls back to `APP_NAME` or `HYPERI_LIB_APP_NAME` environment variables
156    /// if not explicitly provided.
157    ///
158    /// Default: None (user config directory not searched)
159    pub app_name: Option<String>,
160
161    /// Additional paths to search for config files.
162    pub config_paths: Vec<PathBuf>,
163
164    /// Whether to load `.env` files.
165    ///
166    /// When enabled, loads `.env` files in this order (lowest to highest priority):
167    /// 1. `~/.env` (home directory - global defaults)
168    /// 2. Project `.env` (current directory - project overrides)
169    ///
170    /// Later files override earlier ones. Real environment variables always
171    /// take precedence over `.env` values.
172    pub load_dotenv: bool,
173
174    /// Whether to load home directory `.env` file (`~/.env`).
175    ///
176    /// Only applies when `load_dotenv` is true.
177    /// Default: false (opt-in, matching hyperi-pylib)
178    pub load_home_dotenv: bool,
179
180    /// PostgreSQL config source (optional, requires `config-postgres` feature).
181    #[cfg(feature = "config-postgres")]
182    pub postgres: Option<PostgresConfigSource>,
183}
184
185impl Default for ConfigOptions {
186    fn default() -> Self {
187        Self {
188            env_prefix: String::new(),
189            app_env: None,
190            app_name: None,
191            config_paths: Vec::new(),
192            load_dotenv: true,
193            load_home_dotenv: false,
194            #[cfg(feature = "config-postgres")]
195            postgres: None,
196        }
197    }
198}
199
200/// Configuration manager wrapping Figment.
201#[derive(Debug)]
202pub struct Config {
203    figment: Figment,
204    env_prefix: String,
205}
206
207impl Config {
208    /// Resolve the effective app name from explicit value or environment.
209    fn resolve_app_name(explicit: Option<&str>) -> Option<String> {
210        explicit
211            .map(String::from)
212            .or_else(|| std::env::var("APP_NAME").ok())
213            .or_else(|| std::env::var("HYPERI_LIB_APP_NAME").ok())
214    }
215
216    /// Create a new configuration with the given options.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if configuration loading fails.
221    pub fn new(opts: ConfigOptions) -> Result<Self, ConfigError> {
222        let app_env = opts.app_env.unwrap_or_else(get_app_env);
223        let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
224        let app_name_ref = resolved_app_name.as_deref();
225
226        // Load .env files in cascade order (lowest to highest priority)
227        // Home directory .env provides global defaults
228        // Project .env provides project-specific overrides
229        // Real environment variables always win (dotenvy doesn't overwrite)
230        if opts.load_dotenv {
231            Self::load_dotenv_cascade(opts.load_home_dotenv);
232        }
233
234        // Build the cascade (lowest to highest priority)
235        let mut figment = Figment::new();
236
237        // 7. Hard-coded defaults (lowest priority)
238        figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
239
240        // 6. defaults.yaml
241        for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
242            figment = figment.merge(Yaml::file(&path));
243        }
244
245        // 5. settings.yaml
246        for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
247            figment = figment.merge(Yaml::file(&path));
248        }
249
250        // 4. settings.{env}.yaml
251        let env_settings = format!("settings.{app_env}");
252        for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
253            figment = figment.merge(Yaml::file(&path));
254        }
255
256        // 3. .env file values are already loaded into env vars
257
258        // 2. Environment variables (with prefix)
259        // Keys are lowercased: TEST_DATABASE_HOST -> database_host
260        // Use double underscore for nesting: TEST_DATABASE__HOST -> database.host
261        if !opts.env_prefix.is_empty() {
262            figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
263        }
264
265        // 1. CLI args would be merged by the application via merge_cli()
266
267        Ok(Self {
268            figment,
269            env_prefix: opts.env_prefix,
270        })
271    }
272
273    /// Create a new configuration with async loading (for PostgreSQL support).
274    ///
275    /// This method loads configuration asynchronously, allowing PostgreSQL to be
276    /// used as a config source. PostgreSQL sits above file-based config in the
277    /// cascade, so database values override file values.
278    ///
279    /// # Errors
280    ///
281    /// Returns an error if configuration loading fails.
282    #[cfg(feature = "config-postgres")]
283    pub async fn new_async(opts: ConfigOptions) -> Result<Self, ConfigError> {
284        let app_env = opts.app_env.clone().unwrap_or_else(get_app_env);
285        let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
286        let app_name_ref = resolved_app_name.as_deref();
287
288        // Load .env files in cascade order (lowest to highest priority)
289        if opts.load_dotenv {
290            Self::load_dotenv_cascade(opts.load_home_dotenv);
291        }
292
293        // Determine PostgreSQL config source
294        let pg_source = opts
295            .postgres
296            .clone()
297            .unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
298
299        // Load PostgreSQL config (async)
300        let pg_config = PostgresConfig::load(&pg_source).await?;
301
302        // Build the cascade (lowest to highest priority)
303        let mut figment = Figment::new();
304
305        // 8. Hard-coded defaults (lowest priority)
306        figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
307
308        // 7. defaults.yaml
309        for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
310            figment = figment.merge(Yaml::file(&path));
311        }
312
313        // 6. settings.yaml
314        for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
315            figment = figment.merge(Yaml::file(&path));
316        }
317
318        // 5. settings.{env}.yaml
319        let env_settings = format!("settings.{app_env}");
320        for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
321            figment = figment.merge(Yaml::file(&path));
322        }
323
324        // 4. PostgreSQL config (above files, below .env)
325        if let Some(ref pg) = pg_config {
326            let nested = pg.to_nested();
327            // For merge mode, we merge into existing config
328            // For replace mode, PostgreSQL config replaces file-based config
329            // Since figment merges are additive with later values winning,
330            // we just merge here - the position in the cascade determines priority
331            figment = figment.merge(Serialized::defaults(nested));
332        }
333
334        // 3. .env file values are already loaded into env vars
335
336        // 2. Environment variables (with prefix)
337        if !opts.env_prefix.is_empty() {
338            figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
339        }
340
341        // 1. CLI args would be merged by the application via merge_cli()
342
343        Ok(Self {
344            figment,
345            env_prefix: opts.env_prefix,
346        })
347    }
348
349    /// Load `.env` files in cascade order.
350    ///
351    /// Order (lowest to highest priority):
352    /// 1. `~/.env` (home directory - global defaults)
353    /// 2. Project `.env` (current directory - project overrides)
354    ///
355    /// Note: `dotenvy` does NOT overwrite existing environment variables,
356    /// so later files in the cascade take precedence. We load in reverse
357    /// order (project first, then home) so that project values are set first
358    /// and home values only fill in missing variables.
359    ///
360    /// Real environment variables always take precedence over all `.env` values.
361    fn load_dotenv_cascade(load_home: bool) {
362        use tracing::debug;
363
364        // Load project .env first (these values take precedence)
365        // dotenvy::dotenv() looks for .env in current directory
366        match dotenvy::dotenv() {
367            Ok(path) => {
368                debug!(path = %path.display(), "Loaded project .env file");
369            }
370            Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
371                // No project .env, that's fine
372            }
373            Err(e) => {
374                debug!(error = %e, "Failed to load project .env file");
375            }
376        }
377
378        // Load home directory .env (only fills in missing values)
379        if load_home && let Some(home) = dirs::home_dir() {
380            let home_env = home.join(".env");
381            if home_env.exists() {
382                match dotenvy::from_path(&home_env) {
383                    Ok(()) => {
384                        debug!(path = %home_env.display(), "Loaded home .env file");
385                    }
386                    Err(e) => {
387                        debug!(path = %home_env.display(), error = %e, "Failed to load home .env file");
388                    }
389                }
390            }
391        }
392    }
393
394    /// Find config files with the given base name.
395    ///
396    /// Search order (all locations merged, later overrides earlier within
397    /// the same cascade layer):
398    /// 1. Current directory: `./{name}.yaml`
399    /// 2. Project config subdir: `./config/{name}.yaml`
400    /// 3. Container mount: `/config/{name}.yaml` (always checked)
401    /// 4. User config: `~/.config/{app_name}/{name}.yaml` (when app_name set)
402    /// 5. Extra paths from `ConfigOptions::config_paths`
403    fn find_config_files(
404        base_name: &str,
405        extra_paths: &[PathBuf],
406        app_name: Option<&str>,
407    ) -> Vec<PathBuf> {
408        let mut files = Vec::new();
409        let extensions = ["yaml", "yml"];
410
411        // 1. Current directory
412        for ext in &extensions {
413            let path = PathBuf::from(format!("{base_name}.{ext}"));
414            if path.exists() {
415                files.push(path);
416                break;
417            }
418        }
419
420        // 2. Project config subdirectory
421        for ext in &extensions {
422            let path = PathBuf::from(format!("config/{base_name}.{ext}"));
423            if path.exists() {
424                files.push(path);
425                break;
426            }
427        }
428
429        // 3. Container config mount (/config/)
430        let container_config = PathBuf::from("/config");
431        if container_config.is_dir() {
432            for ext in &extensions {
433                let path = container_config.join(format!("{base_name}.{ext}"));
434                if path.exists() {
435                    files.push(path);
436                    break;
437                }
438            }
439        }
440
441        // 4. User config directory (~/.config/{app_name}/)
442        if let Some(name) = app_name
443            && let Some(config_dir) = dirs::config_dir()
444        {
445            let user_config = config_dir.join(name);
446            if user_config.is_dir() {
447                for ext in &extensions {
448                    let path = user_config.join(format!("{base_name}.{ext}"));
449                    if path.exists() {
450                        files.push(path);
451                        break;
452                    }
453                }
454            }
455        }
456
457        // 5. Extra paths (from ConfigOptions::config_paths)
458        for base in extra_paths {
459            for ext in &extensions {
460                let path = base.join(format!("{base_name}.{ext}"));
461                if path.exists() {
462                    files.push(path);
463                    break;
464                }
465            }
466        }
467
468        files
469    }
470
471    /// Merge CLI arguments into the configuration.
472    ///
473    /// Call this after parsing CLI args to add them as highest priority.
474    #[must_use]
475    pub fn merge_cli<T: serde::Serialize>(mut self, cli_args: T) -> Self {
476        self.figment = self.figment.merge(Serialized::defaults(cli_args));
477        self
478    }
479
480    /// Get a string value.
481    #[must_use]
482    pub fn get_string(&self, key: &str) -> Option<String> {
483        self.figment.extract_inner::<String>(key).ok()
484    }
485
486    /// Get an integer value.
487    #[must_use]
488    pub fn get_int(&self, key: &str) -> Option<i64> {
489        self.figment.extract_inner::<i64>(key).ok()
490    }
491
492    /// Get a float value.
493    #[must_use]
494    pub fn get_float(&self, key: &str) -> Option<f64> {
495        self.figment.extract_inner::<f64>(key).ok()
496    }
497
498    /// Get a boolean value.
499    #[must_use]
500    pub fn get_bool(&self, key: &str) -> Option<bool> {
501        self.figment.extract_inner::<bool>(key).ok()
502    }
503
504    /// Get a duration value (parses strings like "30s", "5m", "1h").
505    #[must_use]
506    pub fn get_duration(&self, key: &str) -> Option<Duration> {
507        let value = self.get_string(key)?;
508        parse_duration(&value)
509    }
510
511    /// Get a list of strings.
512    #[must_use]
513    pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
514        self.figment.extract_inner::<Vec<String>>(key).ok()
515    }
516
517    /// Check if a key exists.
518    #[must_use]
519    pub fn contains(&self, key: &str) -> bool {
520        self.figment.find_value(key).is_ok()
521    }
522
523    /// Deserialise the entire configuration into a typed struct.
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if deserialisation fails.
528    pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
529        self.figment.extract().map_err(ConfigError::ExtractError)
530    }
531
532    /// Deserialise a specific key into a typed struct.
533    ///
534    /// # Errors
535    ///
536    /// Returns an error if deserialisation fails.
537    pub fn unmarshal_key<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
538        self.figment
539            .extract_inner(key)
540            .map_err(ConfigError::ExtractError)
541    }
542
543    /// Deserialise a specific key and auto-register it in the config registry.
544    ///
545    /// Same as [`unmarshal_key`](Self::unmarshal_key) but also records the
546    /// section in the global [`registry`] for reflection. The type must
547    /// implement `Serialize + Default` so the registry can capture both
548    /// the effective and default values.
549    ///
550    /// # Errors
551    ///
552    /// Returns an error if deserialisation fails. The section is only
553    /// registered on success.
554    pub fn unmarshal_key_registered<T>(&self, key: &str) -> Result<T, ConfigError>
555    where
556        T: DeserializeOwned + serde::Serialize + Default + 'static,
557    {
558        let value: T = self.unmarshal_key(key)?;
559        registry::register::<T>(key, &value);
560        Ok(value)
561    }
562
563    /// Get the environment variable prefix.
564    #[must_use]
565    pub fn env_prefix(&self) -> &str {
566        &self.env_prefix
567    }
568}
569
570/// Hard-coded default values (lowest priority in cascade).
571#[derive(Debug, serde::Serialize)]
572struct HardcodedDefaults {
573    log_level: String,
574    log_format: String,
575}
576
577impl Default for HardcodedDefaults {
578    fn default() -> Self {
579        Self {
580            log_level: "info".to_string(),
581            log_format: "auto".to_string(),
582        }
583    }
584}
585
586/// Parse a duration string like "30s", "5m", "1h", "2h30m".
587fn parse_duration(s: &str) -> Option<Duration> {
588    let s = s.trim().to_lowercase();
589
590    // Try simple formats first
591    if let Some(secs) = s.strip_suffix('s') {
592        return secs.parse::<u64>().ok().map(Duration::from_secs);
593    }
594    if let Some(mins) = s.strip_suffix('m') {
595        return mins
596            .parse::<u64>()
597            .ok()
598            .map(|m| Duration::from_secs(m * 60));
599    }
600    if let Some(hours) = s.strip_suffix('h') {
601        return hours
602            .parse::<u64>()
603            .ok()
604            .map(|h| Duration::from_secs(h * 3600));
605    }
606
607    // Try parsing as seconds
608    s.parse::<u64>().ok().map(Duration::from_secs)
609}
610
611// Global singleton functions
612
613/// Initialise the global configuration.
614///
615/// # Errors
616///
617/// Returns an error if configuration loading fails or if already initialised.
618pub fn setup(opts: ConfigOptions) -> Result<(), ConfigError> {
619    let config = Config::new(opts)?;
620    CONFIG
621        .set(config)
622        .map_err(|_| ConfigError::AlreadyInitialised)
623}
624
625/// Initialise the global configuration with async loading (for PostgreSQL support).
626///
627/// This function loads configuration asynchronously, allowing PostgreSQL to be
628/// used as a config source.
629///
630/// # Errors
631///
632/// Returns an error if configuration loading fails or if already initialised.
633#[cfg(feature = "config-postgres")]
634pub async fn setup_async(opts: ConfigOptions) -> Result<(), ConfigError> {
635    let config = Config::new_async(opts).await?;
636    CONFIG
637        .set(config)
638        .map_err(|_| ConfigError::AlreadyInitialised)
639}
640
641/// Get the global configuration.
642///
643/// # Panics
644///
645/// Panics if configuration has not been initialised.
646#[must_use]
647pub fn get() -> &'static Config {
648    CONFIG
649        .get()
650        .expect("configuration not initialised - call config::setup() first")
651}
652
653/// Try to get the global configuration.
654#[must_use]
655pub fn try_get() -> Option<&'static Config> {
656    CONFIG.get()
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_parse_duration_seconds() {
665        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
666        assert_eq!(parse_duration("1s"), Some(Duration::from_secs(1)));
667    }
668
669    #[test]
670    fn test_parse_duration_minutes() {
671        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
672        assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60)));
673    }
674
675    #[test]
676    fn test_parse_duration_hours() {
677        assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
678        assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
679    }
680
681    #[test]
682    fn test_parse_duration_plain_number() {
683        assert_eq!(parse_duration("60"), Some(Duration::from_secs(60)));
684    }
685
686    #[test]
687    fn test_config_options_default() {
688        let opts = ConfigOptions::default();
689        assert!(opts.env_prefix.is_empty());
690        assert!(opts.app_env.is_none());
691        assert!(opts.app_name.is_none());
692        assert!(opts.config_paths.is_empty());
693        assert!(opts.load_dotenv);
694        assert!(!opts.load_home_dotenv);
695    }
696
697    #[test]
698    fn test_config_new() {
699        let config = Config::new(ConfigOptions::default());
700        assert!(config.is_ok());
701    }
702
703    #[test]
704    fn test_config_hardcoded_defaults() {
705        let config = Config::new(ConfigOptions::default()).unwrap();
706
707        // Should have hardcoded defaults
708        assert_eq!(config.get_string("log_level"), Some("info".to_string()));
709        assert_eq!(config.get_string("log_format"), Some("auto".to_string()));
710    }
711
712    #[test]
713    fn test_config_env_override() {
714        // Env vars use double underscore for nesting: PREFIX_KEY__SUBKEY -> key.subkey
715        // For flat keys, just use PREFIX_KEY -> key
716        temp_env::with_var("TEST_HOST", Some("testhost"), || {
717            let config = Config::new(ConfigOptions {
718                env_prefix: "TEST".into(),
719                ..Default::default()
720            })
721            .unwrap();
722            assert_eq!(config.get_string("host"), Some("testhost".to_string()));
723        });
724    }
725}