use crate::error::{ConfigError, ConfigResult};
use crate::impl_::loader::{self, Format};
use crate::interface::Source;
use crate::types::{AnnotatedValue, ConfigValue, SourceId, SourceKind};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug)]
pub struct FileSource {
path: PathBuf,
format: Option<Format>,
priority: u8,
optional: bool,
source_id: SourceId,
loader_config: loader::LoaderConfig,
}
impl FileSource {
pub fn new(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let source_id = SourceId::new(path.file_name().and_then(|n| n.to_str()).unwrap_or("file"));
Self {
path,
format: None,
priority: 0,
optional: false,
source_id,
loader_config: loader::LoaderConfig::default(),
}
}
pub fn with_format(mut self, format: Format) -> Self {
self.format = Some(format);
self
}
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
pub fn optional(mut self) -> Self {
self.optional = true;
self
}
pub fn allow_absolute_paths(mut self) -> Self {
self.loader_config = self.loader_config.allow_absolute();
self
}
pub fn with_loader_config(mut self, config: loader::LoaderConfig) -> Self {
self.loader_config = config;
self
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Source for FileSource {
fn collect(&self) -> ConfigResult<AnnotatedValue> {
if !self.path.exists() {
if self.optional {
return Ok(AnnotatedValue::new(
ConfigValue::Map(std::sync::Arc::new(indexmap::IndexMap::new())),
self.source_id.clone(),
"",
));
}
return Err(ConfigError::FileNotFound {
filename: self.path.clone(),
source: None,
});
}
loader::load_file(&self.path, &self.loader_config).map(|v| v.with_priority(self.priority))
}
fn priority(&self) -> u8 {
self.priority
}
fn name(&self) -> &str {
self.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
}
fn source_kind(&self) -> SourceKind {
SourceKind::File
}
fn is_optional(&self) -> bool {
self.optional
}
fn file_path(&self) -> Option<&Path> {
Some(&self.path)
}
}
#[derive(Debug)]
pub struct EnvSource {
prefix: Option<String>,
separator: String,
priority: u8,
source_id: SourceId,
file_suffix_enabled: bool,
file_suffix: &'static str,
}
impl EnvSource {
pub fn new() -> Self {
Self {
prefix: None,
separator: "_".to_string(),
priority: 50,
source_id: SourceId::new("env"),
file_suffix_enabled: true,
file_suffix: "_FILE",
}
}
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: Some(prefix.into()),
separator: "_".to_string(),
priority: 50,
source_id: SourceId::new("env"),
file_suffix_enabled: true,
file_suffix: "_FILE",
}
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
pub fn with_file_suffix(mut self, enabled: bool) -> Self {
self.file_suffix_enabled = enabled;
self
}
pub fn file_suffix(mut self, suffix: &'static str) -> Self {
self.file_suffix = suffix;
self
}
fn parse_key(&self, env_key: &str) -> Option<String> {
let key = if let Some(ref prefix) = self.prefix {
if !env_key.starts_with(prefix) {
return None;
}
&env_key[prefix.len()..]
} else {
env_key
};
let actual_key =
if self.file_suffix_enabled && self.prefix.is_some() && key.ends_with(self.file_suffix)
{
&key[..key.len() - self.file_suffix.len()]
} else if self.file_suffix_enabled
&& self.prefix.is_none()
&& key.ends_with(self.file_suffix)
{
return None;
} else {
key
};
Some(actual_key.to_lowercase().replace(&self.separator, "."))
}
fn resolve_value(&self, raw: &str, env_key: &str) -> ConfigResult<String> {
if self.file_suffix_enabled && env_key.ends_with(self.file_suffix) {
self.validate_file_path(raw)?;
std::fs::read_to_string(raw).map_err(|_| ConfigError::InvalidValue {
key: raw.to_string(),
expected_type: "readable file".to_string(),
message: format!("Cannot read file referenced by {}", env_key),
})
} else {
Ok(raw.to_string())
}
}
fn validate_file_path(&self, file_path: &str) -> ConfigResult<()> {
if file_path.is_empty() {
return Ok(());
}
let path = Path::new(file_path);
let canonical = std::fs::canonicalize(path).map_err(|_| ConfigError::FileNotFound {
filename: path.to_path_buf(),
source: Some(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Cannot resolve file path",
)),
})?;
let sensitive_prefixes = [
std::path::Path::new("/etc/shadow"),
std::path::Path::new("/etc/passwd"),
std::path::Path::new("/root"),
std::path::Path::new("/home"),
];
for prefix in &sensitive_prefixes {
if canonical.starts_with(prefix) {
return Err(ConfigError::InvalidValue {
key: "file_path".to_string(),
expected_type: "safe file path".to_string(),
message: format!("Access to {:?} is not allowed", prefix),
});
}
}
if !canonical.is_file() {
return Err(ConfigError::InvalidValue {
key: "file_path".to_string(),
expected_type: "regular file".to_string(),
message: "Only regular files can be read".to_string(),
});
}
if let Some(ext) = canonical.extension() {
let allowed = [
"txt", "json", "yaml", "yml", "toml", "ini", "env", "secret", "key", "pem", "crt",
];
if !allowed
.iter()
.any(|&e| ext.to_str().is_some_and(|s| s.eq_ignore_ascii_case(e)))
{
return Err(ConfigError::InvalidValue {
key: "file_path".to_string(),
expected_type: "allowed extension".to_string(),
message: format!("File extension {:?} is not allowed", ext),
});
}
}
Ok(())
}
}
impl Default for EnvSource {
fn default() -> Self {
Self::new()
}
}
impl Source for EnvSource {
fn collect(&self) -> ConfigResult<AnnotatedValue> {
let mut map = indexmap::IndexMap::new();
#[cfg(feature = "env")]
{
if let Ok(iter) = dotenvy::dotenv_iter() {
for item in iter.flatten() {
if let Some(config_path) = self.parse_key(&item.0) {
let resolved = self.resolve_value(&item.1, &item.0)?;
let value = AnnotatedValue::new(
ConfigValue::String(resolved),
self.source_id.clone(),
std::sync::Arc::from(config_path.as_str()),
)
.with_priority(self.priority.saturating_sub(10));
let parts: Vec<&str> = config_path.split('.').collect();
Self::insert_nested(&mut map, &parts, value);
}
}
}
}
for (key, value) in std::env::vars() {
if let Some(config_path) = self.parse_key(&key) {
let resolved = self.resolve_value(&value, &key)?;
let value = AnnotatedValue::new(
ConfigValue::String(resolved),
self.source_id.clone(),
std::sync::Arc::from(config_path.as_str()),
)
.with_priority(self.priority);
let parts: Vec<&str> = config_path.split('.').collect();
Self::insert_nested(&mut map, &parts, value);
}
}
Ok(AnnotatedValue::new(
ConfigValue::Map(std::sync::Arc::new(map)),
self.source_id.clone(),
"",
))
}
fn priority(&self) -> u8 {
self.priority
}
fn name(&self) -> &str {
"env"
}
fn source_kind(&self) -> SourceKind {
SourceKind::Environment
}
}
impl EnvSource {
fn insert_nested(
map: &mut indexmap::IndexMap<std::sync::Arc<str>, AnnotatedValue>,
parts: &[&str],
value: AnnotatedValue,
) {
if parts.is_empty() {
return;
}
if parts.len() == 1 {
map.insert(std::sync::Arc::from(parts[0]), value);
return;
}
let first = parts[0];
let remaining = &parts[1..];
let nested = map.entry(std::sync::Arc::from(first)).or_insert_with(|| {
AnnotatedValue::new(
ConfigValue::Map(std::sync::Arc::new(indexmap::IndexMap::new())),
value.source.clone(),
std::sync::Arc::from(first),
)
});
if let ConfigValue::Map(ref mut inner_map) = nested.inner {
if let Some(map_ref) = Arc::get_mut(inner_map) {
Self::insert_nested(map_ref, remaining, value);
} else {
let mut map_clone = (*inner_map).as_ref().clone();
Self::insert_nested(&mut map_clone, remaining, value);
*inner_map = Arc::new(map_clone);
}
}
}
}
#[derive(Debug)]
pub struct MemorySource {
values: HashMap<String, ConfigValue>,
priority: u8,
source_id: SourceId,
name: String,
}
impl MemorySource {
pub fn new() -> Self {
Self {
values: HashMap::new(),
priority: 0,
source_id: SourceId::new("memory"),
name: "memory".to_string(),
}
}
pub fn with_values(values: HashMap<String, ConfigValue>) -> Self {
Self {
values,
priority: 0,
source_id: SourceId::new("memory"),
name: "memory".to_string(),
}
}
pub fn set(mut self, key: impl Into<String>, value: ConfigValue) -> Self {
self.values.insert(key.into(), value);
self
}
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
}
impl Default for MemorySource {
fn default() -> Self {
Self::new()
}
}
impl Source for MemorySource {
fn collect(&self) -> ConfigResult<AnnotatedValue> {
let mut map = indexmap::IndexMap::new();
for (key, value) in &self.values {
let annotated = AnnotatedValue::new(
value.clone(),
self.source_id.clone(),
std::sync::Arc::from(key.as_str()),
)
.with_priority(self.priority);
let parts: Vec<&str> = key.split('.').collect();
EnvSource::insert_nested(&mut map, &parts, annotated);
}
Ok(AnnotatedValue::new(
ConfigValue::Map(std::sync::Arc::new(map)),
self.source_id.clone(),
"",
)
.with_priority(self.priority))
}
fn priority(&self) -> u8 {
self.priority
}
fn name(&self) -> &str {
&self.name
}
fn source_kind(&self) -> SourceKind {
SourceKind::Memory
}
}
#[derive(Debug)]
pub struct DefaultSource {
defaults: HashMap<String, ConfigValue>,
source_id: SourceId,
}
impl DefaultSource {
pub fn new() -> Self {
Self {
defaults: HashMap::new(),
source_id: SourceId::new("default"),
}
}
pub fn with_defaults(defaults: HashMap<String, ConfigValue>) -> Self {
Self {
defaults,
source_id: SourceId::new("default"),
}
}
pub fn set(mut self, key: impl Into<String>, value: ConfigValue) -> Self {
self.defaults.insert(key.into(), value);
self
}
}
impl Default for DefaultSource {
fn default() -> Self {
Self::new()
}
}
impl Source for DefaultSource {
fn collect(&self) -> ConfigResult<AnnotatedValue> {
let mut map = indexmap::IndexMap::new();
for (key, value) in &self.defaults {
let annotated = AnnotatedValue::new(
value.clone(),
self.source_id.clone(),
std::sync::Arc::from(key.as_str()),
)
.with_priority(0);
let parts: Vec<&str> = key.split('.').collect();
EnvSource::insert_nested(&mut map, &parts, annotated);
}
Ok(AnnotatedValue::new(
ConfigValue::Map(std::sync::Arc::new(map)),
self.source_id.clone(),
"",
))
}
fn priority(&self) -> u8 {
0 }
fn name(&self) -> &str {
"default"
}
fn source_kind(&self) -> SourceKind {
SourceKind::Default
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_source() {
let source = MemorySource::new()
.set("database.host", ConfigValue::string("localhost"))
.set("database.port", ConfigValue::uint(5432))
.with_priority(10);
let result = source.collect().unwrap();
assert_eq!(result.priority, 10);
assert!(result.is_map());
}
#[test]
fn test_default_source() {
let source = DefaultSource::new()
.set("app.name", ConfigValue::string("myapp"))
.set("app.debug", ConfigValue::bool(false));
let result = source.collect().unwrap();
assert_eq!(result.priority, 0);
}
#[test]
fn test_file_source_missing() {
let source = FileSource::new("/nonexistent/config.toml").optional();
let result = source.collect().unwrap();
assert!(result.is_map());
}
#[test]
fn test_env_source_prefix() {
std::env::set_var("TEST_APP_HOST", "localhost");
std::env::set_var("TEST_APP_PORT", "5432");
let source = EnvSource::with_prefix("TEST_APP_");
let result = source.collect().unwrap();
assert!(result.is_map());
std::env::remove_var("TEST_APP_HOST");
std::env::remove_var("TEST_APP_PORT");
}
#[test]
fn test_source_kind() {
let mem = MemorySource::new();
assert_eq!(mem.source_kind(), SourceKind::Memory);
let def = DefaultSource::new();
assert_eq!(def.source_kind(), SourceKind::Default);
let env = EnvSource::new();
assert_eq!(env.source_kind(), SourceKind::Environment);
}
#[test]
fn test_default_source_priority() {
let source = DefaultSource::new();
assert_eq!(source.priority(), 0);
}
#[test]
fn test_memory_source_priority() {
let source = MemorySource::new();
assert_eq!(source.priority(), 0);
}
#[test]
fn test_env_source_builder_priority() {
let source = EnvSource::with_prefix("X_").with_priority(99);
assert_eq!(source.priority(), 99);
}
#[test]
fn test_env_source_name_default() {
let source = EnvSource::new();
assert_eq!(source.name(), "env");
}
#[test]
fn test_memory_source_name() {
let source = MemorySource::new();
assert_eq!(source.name(), "memory");
}
#[test]
fn test_default_source_name() {
let source = DefaultSource::new();
assert_eq!(source.name(), "default");
}
#[test]
fn test_file_source_name() {
let source = FileSource::new("test.toml");
assert_eq!(source.name(), "test.toml");
}
#[test]
fn test_file_source_path() {
let source = FileSource::new("test.toml");
assert!(source.file_path().is_some());
assert!(!source.is_optional());
}
#[test]
fn test_file_source_optional_flag() {
let source = FileSource::new("missing.toml").optional();
assert!(source.is_optional());
}
#[test]
fn test_file_source_format() {
let source = FileSource::new("config.toml").with_format(crate::impl_::loader::Format::Toml);
assert_eq!(source.name(), "config.toml");
}
#[test]
fn test_memory_source_is_not_optional() {
let source = MemorySource::new();
assert!(!source.is_optional());
}
#[test]
fn test_source_kind_memory() {
let source = MemorySource::new();
assert_eq!(source.source_kind(), SourceKind::Memory);
}
#[test]
fn test_source_kind_default() {
let source = DefaultSource::new();
assert_eq!(source.source_kind(), SourceKind::Default);
}
#[test]
fn test_source_kind_file() {
let source = FileSource::new("test.toml");
assert_eq!(source.source_kind(), SourceKind::File);
}
#[test]
fn test_env_source_default_priority() {
let source = EnvSource::new();
assert_eq!(source.priority(), 50);
}
#[test]
fn test_env_source_default_trait() {
let source = EnvSource::default();
assert_eq!(source.name(), "env");
assert_eq!(source.priority(), 50);
}
#[test]
fn test_env_source_separator_setter() {
let source = EnvSource::new().separator("__");
let _ = source.collect();
}
#[test]
fn test_env_source_with_file_suffix_disabled() {
let source = EnvSource::with_prefix("X_").with_file_suffix(false);
assert_eq!(source.priority(), 50);
let _ = source.collect();
}
#[test]
fn test_env_source_custom_file_suffix() {
let source = EnvSource::with_prefix("X_").file_suffix("_SECRET"); assert_eq!(source.priority(), 50);
let _ = source.collect();
}
#[test]
fn test_file_source_with_priority() {
let source = FileSource::new("test.toml").with_priority(75);
assert_eq!(source.priority(), 75);
}
#[test]
fn test_file_source_with_loader_config() {
let config = loader::LoaderConfig::default();
let source = FileSource::new("test.toml").with_loader_config(config);
assert_eq!(source.name(), "test.toml");
}
#[test]
fn test_file_source_allow_absolute_paths() {
let source = FileSource::new("test.toml").allow_absolute_paths();
assert_eq!(source.name(), "test.toml");
}
#[test]
fn test_file_source_path_accessor() {
let source = FileSource::new("/some/path/config.json");
assert_eq!(source.path(), Path::new("/some/path/config.json"));
}
#[test]
fn test_file_source_missing_required_error() {
let source = FileSource::new("/nonexistent/required.toml");
let result = source.collect();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ConfigError::FileNotFound { .. }
));
}
#[test]
fn test_file_source_new_no_file_name() {
let source = FileSource::new("/");
assert_eq!(source.name(), "file");
}
#[test]
fn test_memory_source_with_values_constructor() {
let mut values = HashMap::new();
values.insert("key".to_string(), ConfigValue::string("val"));
let source = MemorySource::with_values(values);
let result = source.collect().unwrap();
assert!(result.is_map());
}
#[test]
fn test_memory_source_with_priority() {
let source = MemorySource::new().with_priority(42);
assert_eq!(source.priority(), 42);
}
#[test]
fn test_memory_source_with_name() {
let source = MemorySource::new().with_name("custom");
assert_eq!(source.name(), "custom");
}
#[test]
fn test_memory_source_collect_returns_priority() {
let source = MemorySource::new()
.set("key", ConfigValue::string("val"))
.with_priority(30);
let result = source.collect().unwrap();
assert_eq!(result.priority, 30);
}
#[test]
fn test_memory_source_default_trait() {
let source = MemorySource::default();
assert_eq!(source.name(), "memory");
assert_eq!(source.priority(), 0);
}
#[test]
fn test_memory_source_nested_keys() {
let source = MemorySource::new()
.set("database.host", ConfigValue::string("localhost"))
.set("database.port", ConfigValue::uint(5432));
let result = source.collect().unwrap();
assert!(result.is_map());
if let ConfigValue::Map(map) = &result.inner {
assert!(map.contains_key("database"));
} else {
panic!("expected map");
}
}
#[test]
fn test_default_source_with_defaults_constructor() {
let mut defaults = HashMap::new();
defaults.insert("key".to_string(), ConfigValue::string("val"));
let source = DefaultSource::with_defaults(defaults);
let result = source.collect().unwrap();
assert!(result.is_map());
assert_eq!(result.priority, 0);
}
#[test]
fn test_default_source_set_chained() {
let source = DefaultSource::new()
.set("a", ConfigValue::string("1"))
.set("b", ConfigValue::string("2"));
let result = source.collect().unwrap();
assert!(result.is_map());
}
#[test]
fn test_default_source_default_trait() {
let source = DefaultSource::default();
assert_eq!(source.name(), "default");
}
#[test]
fn test_default_source_collect_priority_zero() {
let source = DefaultSource::new().set("key", ConfigValue::string("val"));
let result = source.collect().unwrap();
assert_eq!(result.priority, 0);
}
#[serial_test::serial]
#[test]
fn test_env_source_file_suffix_reads_file() {
use std::io::Write;
let mut tmp = tempfile::Builder::new().suffix(".txt").tempfile().unwrap();
write!(tmp, "the_secret_content").unwrap(); let path = tmp.path().to_str().unwrap().to_string();
std::env::set_var("MYTEST_VAL_FILE", &path); let source = EnvSource::with_prefix("MYTEST_");
let result = source.collect();
std::env::remove_var("MYTEST_VAL_FILE"); let result = result.unwrap();
if let ConfigValue::Map(map) = &result.inner {
if let Some(av) = map.get("val") {
if let ConfigValue::String(s) = &av.inner {
assert_eq!(s, "the_secret_content"); return;
}
}
}
panic!("expected val key with string value read from file");
}
#[serial_test::serial]
#[test]
fn test_env_source_file_suffix_nonexistent_file() {
std::env::set_var("MYTEST_MISSING_FILE", "/nonexistent/path.txt"); let source = EnvSource::with_prefix("MYTEST_");
let result = source.collect();
std::env::remove_var("MYTEST_MISSING_FILE"); assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ConfigError::FileNotFound { .. }
));
}
#[serial_test::serial]
#[test]
fn test_env_source_file_suffix_blocks_sensitive_path() {
std::env::set_var("MYTEST_BLOCK_FILE", "/etc/passwd"); let source = EnvSource::with_prefix("MYTEST_");
let result = source.collect();
std::env::remove_var("MYTEST_BLOCK_FILE"); assert!(result.is_err());
}
#[serial_test::serial]
#[test]
fn test_env_source_file_suffix_rejects_directory() {
std::env::set_var("MYTEST_DIR_FILE", "/tmp"); let source = EnvSource::with_prefix("MYTEST_");
let result = source.collect();
std::env::remove_var("MYTEST_DIR_FILE"); assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ConfigError::InvalidValue { .. }
));
}
#[serial_test::serial]
#[test]
fn test_env_source_file_suffix_rejects_bad_extension() {
use std::io::Write;
let mut tmp = tempfile::Builder::new().suffix(".exe").tempfile().unwrap();
write!(tmp, "data").unwrap();
let path = tmp.path().to_str().unwrap().to_string();
std::env::set_var("MYTEST_EXT_FILE", &path); let source = EnvSource::with_prefix("MYTEST_");
let result = source.collect();
std::env::remove_var("MYTEST_EXT_FILE"); assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ConfigError::InvalidValue { .. }
));
}
#[serial_test::serial]
#[test]
fn test_env_source_file_suffix_empty_path() {
std::env::set_var("MYTEST_EMPTY_FILE", ""); let source = EnvSource::with_prefix("MYTEST_");
let result = source.collect();
std::env::remove_var("MYTEST_EMPTY_FILE"); assert!(result.is_err());
}
#[serial_test::serial]
#[test]
fn test_env_source_skips_file_suffix_without_prefix() {
std::env::set_var("MYTEST_NOPREFIX_FILE", "/tmp/x.txt"); let source = EnvSource::new(); let result = source.collect();
std::env::remove_var("MYTEST_NOPREFIX_FILE"); assert!(result.is_ok());
}
}