use serde::de::DeserializeOwned;
use std::{fmt, str::FromStr};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError {
key: String,
reason: String,
}
impl ConfigError {
fn new(key: &str, reason: impl Into<String>) -> Self {
Self {
key: key.into(),
reason: reason.into(),
}
}
pub fn invalid(key: &str, reason: &'static str) -> Self {
Self::new(key, reason)
}
pub fn key(&self) -> &str {
&self.key
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.key, self.reason)
}
}
impl std::error::Error for ConfigError {}
pub fn read_env(key: &str) -> Result<Option<String>, String> {
match std::env::var(key) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{key} must be valid UTF-8")),
}
}
pub struct Env<'a> {
lookup: &'a dyn Fn(&str) -> Result<Option<String>, String>,
}
impl Env<'static> {
pub fn process() -> Self {
Self::new(&read_env)
}
}
impl<'a> Env<'a> {
pub fn new(lookup: &'a impl Fn(&str) -> Result<Option<String>, String>) -> Self {
Self { lookup }
}
pub fn optional<T: FromStr>(&self, key: &str) -> Result<Option<T>, ConfigError> {
(self.lookup)(key)
.map_err(|_| ConfigError::new(key, "could not read setting"))?
.map(|value| {
value
.parse()
.map_err(|_| ConfigError::new(key, "invalid value or type"))
})
.transpose()
}
pub fn required<T: FromStr>(&self, key: &str) -> Result<T, ConfigError> {
let raw = self
.optional::<String>(key)?
.ok_or_else(|| ConfigError::new(key, "required setting is missing"))?;
if raw.trim().is_empty() {
return Err(ConfigError::new(key, "required setting must not be empty"));
}
raw.parse()
.map_err(|_| ConfigError::new(key, "invalid value or type"))
}
pub fn get_or<T: FromStr>(&self, key: &str, default: T) -> Result<T, ConfigError> {
Ok(self.optional(key)?.unwrap_or(default))
}
pub fn range(&self, key: &str, default: u64, min: u64, max: u64) -> Result<u64, ConfigError> {
let value = self.get_or(key, default)?;
if !(min..=max).contains(&value) {
return Err(ConfigError::new(
key,
format!("must be between {min} and {max}"),
));
}
Ok(value)
}
pub fn json<T: DeserializeOwned>(&self, key: &str, default: T) -> Result<T, ConfigError> {
match self.optional::<String>(key)? {
None => Ok(default),
Some(raw) => serde_json::from_str(&raw)
.map_err(|_| ConfigError::new(key, "invalid JSON value or type")),
}
}
pub fn validate<T>(
&self,
key: &str,
value: T,
predicate: impl FnOnce(&T) -> bool,
) -> Result<T, ConfigError> {
if predicate(&value) {
Ok(value)
} else {
Err(ConfigError::new(key, "failed validation"))
}
}
pub fn secret(&self, key: &str) -> Result<SecretString, ConfigError> {
self.required::<String>(key).map(SecretString)
}
}
#[derive(Clone)]
pub struct SecretString(String);
impl SecretString {
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("[REDACTED]")
}
}
pub struct ConfigFile {
values: toml::Table,
}
impl ConfigFile {
pub fn load(path: impl AsRef<std::path::Path>) -> Result<Self, ConfigError> {
let source = std::fs::read_to_string(path)
.map_err(|_| ConfigError::new("AppConfig.toml", "could not read configuration file"))?;
Self::parse(&source)
}
pub fn parse(source: &str) -> Result<Self, ConfigError> {
source
.parse::<toml::Table>()
.map(|values| Self { values })
.map_err(|_| ConfigError::new("AppConfig.toml", "invalid TOML syntax"))
}
fn find(&self, key: &str) -> Result<Option<&toml::Value>, ConfigError> {
let mut table = &self.values;
let mut parts = key.split('.').peekable();
while let Some(part) = parts.next() {
if part.is_empty() {
return Err(ConfigError::new(key, "invalid setting path"));
}
let Some(value) = table.get(part) else {
return Ok(None);
};
if parts.peek().is_none() {
return Ok(Some(value));
}
table = value
.as_table()
.ok_or_else(|| ConfigError::new(key, "expected a configuration table"))?;
}
Ok(None)
}
pub fn optional<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ConfigError> {
self.find(key)?
.cloned()
.map(|value| {
value
.try_into()
.map_err(|_| ConfigError::new(key, "invalid value or type"))
})
.transpose()
}
pub fn required<T: DeserializeOwned>(&self, key: &str) -> Result<T, ConfigError> {
let value = self
.find(key)?
.ok_or_else(|| ConfigError::new(key, "required setting is missing"))?;
if value.as_str().is_some_and(|value| value.trim().is_empty()) {
return Err(ConfigError::new(key, "required setting must not be empty"));
}
value
.clone()
.try_into()
.map_err(|_| ConfigError::new(key, "invalid value or type"))
}
pub fn get_or<T: DeserializeOwned>(&self, key: &str, default: T) -> Result<T, ConfigError> {
Ok(self.optional(key)?.unwrap_or(default))
}
pub fn range(&self, key: &str, default: u64, min: u64, max: u64) -> Result<u64, ConfigError> {
let value = self.get_or(key, default)?;
if !(min..=max).contains(&value) {
return Err(ConfigError::new(
key,
format!("must be between {min} and {max}"),
));
}
Ok(value)
}
pub fn check_keys(&self, allowed: &[&str]) -> Result<(), ConfigError> {
fn visit(table: &toml::Table, prefix: &str, allowed: &[&str]) -> Result<(), ConfigError> {
for (key, value) in table {
if key.contains('.') {
return Err(ConfigError::new(
key,
"use nested TOML tables instead of literal dotted keys",
));
}
let key = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
if let Some(table) = value.as_table() {
if !allowed
.iter()
.any(|path| path.starts_with(&format!("{key}.")))
{
return Err(ConfigError::new(&key, "unknown configuration section"));
}
visit(table, &key, allowed)?;
} else if !allowed.contains(&key.as_str()) {
return Err(ConfigError::new(&key, "unknown setting"));
}
}
Ok(())
}
visit(&self.values, "", allowed)
}
}