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_paths: Vec<PathBuf>,
138
139 pub load_dotenv: bool,
148
149 pub load_home_dotenv: bool,
154
155 #[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#[derive(Debug)]
177pub struct Config {
178 figment: Figment,
179 env_prefix: String,
180}
181
182impl Config {
183 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 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 if opts.load_dotenv {
204 Self::load_dotenv_cascade(opts.load_home_dotenv);
205 }
206
207 let mut figment = Figment::new();
209
210 figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
212
213 for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
215 figment = figment.merge(Yaml::file(&path));
216 }
217
218 for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
220 figment = figment.merge(Yaml::file(&path));
221 }
222
223 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 if !opts.env_prefix.is_empty() {
235 figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
236 }
237
238 Ok(Self {
241 figment,
242 env_prefix: opts.env_prefix,
243 })
244 }
245
246 #[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 if opts.load_dotenv {
262 Self::load_dotenv_cascade(opts.load_home_dotenv);
263 }
264
265 let pg_source = opts
267 .postgres
268 .clone()
269 .unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
270
271 let pg_config = PostgresConfig::load(&pg_source).await?;
273
274 let mut figment = Figment::new();
276
277 figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
279
280 for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
282 figment = figment.merge(Yaml::file(&path));
283 }
284
285 for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
287 figment = figment.merge(Yaml::file(&path));
288 }
289
290 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 if let Some(ref pg) = pg_config {
299 let nested = pg.to_nested();
300 figment = figment.merge(Serialized::defaults(nested));
301 }
302
303 if !opts.env_prefix.is_empty() {
307 figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
308 }
309
310 Ok(Self {
313 figment,
314 env_prefix: opts.env_prefix,
315 })
316 }
317
318 fn load_dotenv_cascade(load_home: bool) {
322 use tracing::debug;
323
324 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 }
332 Err(e) => {
333 debug!(error = %e, "Failed to load project .env file");
334 }
335 }
336
337 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 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 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 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 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 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 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 #[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 #[must_use]
441 pub fn get_string(&self, key: &str) -> Option<String> {
442 self.figment.extract_inner::<String>(key).ok()
443 }
444
445 #[must_use]
447 pub fn get_int(&self, key: &str) -> Option<i64> {
448 self.figment.extract_inner::<i64>(key).ok()
449 }
450
451 #[must_use]
453 pub fn get_float(&self, key: &str) -> Option<f64> {
454 self.figment.extract_inner::<f64>(key).ok()
455 }
456
457 #[must_use]
459 pub fn get_bool(&self, key: &str) -> Option<bool> {
460 self.figment.extract_inner::<bool>(key).ok()
461 }
462
463 #[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 #[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 #[must_use]
478 pub fn contains(&self, key: &str) -> bool {
479 self.figment.find_value(key).is_ok()
480 }
481
482 pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
488 self.figment.extract().map_err(ConfigError::ExtractError)
489 }
490
491 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 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 #[must_use]
524 pub fn env_prefix(&self) -> &str {
525 &self.env_prefix
526 }
527}
528
529#[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
545fn parse_duration(s: &str) -> Option<Duration> {
547 let s = s.trim().to_lowercase();
548
549 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 s.parse::<u64>().ok().map(Duration::from_secs)
568}
569
570pub 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#[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#[must_use]
603pub fn get() -> &'static Config {
604 CONFIG
605 .get()
606 .expect("configuration not initialised - call config::setup() first")
607}
608
609#[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 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 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}