1pub 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
82static CONFIG: OnceLock<Config> = OnceLock::new();
84
85#[derive(Debug, Error)]
87pub enum ConfigError {
88 #[error("failed to load config file '{path}': {message}")]
90 LoadError { path: PathBuf, message: String },
91
92 #[error("failed to extract config: {0}")]
94 ExtractError(#[from] figment::Error),
95
96 #[error("missing required config key: {0}")]
98 MissingKey(String),
99
100 #[error("invalid config value for '{key}': {reason}")]
102 InvalidValue { key: String, reason: String },
103
104 #[error("configuration already initialised")]
106 AlreadyInitialised,
107
108 #[error("configuration not initialised - call config::setup() first")]
110 NotInitialised,
111
112 #[cfg(feature = "config-postgres")]
114 #[error("PostgreSQL config error: {0}")]
115 Postgres(#[from] PostgresConfigError),
116}
117
118#[derive(Debug, Clone)]
120pub struct ConfigOptions {
121 pub env_prefix: String,
123
124 pub app_env: Option<String>,
126
127 pub app_name: Option<String>,
135
136 pub config_file: Option<PathBuf>,
147
148 pub config_paths: Vec<PathBuf>,
150
151 pub load_dotenv: bool,
160
161 pub load_home_dotenv: bool,
166
167 #[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#[derive(Debug)]
190pub struct Config {
191 figment: Figment,
192 env_prefix: String,
193}
194
195impl Config {
196 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 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 if opts.load_dotenv {
217 Self::load_dotenv_cascade(opts.load_home_dotenv);
218 }
219
220 let mut figment = Figment::new();
222
223 figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
225
226 for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
228 figment = figment.merge(Yaml::file(&path));
229 }
230
231 for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
233 figment = figment.merge(Yaml::file(&path));
234 }
235
236 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 if let Some(ref path) = opts.config_file {
248 figment = figment.merge(Yaml::file(path));
249 }
250
251 if !opts.env_prefix.is_empty() {
257 figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
258 }
259
260 Ok(Self {
263 figment,
264 env_prefix: opts.env_prefix,
265 })
266 }
267
268 #[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 if opts.load_dotenv {
284 Self::load_dotenv_cascade(opts.load_home_dotenv);
285 }
286
287 let pg_source = opts
289 .postgres
290 .clone()
291 .unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
292
293 let pg_config = PostgresConfig::load(&pg_source).await?;
295
296 let mut figment = Figment::new();
298
299 figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
301
302 for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
304 figment = figment.merge(Yaml::file(&path));
305 }
306
307 for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
309 figment = figment.merge(Yaml::file(&path));
310 }
311
312 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 if let Some(ref path) = opts.config_file {
322 figment = figment.merge(Yaml::file(path));
323 }
324
325 if let Some(ref pg) = pg_config {
328 let nested = pg.to_nested();
329 figment = figment.merge(Serialized::defaults(nested));
330 }
331
332 if !opts.env_prefix.is_empty() {
336 figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
337 }
338
339 Ok(Self {
342 figment,
343 env_prefix: opts.env_prefix,
344 })
345 }
346
347 fn load_dotenv_cascade(load_home: bool) {
351 use tracing::debug;
352
353 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 }
361 Err(e) => {
362 debug!(error = %e, "Failed to load project .env file");
363 }
364 }
365
366 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 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 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 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 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 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 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 #[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 #[must_use]
470 pub fn get_string(&self, key: &str) -> Option<String> {
471 self.figment.extract_inner::<String>(key).ok()
472 }
473
474 #[must_use]
476 pub fn get_int(&self, key: &str) -> Option<i64> {
477 self.figment.extract_inner::<i64>(key).ok()
478 }
479
480 #[must_use]
482 pub fn get_float(&self, key: &str) -> Option<f64> {
483 self.figment.extract_inner::<f64>(key).ok()
484 }
485
486 #[must_use]
488 pub fn get_bool(&self, key: &str) -> Option<bool> {
489 self.figment.extract_inner::<bool>(key).ok()
490 }
491
492 #[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 #[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 #[must_use]
507 pub fn contains(&self, key: &str) -> bool {
508 self.figment.find_value(key).is_ok()
509 }
510
511 pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
517 self.figment.extract().map_err(ConfigError::ExtractError)
518 }
519
520 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 #[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 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 #[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 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 #[must_use]
605 pub fn env_prefix(&self) -> &str {
606 &self.env_prefix
607 }
608}
609
610#[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
626fn parse_duration(s: &str) -> Option<Duration> {
628 let s = s.trim().to_lowercase();
629
630 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 s.parse::<u64>().ok().map(Duration::from_secs)
649}
650
651pub 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#[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#[must_use]
684pub fn get() -> &'static Config {
685 CONFIG
686 .get()
687 .expect("configuration not initialised - call config::setup() first")
688}
689
690#[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 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 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 assert_eq!(config.get_string("log_level"), Some("warn".to_string()));
767 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 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 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 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 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 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 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}