use super::env::EnvError;
use super::env_loader::EnvLoader;
use super::profile::Profile;
use indexmap::IndexMap;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
pub trait ConfigSource: Send + Sync {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError>;
fn priority(&self) -> u8;
fn description(&self) -> String;
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum SourceError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(String),
#[error("Environment error: {0}")]
Env(#[from] EnvError),
#[error("TOML error: {0}")]
Toml(#[from] toml::de::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Invalid source: {0}")]
InvalidSource(String),
#[error("Interpolation error: {0}")]
Interpolation(#[from] Box<super::interpolation::InterpolationError>),
}
impl From<super::interpolation::InterpolationError> for SourceError {
fn from(err: super::interpolation::InterpolationError) -> Self {
SourceError::Interpolation(Box::new(err))
}
}
pub struct EnvSource {
prefix: Option<String>,
interpolate: bool,
}
impl EnvSource {
pub fn new() -> Self {
Self {
prefix: None,
interpolate: false,
}
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = Some(prefix.into());
self
}
pub fn with_interpolation(mut self, enabled: bool) -> Self {
self.interpolate = enabled;
self
}
}
impl Default for EnvSource {
fn default() -> Self {
Self::new()
}
}
impl ConfigSource for EnvSource {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
let mut config = IndexMap::new();
for (key, value) in std::env::vars() {
if let Some(prefix) = &self.prefix
&& !key.starts_with(prefix)
{
continue;
}
let clean_key = if let Some(prefix) = &self.prefix {
key.strip_prefix(prefix).unwrap_or(&key).to_string()
} else {
key.clone()
};
let lower_key = clean_key.to_lowercase();
let parsed_value = if lower_key == "debug" {
match value.trim().to_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Value::Bool(true),
"false" | "0" | "no" | "off" => Value::Bool(false),
_ => {
if let Ok(b) = value.parse::<bool>() {
Value::Bool(b)
} else {
Value::String(value)
}
}
}
} else if lower_key == "allowed_hosts" {
let list: Vec<_> = value
.split(',')
.map(|s| Value::String(s.trim().to_string()))
.collect();
Value::Array(list)
} else if let Ok(num) = value.parse::<i64>() {
Value::Number(num.into())
} else if let Ok(b) = value.parse::<bool>() {
Value::Bool(b)
} else {
Value::String(value)
};
config.insert(lower_key, parsed_value);
}
Ok(config)
}
fn priority(&self) -> u8 {
100 }
fn description(&self) -> String {
match &self.prefix {
Some(prefix) => format!("Environment variables (prefix: {})", prefix),
None => "Environment variables".to_string(),
}
}
}
pub struct DotEnvSource {
path: Option<PathBuf>,
profile: Option<Profile>,
interpolate: bool,
}
impl DotEnvSource {
pub fn new() -> Self {
Self {
path: None,
profile: None,
interpolate: false,
}
}
pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
pub fn with_profile(mut self, profile: Profile) -> Self {
self.profile = Some(profile);
self
}
pub fn with_interpolation(mut self, enabled: bool) -> Self {
self.interpolate = enabled;
self
}
}
impl Default for DotEnvSource {
fn default() -> Self {
Self::new()
}
}
impl ConfigSource for DotEnvSource {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
let path = match &self.path {
Some(p) => p.clone(),
None => {
let filename = match &self.profile {
Some(profile) => profile.env_file_name(),
None => ".env".to_string(),
};
PathBuf::from(filename)
}
};
let loader = EnvLoader::new()
.path(&path)
.interpolate(self.interpolate)
.overwrite(false);
let _ = loader.load_optional()?;
Ok(IndexMap::new())
}
fn priority(&self) -> u8 {
90 }
fn description(&self) -> String {
match &self.path {
Some(path) => format!(".env file: {}", path.display()),
None => match &self.profile {
Some(profile) => format!(".env file: {}", profile.env_file_name()),
None => ".env file".to_string(),
},
}
}
}
pub struct TomlFileSource {
path: PathBuf,
interpolate: bool,
}
impl TomlFileSource {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
interpolate: true,
}
}
pub fn with_interpolation(mut self) -> Self {
self.interpolate = true;
self
}
pub fn without_interpolation(mut self) -> Self {
self.interpolate = false;
self
}
}
impl ConfigSource for TomlFileSource {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
if !self.path.exists() {
return Ok(IndexMap::new());
}
let content = fs::read_to_string(&self.path)?;
let mut toml_value: toml::Value = toml::from_str(&content)?;
if self.interpolate {
let lookup = |name: &str| std::env::var(name).ok();
let interpolator = super::interpolation::Interpolator::new(&lookup);
interpolator.interpolate_value(&mut toml_value, &self.path)?;
}
let json_str = serde_json::to_string(&toml_value)?;
let json_value: Value = serde_json::from_str(&json_str)?;
let map = json_value
.as_object()
.ok_or_else(|| SourceError::Parse("Expected object at root".to_string()))?;
Ok(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
}
fn priority(&self) -> u8 {
50 }
fn description(&self) -> String {
format!("TOML file: {}", self.path.display())
}
}
pub struct DefaultSource {
values: IndexMap<String, Value>,
}
impl DefaultSource {
pub fn new() -> Self {
Self {
values: IndexMap::new(),
}
}
pub fn with_value(mut self, key: impl Into<String>, value: Value) -> Self {
self.values.insert(key.into(), value);
self
}
pub fn with_defaults(mut self, defaults: HashMap<String, Value>) -> Self {
self.values.extend(defaults);
self
}
}
impl Default for DefaultSource {
fn default() -> Self {
Self::new()
}
}
impl ConfigSource for DefaultSource {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
Ok(self.values.clone())
}
fn priority(&self) -> u8 {
0 }
fn description(&self) -> String {
"Default values".to_string()
}
}
pub struct LowPriorityEnvSource {
inner: EnvSource,
}
impl LowPriorityEnvSource {
pub fn new() -> Self {
Self {
inner: EnvSource::new(),
}
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.inner = self.inner.with_prefix(prefix);
self
}
pub fn with_interpolation(mut self, enabled: bool) -> Self {
self.inner = self.inner.with_interpolation(enabled);
self
}
}
impl Default for LowPriorityEnvSource {
fn default() -> Self {
Self::new()
}
}
impl ConfigSource for LowPriorityEnvSource {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
self.inner.load()
}
fn priority(&self) -> u8 {
40 }
fn description(&self) -> String {
format!("{} (low priority)", self.inner.description())
}
}
pub struct HighPriorityEnvSource {
inner: EnvSource,
}
impl HighPriorityEnvSource {
pub fn new() -> Self {
Self {
inner: EnvSource::new(),
}
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.inner = self.inner.with_prefix(prefix);
self
}
pub fn with_interpolation(mut self, enabled: bool) -> Self {
self.inner = self.inner.with_interpolation(enabled);
self
}
}
impl Default for HighPriorityEnvSource {
fn default() -> Self {
Self::new()
}
}
impl ConfigSource for HighPriorityEnvSource {
fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
self.inner.load()
}
fn priority(&self) -> u8 {
60 }
fn description(&self) -> String {
format!("{} (high priority)", self.inner.description())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_env_source() {
unsafe {
env::set_var("SECRET_KEY", "test-secret");
env::set_var("DEBUG", "true");
}
let source = EnvSource::new();
let config = source.load().unwrap();
assert_eq!(
config.get("secret_key").unwrap(),
&Value::String("test-secret".to_string())
);
assert_eq!(config.get("debug").unwrap(), &Value::Bool(true));
unsafe {
env::remove_var("SECRET_KEY");
env::remove_var("DEBUG");
}
}
#[test]
fn test_toml_source() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut file = File::create(&config_path).unwrap();
writeln!(
file,
r#"
debug = true
secret_key = "test-key"
"#
)
.unwrap();
let source = TomlFileSource::new(&config_path);
let config = source.load().unwrap();
assert_eq!(config.get("debug").unwrap(), &Value::Bool(true));
assert_eq!(
config.get("secret_key").unwrap(),
&Value::String("test-key".to_string())
);
}
#[test]
fn test_default_source() {
let source = DefaultSource::new()
.with_value("key1", Value::String("value1".to_string()))
.with_value("key2", Value::Bool(true));
let config = source.load().unwrap();
assert_eq!(
config.get("key1").unwrap(),
&Value::String("value1".to_string())
);
assert_eq!(config.get("key2").unwrap(), &Value::Bool(true));
}
#[test]
fn test_source_priority() {
assert_eq!(EnvSource::new().priority(), 100);
assert_eq!(DotEnvSource::new().priority(), 90);
assert_eq!(HighPriorityEnvSource::new().priority(), 60);
assert_eq!(TomlFileSource::new("test.toml").priority(), 50);
assert_eq!(LowPriorityEnvSource::new().priority(), 40);
assert_eq!(DefaultSource::new().priority(), 0);
}
#[test]
fn test_high_priority_env_source_wraps_env_source() {
let source = HighPriorityEnvSource::new();
let priority = source.priority();
let description = source.description();
assert_eq!(priority, 60);
assert!(description.contains("high priority"));
}
#[test]
fn test_high_priority_env_source_with_prefix() {
let source = HighPriorityEnvSource::new().with_prefix("REINHARDT_TEST_");
let description = source.description();
assert!(description.contains("REINHARDT_TEST_"));
assert!(description.contains("high priority"));
}
#[test]
fn toml_file_source_without_interpolation_preserves_literal() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut file = File::create(&config_path).unwrap();
writeln!(file, r#"host = "${{LITERAL_VAR}}""#).unwrap();
let source = TomlFileSource::new(&config_path).without_interpolation();
let config = source.load().unwrap();
assert_eq!(
config.get("host").unwrap(),
&Value::String("${LITERAL_VAR}".to_string())
);
}
#[test]
fn test_high_priority_env_source_overrides_toml() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut file = File::create(&config_path).unwrap();
writeln!(file, r#"port = 1025"#).unwrap();
let prefix = "HPENV_TEST_3518_";
let env_key = format!("{prefix}PORT");
unsafe { env::set_var(&env_key, "9999") };
let settings = crate::settings::builder::SettingsBuilder::new()
.add_source(TomlFileSource::new(&config_path))
.add_source(HighPriorityEnvSource::new().with_prefix(prefix))
.build()
.unwrap();
let port: i64 = settings.get("port").unwrap();
assert_eq!(port, 9999);
unsafe { env::remove_var(&env_key) };
}
}