pub mod env_compat;
pub mod flat_env;
pub mod registry;
pub mod sensitive;
#[cfg(feature = "config-reload")]
pub mod reloader;
#[cfg(feature = "config-reload")]
pub mod shared;
#[cfg(feature = "config-postgres")]
pub mod postgres;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use figment::Figment;
use figment::providers::{Env, Format, Serialized, Yaml};
use serde::de::DeserializeOwned;
use thiserror::Error;
use crate::env::get_app_env;
#[cfg(feature = "config-postgres")]
use self::postgres::{PostgresConfig, PostgresConfigError, PostgresConfigSource};
static CONFIG: OnceLock<Config> = OnceLock::new();
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to load config file '{path}': {message}")]
LoadError { path: PathBuf, message: String },
#[error("failed to extract config: {0}")]
ExtractError(#[from] figment::Error),
#[error("missing required config key: {0}")]
MissingKey(String),
#[error("invalid config value for '{key}': {reason}")]
InvalidValue { key: String, reason: String },
#[error("configuration already initialised")]
AlreadyInitialised,
#[error("configuration not initialised - call config::setup() first")]
NotInitialised,
#[cfg(feature = "config-postgres")]
#[error("PostgreSQL config error: {0}")]
Postgres(#[from] PostgresConfigError),
}
#[derive(Debug, Clone)]
pub struct ConfigOptions {
pub env_prefix: String,
pub app_env: Option<String>,
pub app_name: Option<String>,
pub config_paths: Vec<PathBuf>,
pub load_dotenv: bool,
pub load_home_dotenv: bool,
#[cfg(feature = "config-postgres")]
pub postgres: Option<PostgresConfigSource>,
}
impl Default for ConfigOptions {
fn default() -> Self {
Self {
env_prefix: String::new(),
app_env: None,
app_name: None,
config_paths: Vec::new(),
load_dotenv: true,
load_home_dotenv: false,
#[cfg(feature = "config-postgres")]
postgres: None,
}
}
}
#[derive(Debug)]
pub struct Config {
figment: Figment,
env_prefix: String,
}
impl Config {
fn resolve_app_name(explicit: Option<&str>) -> Option<String> {
explicit
.map(String::from)
.or_else(|| std::env::var("APP_NAME").ok())
.or_else(|| std::env::var("HYPERI_LIB_APP_NAME").ok())
}
pub fn new(opts: ConfigOptions) -> Result<Self, ConfigError> {
let app_env = opts.app_env.unwrap_or_else(get_app_env);
let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
let app_name_ref = resolved_app_name.as_deref();
if opts.load_dotenv {
Self::load_dotenv_cascade(opts.load_home_dotenv);
}
let mut figment = Figment::new();
figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
figment = figment.merge(Yaml::file(&path));
}
for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
figment = figment.merge(Yaml::file(&path));
}
let env_settings = format!("settings.{app_env}");
for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
figment = figment.merge(Yaml::file(&path));
}
if !opts.env_prefix.is_empty() {
figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
}
Ok(Self {
figment,
env_prefix: opts.env_prefix,
})
}
#[cfg(feature = "config-postgres")]
pub async fn new_async(opts: ConfigOptions) -> Result<Self, ConfigError> {
let app_env = opts.app_env.clone().unwrap_or_else(get_app_env);
let resolved_app_name = Self::resolve_app_name(opts.app_name.as_deref());
let app_name_ref = resolved_app_name.as_deref();
if opts.load_dotenv {
Self::load_dotenv_cascade(opts.load_home_dotenv);
}
let pg_source = opts
.postgres
.clone()
.unwrap_or_else(|| PostgresConfigSource::from_env(&opts.env_prefix));
let pg_config = PostgresConfig::load(&pg_source).await?;
let mut figment = Figment::new();
figment = figment.merge(Serialized::defaults(HardcodedDefaults::default()));
for path in Self::find_config_files("defaults", &opts.config_paths, app_name_ref) {
figment = figment.merge(Yaml::file(&path));
}
for path in Self::find_config_files("settings", &opts.config_paths, app_name_ref) {
figment = figment.merge(Yaml::file(&path));
}
let env_settings = format!("settings.{app_env}");
for path in Self::find_config_files(&env_settings, &opts.config_paths, app_name_ref) {
figment = figment.merge(Yaml::file(&path));
}
if let Some(ref pg) = pg_config {
let nested = pg.to_nested();
figment = figment.merge(Serialized::defaults(nested));
}
if !opts.env_prefix.is_empty() {
figment = figment.merge(Env::prefixed(&format!("{}_", opts.env_prefix)).split("__"));
}
Ok(Self {
figment,
env_prefix: opts.env_prefix,
})
}
fn load_dotenv_cascade(load_home: bool) {
use tracing::debug;
match dotenvy::dotenv() {
Ok(path) => {
debug!(path = %path.display(), "Loaded project .env file");
}
Err(dotenvy::Error::Io(ref e)) if e.kind() == std::io::ErrorKind::NotFound => {
}
Err(e) => {
debug!(error = %e, "Failed to load project .env file");
}
}
if load_home && let Some(home) = dirs::home_dir() {
let home_env = home.join(".env");
if home_env.exists() {
match dotenvy::from_path(&home_env) {
Ok(()) => {
debug!(path = %home_env.display(), "Loaded home .env file");
}
Err(e) => {
debug!(path = %home_env.display(), error = %e, "Failed to load home .env file");
}
}
}
}
}
fn find_config_files(
base_name: &str,
extra_paths: &[PathBuf],
app_name: Option<&str>,
) -> Vec<PathBuf> {
let mut files = Vec::new();
let extensions = ["yaml", "yml"];
for ext in &extensions {
let path = PathBuf::from(format!("{base_name}.{ext}"));
if path.exists() {
files.push(path);
break;
}
}
for ext in &extensions {
let path = PathBuf::from(format!("config/{base_name}.{ext}"));
if path.exists() {
files.push(path);
break;
}
}
let container_config = PathBuf::from("/config");
if container_config.is_dir() {
for ext in &extensions {
let path = container_config.join(format!("{base_name}.{ext}"));
if path.exists() {
files.push(path);
break;
}
}
}
if let Some(name) = app_name
&& let Some(config_dir) = dirs::config_dir()
{
let user_config = config_dir.join(name);
if user_config.is_dir() {
for ext in &extensions {
let path = user_config.join(format!("{base_name}.{ext}"));
if path.exists() {
files.push(path);
break;
}
}
}
}
for base in extra_paths {
for ext in &extensions {
let path = base.join(format!("{base_name}.{ext}"));
if path.exists() {
files.push(path);
break;
}
}
}
files
}
#[must_use]
pub fn merge_cli<T: serde::Serialize>(mut self, cli_args: T) -> Self {
self.figment = self.figment.merge(Serialized::defaults(cli_args));
self
}
#[must_use]
pub fn get_string(&self, key: &str) -> Option<String> {
self.figment.extract_inner::<String>(key).ok()
}
#[must_use]
pub fn get_int(&self, key: &str) -> Option<i64> {
self.figment.extract_inner::<i64>(key).ok()
}
#[must_use]
pub fn get_float(&self, key: &str) -> Option<f64> {
self.figment.extract_inner::<f64>(key).ok()
}
#[must_use]
pub fn get_bool(&self, key: &str) -> Option<bool> {
self.figment.extract_inner::<bool>(key).ok()
}
#[must_use]
pub fn get_duration(&self, key: &str) -> Option<Duration> {
let value = self.get_string(key)?;
parse_duration(&value)
}
#[must_use]
pub fn get_string_list(&self, key: &str) -> Option<Vec<String>> {
self.figment.extract_inner::<Vec<String>>(key).ok()
}
#[must_use]
pub fn contains(&self, key: &str) -> bool {
self.figment.find_value(key).is_ok()
}
pub fn unmarshal<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
self.figment.extract().map_err(ConfigError::ExtractError)
}
pub fn unmarshal_key<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
self.figment
.extract_inner(key)
.map_err(ConfigError::ExtractError)
}
pub fn unmarshal_key_registered<T>(&self, key: &str) -> Result<T, ConfigError>
where
T: DeserializeOwned + serde::Serialize + Default + 'static,
{
let value: T = self.unmarshal_key(key)?;
registry::register::<T>(key, &value);
Ok(value)
}
#[must_use]
pub fn env_prefix(&self) -> &str {
&self.env_prefix
}
}
#[derive(Debug, serde::Serialize)]
struct HardcodedDefaults {
log_level: String,
log_format: String,
}
impl Default for HardcodedDefaults {
fn default() -> Self {
Self {
log_level: "info".to_string(),
log_format: "auto".to_string(),
}
}
}
fn parse_duration(s: &str) -> Option<Duration> {
let s = s.trim().to_lowercase();
if let Some(secs) = s.strip_suffix('s') {
return secs.parse::<u64>().ok().map(Duration::from_secs);
}
if let Some(mins) = s.strip_suffix('m') {
return mins
.parse::<u64>()
.ok()
.map(|m| Duration::from_secs(m * 60));
}
if let Some(hours) = s.strip_suffix('h') {
return hours
.parse::<u64>()
.ok()
.map(|h| Duration::from_secs(h * 3600));
}
s.parse::<u64>().ok().map(Duration::from_secs)
}
pub fn setup(opts: ConfigOptions) -> Result<(), ConfigError> {
let config = Config::new(opts)?;
CONFIG
.set(config)
.map_err(|_| ConfigError::AlreadyInitialised)
}
#[cfg(feature = "config-postgres")]
pub async fn setup_async(opts: ConfigOptions) -> Result<(), ConfigError> {
let config = Config::new_async(opts).await?;
CONFIG
.set(config)
.map_err(|_| ConfigError::AlreadyInitialised)
}
#[must_use]
pub fn get() -> &'static Config {
CONFIG
.get()
.expect("configuration not initialised - call config::setup() first")
}
#[must_use]
pub fn try_get() -> Option<&'static Config> {
CONFIG.get()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_duration_seconds() {
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
assert_eq!(parse_duration("1s"), Some(Duration::from_secs(1)));
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
assert_eq!(parse_duration("1m"), Some(Duration::from_secs(60)));
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
}
#[test]
fn test_parse_duration_plain_number() {
assert_eq!(parse_duration("60"), Some(Duration::from_secs(60)));
}
#[test]
fn test_config_options_default() {
let opts = ConfigOptions::default();
assert!(opts.env_prefix.is_empty());
assert!(opts.app_env.is_none());
assert!(opts.app_name.is_none());
assert!(opts.config_paths.is_empty());
assert!(opts.load_dotenv);
assert!(!opts.load_home_dotenv);
}
#[test]
fn test_config_new() {
let config = Config::new(ConfigOptions::default());
assert!(config.is_ok());
}
#[test]
fn test_config_hardcoded_defaults() {
let config = Config::new(ConfigOptions::default()).unwrap();
assert_eq!(config.get_string("log_level"), Some("info".to_string()));
assert_eq!(config.get_string("log_format"), Some("auto".to_string()));
}
#[test]
fn test_config_env_override() {
temp_env::with_var("TEST_HOST", Some("testhost"), || {
let config = Config::new(ConfigOptions {
env_prefix: "TEST".into(),
..Default::default()
})
.unwrap();
assert_eq!(config.get_string("host"), Some("testhost".to_string()));
});
}
}