use std::path::{Path, PathBuf}; use std::{env, fs}; use figment::Figment; use figment::providers::{Env, Serialized}; use serde::de::DeserializeOwned; use crate::AppConfig; use crate::error::{ConfigError, ConfigLoadError}; pub const ENV_PREFIX: &str = "APP_"; pub const ENV_SEPARATOR: &str = "__"; pub const CONFIG_DIR: &str = "config"; pub const DEFAULT_PROFILE: &str = "default"; pub const LOCAL_PROFILE: &str = "local"; pub const PROD_PROFILE: &str = "prod"; pub const PROFILE_VAR: &str = "APP_ENV"; pub fn figment() -> Result { let _ = dotenvy::dotenv(); let profile = profile()?; let mut figment = Figment::new(); figment = merge_profile(figment, DEFAULT_PROFILE)?; if profile != DEFAULT_PROFILE { figment = merge_profile(figment, &profile)?; } Ok(figment.merge(Env::prefixed(ENV_PREFIX).split(ENV_SEPARATOR))) } pub fn profile() -> Result { let profile = env::var(PROFILE_VAR) .ok() .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| LOCAL_PROFILE.to_owned()); match profile.as_str() { DEFAULT_PROFILE | LOCAL_PROFILE | PROD_PROFILE => Ok(profile), other => Err(ConfigError::Validation(format!( "{PROFILE_VAR} must be one of {DEFAULT_PROFILE}, {LOCAL_PROFILE}, {PROD_PROFILE}; got `{other}`" ))), } } fn merge_profile(mut figment: Figment, profile: &str) -> Result { let dir = Path::new(CONFIG_DIR).join(profile); let entries = match fs::read_dir(&dir) { Ok(entries) => entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(figment), Err(error) => return Err(ConfigError::Io(error)), }; let mut files: Vec = entries .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| { path.extension() .and_then(|extension| extension.to_str()) .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) }) .collect(); files.sort(); for path in files { let Some(key) = path.file_stem().and_then(|stem| stem.to_str()) else { continue; }; let text = fs::read_to_string(&path)?; let value: serde_json::Value = serde_json::from_str(&text).map_err(|error| { ConfigError::Validation(format!("failed to parse {}: {error}", path.display())) })?; let mut domain = serde_json::Map::new(); domain.insert(key.to_owned(), value); figment = figment.merge(Serialized::defaults(serde_json::Value::Object(domain))); } Ok(figment) } pub fn load_default() -> Result { let cfg: AppConfig = figment()?.extract().map_err(ConfigError::from)?; cfg.validate()?; Ok(cfg) } pub trait Section { const KEY: &'static str; } #[derive(Debug, Clone)] pub struct Config { path: PathBuf, figment: Figment, } impl Config { pub fn from_value(value: serde_json::Value) -> Self { Self { path: PathBuf::from(CONFIG_DIR), figment: Figment::from(Serialized::defaults(value)), } } pub fn path(&self) -> &Path { &self.path } pub fn section(&self) -> Result { if !self.has(T::KEY) { return Err(ConfigLoadError::Section { key: T::KEY.to_owned(), path: self.path.clone(), }); } self.get(T::KEY) } pub fn get(&self, key: &str) -> Result { self.figment .extract_inner(key) .map_err(|error| ConfigLoadError::Load { path: self.path.clone(), message: format!("{key}: {error}"), }) } pub fn has(&self, key: &str) -> bool { self.figment.find_value(key).is_ok() } }