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    /// Additional paths to search for config files.
137    pub config_paths: Vec<PathBuf>,
138
139    /// Whether to load `.env` files.
140    ///
141    /// When enabled, loads `.env` files in this order (lowest to highest priority):
142    /// 1. `~/.env` (home directory - global defaults)
143    /// 2. Project `.env` (current directory - project overrides)
144    ///
145    /// Later files override earlier ones. Real environment variables always
146    /// take precedence over `.env` values.
147    pub load_dotenv: bool,
148
149    /// Whether to load home directory `.env` file (`~/.env`).
150    ///
151    /// Only applies when `load_dotenv` is true.
152    /// Default: false (opt-in, matching hyperi-pylib)
153    pub load_home_dotenv: bool,
154
155    /// PostgreSQL config source (optional, requires `config-postgres` feature).
156    #[cfg(feature = "config-postgres")]
157    pub postgres: Option<PostgresConfigSource>,
158}
159
160impl Default for ConfigOptions {
161    fn default() -> Self {
162        Self {
163            env_prefix: String::new(),
164            app_env: None,
165            app_name: None,
166            config_paths: Vec::new(),
167            load_dotenv: true,
168            load_home_dotenv: false,
169            #[cfg(feature = "config-postgres")]
170            postgres: None,
171        }
172    }
173}
174
175/// Configuration manager wrapping Figment.
176#[derive(Debug)]
177pub struct Config {
178    figment: Figment,
179    env_prefix: String,
180}
181
182impl Config {
183    /// Resolve the effective app name from explicit value or environment.
184    fn resolve_app_name(explicit: Option<&str>) -> Option<String> {
185        explicit
186            .map(String::from)
187            .or_else(|| std::env::var("APP_NAME").ok())
188            .or_else(|| std::env::var("HYPERI_LIB_APP_NAME").ok())
189    }
190
191    /// Create a new configuration with the given options.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if configuration loading fails.
196    pub fn new(opts: ConfigOptions) -> Result<Self, ConfigError> {
197        let app_env = opts.app_env.unwrap_or_else(get_app_env);
198        let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
199        let app_name_ref = resolved_app_name.as_deref();
200
201        // Load .env files (home = global defaults, project = overrides).
202        // Real env vars always win -- dotenvy doesn't overwrite.
203        if opts.load_dotenv {
204            Self::load_dotenv_cascade(opts.load_home_dotenv);
205        }
206
207        // Build the cascade (lowest to highest priority)
208        let mut figment = Figment::new();
209
210        // 7. Hard-coded defaults (lowest priority)
211        figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
212
213        // 6. defaults.yaml
214        for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
215            figment = figment.merge(Yaml::file(&path));
216        }
217
218        // 5. settings.yaml
219        for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
220            figment = figment.merge(Yaml::file(&path));
221        }
222
223        // 4. settings.{env}.yaml
224        let env_settings = format!("settings.{app_env}");
225        for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
226            figment = figment.merge(Yaml::file(&path));
227        }
228
229        // 3. .env file values are already loaded into env vars
230
231        // 2. Environment variables (with prefix)
232        // Keys are lowercased: TEST_DATABASE_HOST -> database_host
233        // Use double underscore for nesting: TEST_DATABASE__HOST -> database.host
234        if !opts.env_prefix.is_empty() {
235            figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
236        }
237
238        // 1. CLI args would be merged by the application via merge_cli()
239
240        Ok(Self {
241            figment,
242            env_prefix: opts.env_prefix,
243        })
244    }
245
246    /// Create a new configuration with async loading (for PostgreSQL support).
247    ///
248    /// PostgreSQL sits above file-based config in the cascade, so database
249    /// values override file values.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if configuration loading fails.
254    #[cfg(feature = "config-postgres")]
255    pub async fn new_async(opts: ConfigOptions) -> Result<Self, ConfigError> {
256        let app_env = opts.app_env.clone().unwrap_or_else(get_app_env);
257        let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
258        let app_name_ref = resolved_app_name.as_deref();
259
260        // Load .env files in cascade order (lowest to highest priority)
261        if opts.load_dotenv {
262            Self::load_dotenv_cascade(opts.load_home_dotenv);
263        }
264
265        // Determine PostgreSQL config source
266        let pg_source = opts
267            .postgres
268            .clone()
269            .unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
270
271        // Load PostgreSQL config (async)
272        let pg_config = PostgresConfig::load(&pg_source).await?;
273
274        // Build the cascade (lowest to highest priority)
275        let mut figment = Figment::new();
276
277        // 8. Hard-coded defaults (lowest priority)
278        figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
279
280        // 7. defaults.yaml
281        for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
282            figment = figment.merge(Yaml::file(&path));
283        }
284
285        // 6. settings.yaml
286        for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
287            figment = figment.merge(Yaml::file(&path));
288        }
289
290        // 5. settings.{env}.yaml
291        let env_settings = format!("settings.{app_env}");
292        for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
293            figment = figment.merge(Yaml::file(&path));
294        }
295
296        // 4. PostgreSQL config (above files, below .env). Figment merges are
297        // additive, later wins -- cascade position alone sets priority.
298        if let Some(ref pg) = pg_config {
299            let nested = pg.to_nested();
300            figment = figment.merge(Serialized::defaults(nested));
301        }
302
303        // 3. .env file values are already loaded into env vars
304
305        // 2. Environment variables (with prefix)
306        if !opts.env_prefix.is_empty() {
307            figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
308        }
309
310        // 1. CLI args would be merged by the application via merge_cli()
311
312        Ok(Self {
313            figment,
314            env_prefix: opts.env_prefix,
315        })
316    }
317
318    /// Load `.env` files: `~/.env` (global defaults) then project `.env`
319    /// (overrides). `dotenvy` doesn't overwrite, so load in reverse --
320    /// project first wins, home only fills gaps. Real env vars beat both.
321    fn load_dotenv_cascade(load_home: bool) {
322        use tracing::debug;
323
324        // Project .env first -- these values take precedence.
325        match dotenvy::dotenv() {
326            Ok(path) => {
327                debug!(path = %path.display(), "Loaded project .env file");
328            }
329            Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
330                // No project .env, that's fine
331            }
332            Err(e) => {
333                debug!(error = %e, "Failed to load project .env file");
334            }
335        }
336
337        // Load home directory .env (only fills in missing values)
338        if load_home && let Some(home) = dirs::home_dir() {
339            let home_env = home.join(".env");
340            if home_env.exists() {
341                match dotenvy::from_path(&home_env) {
342                    Ok(()) => {
343                        debug!(path = %home_env.display(), "Loaded home .env file");
344                    }
345                    Err(e) => {
346                        debug!(path = %home_env.display(), error = %e, "Failed to load home .env file");
347                    }
348                }
349            }
350        }
351    }
352
353    /// Find config files with the given base name.
354    ///
355    /// Search order (all locations merged, later overrides earlier within
356    /// the same cascade layer):
357    /// 1. Current directory: `./{name}.yaml`
358    /// 2. Project config subdir: `./config/{name}.yaml`
359    /// 3. Container mount: `/config/{name}.yaml` (always checked)
360    /// 4. User config: `~/.config/{app_name}/{name}.yaml` (when app_name set)
361    /// 5. Extra paths from `ConfigOptions::config_paths`
362    fn find_config_files(
363        base_name: &str,
364        extra_paths: &[PathBuf],
365        app_name: Option<&str>,
366    ) -> Vec<PathBuf> {
367        let mut files = Vec::new();
368        let extensions = ["yaml", "yml"];
369
370        // 1. Current directory
371        for ext in &extensions {
372            let path = PathBuf::from(format!("{base_name}.{ext}"));
373            if path.exists() {
374                files.push(path);
375                break;
376            }
377        }
378
379        // 2. Project config subdirectory
380        for ext in &extensions {
381            let path = PathBuf::from(format!("config/{base_name}.{ext}"));
382            if path.exists() {
383                files.push(path);
384                break;
385            }
386        }
387
388        // 3. Container config mount (/config/)
389        let container_config = PathBuf::from("/config");
390        if container_config.is_dir() {
391            for ext in &extensions {
392                let path = container_config.join(format!("{base_name}.{ext}"));
393                if path.exists() {
394                    files.push(path);
395                    break;
396                }
397            }
398        }
399
400        // 4. User config directory (~/.config/{app_name}/)
401        if let Some(name) = app_name
402            && let Some(config_dir) = dirs::config_dir()
403        {
404            let user_config = config_dir.join(name);
405            if user_config.is_dir() {
406                for ext in &extensions {
407                    let path = user_config.join(format!("{base_name}.{ext}"));
408                    if path.exists() {
409                        files.push(path);
410                        break;
411                    }
412                }
413            }
414        }
415
416        // 5. Extra paths (from ConfigOptions::config_paths)
417        for base in extra_paths {
418            for ext in &extensions {
419                let path = base.join(format!("{base_name}.{ext}"));
420                if path.exists() {
421                    files.push(path);
422                    break;
423                }
424            }
425        }
426
427        files
428    }
429
430    /// Merge CLI arguments into the configuration.
431    ///
432    /// Call this after parsing CLI args to add them as highest priority.
433    #[must_use]
434    pub fn merge_cli<T: serde::Serialize>(mut self, cli_args: T) -> Self {
435        self.figment = self.figment.merge(Serialized::defaults(cli_args));
436        self
437    }
438
439    /// Get a string value.
440    #[must_use]
441    pub fn get_string(&self, key: &str) -> Option<String> {
442        self.figment.extract_inner::<String>(key).ok()
443    }
444
445    /// Get an integer value.
446    #[must_use]
447    pub fn get_int(&self, key: &str) -> Option<i64> {
448        self.figment.extract_inner::<i64>(key).ok()
449    }
450
451    /// Get a float value.
452    #[must_use]
453    pub fn get_float(&self, key: &str) -> Option<f64> {
454        self.figment.extract_inner::<f64>(key).ok()
455    }
456
457    /// Get a boolean value.
458    #[must_use]
459    pub fn get_bool(&self, key: &str) -> Option<bool> {
460        self.figment.extract_inner::<bool>(key).ok()
461    }
462
463    /// Get a duration value (parses strings like "30s", "5m", "1h").
464    #[must_use]
465    pub fn get_duration(&self, key: &str) -> Option<Duration> {
466        let value = self.get_string(key)?;
467        parse_duration(&value)
468    }
469
470    /// Get a list of strings.
471    #[must_use]
472    pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
473        self.figment.extract_inner::<Vec<String>>(key).ok()
474    }
475
476    /// Check if a key exists.
477    #[must_use]
478    pub fn contains(&self, key: &str) -> bool {
479        self.figment.find_value(key).is_ok()
480    }
481
482    /// Deserialise the entire configuration into a typed struct.
483    ///
484    /// # Errors
485    ///
486    /// Returns an error if deserialisation fails.
487    pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
488        self.figment.extract().map_err(ConfigError::ExtractError)
489    }
490
491    /// Deserialise a specific key into a typed struct.
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if deserialisation fails.
496    pub fn unmarshal_key<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
497        self.figment
498            .extract_inner(key)
499            .map_err(ConfigError::ExtractError)
500    }
501
502    /// Deserialise a specific key and auto-register it in the config registry.
503    ///
504    /// Same as [`unmarshal_key`](Self::unmarshal_key) but also records the
505    /// section in the global [`registry`] for reflection. The type must
506    /// implement `Serialize + Default` so the registry can capture both
507    /// the effective and default values.
508    ///
509    /// # Errors
510    ///
511    /// Returns an error if deserialisation fails. The section is only
512    /// registered on success.
513    pub fn unmarshal_key_registered<T>(&self, key: &str) -> Result<T, ConfigError>
514    where
515        T: DeserializeOwned + serde::Serialize + Default + 'static,
516    {
517        let value: T = self.unmarshal_key(key)?;
518        registry::register::<T>(key, &value);
519        Ok(value)
520    }
521
522    /// Get the environment variable prefix.
523    #[must_use]
524    pub fn env_prefix(&self) -> &str {
525        &self.env_prefix
526    }
527}
528
529/// Hard-coded default values (lowest priority in cascade).
530#[derive(Debug, serde::Serialize)]
531struct HardcodedDefaults {
532    log_level: String,
533    log_format: String,
534}
535
536impl Default for HardcodedDefaults {
537    fn default() -> Self {
538        Self {
539            log_level: "info".to_string(),
540            log_format: "auto".to_string(),
541        }
542    }
543}
544
545/// Parse a duration string like "30s", "5m", "1h", "2h30m".
546fn parse_duration(s: &str) -> Option<Duration> {
547    let s = s.trim().to_lowercase();
548
549    // Try simple formats first
550    if let Some(secs) = s.strip_suffix('s') {
551        return secs.parse::<u64>().ok().map(Duration::from_secs);
552    }
553    if let Some(mins) = s.strip_suffix('m') {
554        return mins
555            .parse::<u64>()
556            .ok()
557            .map(|m| Duration::from_secs(m * 60));
558    }
559    if let Some(hours) = s.strip_suffix('h') {
560        return hours
561            .parse::<u64>()
562            .ok()
563            .map(|h| Duration::from_secs(h * 3600));
564    }
565
566    // Try parsing as seconds
567    s.parse::<u64>().ok().map(Duration::from_secs)
568}
569
570// Global singleton functions
571
572/// Initialise the global configuration.
573///
574/// # Errors
575///
576/// Returns an error if configuration loading fails or if already initialised.
577pub fn setup(opts: ConfigOptions) -> Result<(), ConfigError> {
578    let config = Config::new(opts)?;
579    CONFIG
580        .set(config)
581        .map_err(|_| ConfigError::AlreadyInitialised)
582}
583
584/// Initialise the global configuration with async loading (for PostgreSQL support).
585///
586/// # Errors
587///
588/// Returns an error if configuration loading fails or if already initialised.
589#[cfg(feature = "config-postgres")]
590pub async fn setup_async(opts: ConfigOptions) -> Result<(), ConfigError> {
591    let config = Config::new_async(opts).await?;
592    CONFIG
593        .set(config)
594        .map_err(|_| ConfigError::AlreadyInitialised)
595}
596
597/// Get the global configuration.
598///
599/// # Panics
600///
601/// Panics if configuration has not been initialised.
602#[must_use]
603pub fn get() -> &'static Config {
604    CONFIG
605        .get()
606        .expect("configuration not initialised - call config::setup() first")
607}
608
609/// Try to get the global configuration.
610#[must_use]
611pub fn try_get() -> Option<&'static Config> {
612    CONFIG.get()
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn test_parse_duration_seconds() {
621        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
622        assert_eq!(parse_duration("1s"), Some(Duration::from_secs(1)));
623    }
624
625    #[test]
626    fn test_parse_duration_minutes() {
627        assert_eq!(parse_duration("5m"), Some(Duration::from_mins(5)));
628        assert_eq!(parse_duration("1m"), Some(Duration::from_mins(1)));
629    }
630
631    #[test]
632    fn test_parse_duration_hours() {
633        assert_eq!(parse_duration("1h"), Some(Duration::from_hours(1)));
634        assert_eq!(parse_duration("2h"), Some(Duration::from_hours(2)));
635    }
636
637    #[test]
638    fn test_parse_duration_plain_number() {
639        assert_eq!(parse_duration("60"), Some(Duration::from_mins(1)));
640    }
641
642    #[test]
643    fn test_config_options_default() {
644        let opts = ConfigOptions::default();
645        assert!(opts.env_prefix.is_empty());
646        assert!(opts.app_env.is_none());
647        assert!(opts.app_name.is_none());
648        assert!(opts.config_paths.is_empty());
649        assert!(opts.load_dotenv);
650        assert!(!opts.load_home_dotenv);
651    }
652
653    #[test]
654    fn test_config_new() {
655        let config = Config::new(ConfigOptions::default());
656        assert!(config.is_ok());
657    }
658
659    #[test]
660    fn test_config_hardcoded_defaults() {
661        let config = Config::new(ConfigOptions::default()).unwrap();
662
663        // Should have hardcoded defaults
664        assert_eq!(config.get_string("log_level"), Some("info".to_string()));
665        assert_eq!(config.get_string("log_format"), Some("auto".to_string()));
666    }
667
668    #[test]
669    fn test_config_env_override() {
670        // Env vars use double underscore for nesting: PREFIX_KEY__SUBKEY -> key.subkey
671        // For flat keys, just use PREFIX_KEY -> key
672        temp_env::with_var("TEST_HOST", Some("testhost"), || {
673            let config = Config::new(ConfigOptions {
674                env_prefix: "TEST".into(),
675                ..Default::default()
676            })
677            .unwrap();
678            assert_eq!(config.get_string("host"), Some("testhost".to_string()));
679        });
680    }
681}