Skip to content
Clone
eeff292
129 lines 3.9 KB Rust
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<Figment, ConfigError> {
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<String, ConfigError> {
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<Figment, ConfigError> {
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<PathBuf> = 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<AppConfig, ConfigError> {
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<T: Section + DeserializeOwned>(&self) -> Result<T, ConfigLoadError> {
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<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigLoadError> {
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()
}
}