use std::collections::{HashMap, HashSet};
use crate::env::ConfigEnv;
use crate::error::{ConfigErrors, SourceLocation};
use crate::source::{ConfigValues, Source};
use crate::value::{ConfigValue, Value};
#[derive(Debug, Clone)]
pub struct Env {
prefix: String,
separator: String,
case_sensitive: bool,
list_separator: Option<String>,
custom_mappings: HashMap<String, String>,
excluded: HashSet<String>,
required_vars: HashSet<String>,
}
impl Env {
pub fn prefix(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
separator: "_".to_string(),
case_sensitive: false,
list_separator: None,
custom_mappings: HashMap::new(),
excluded: HashSet::new(),
required_vars: HashSet::new(),
}
}
pub fn all() -> Self {
Self::prefix("")
}
pub fn separator(mut self, sep: impl Into<String>) -> Self {
self.separator = sep.into();
self
}
pub fn map(mut self, env_suffix: impl Into<String>, path: impl Into<String>) -> Self {
self.custom_mappings.insert(env_suffix.into(), path.into());
self
}
pub fn exclude(mut self, var: impl Into<String>) -> Self {
self.excluded.insert(var.into());
self
}
pub fn case_sensitive(mut self) -> Self {
self.case_sensitive = true;
self
}
pub fn case_insensitive(mut self) -> Self {
self.case_sensitive = false;
self
}
pub fn list_separator(mut self, sep: impl Into<String>) -> Self {
self.list_separator = Some(sep.into());
self
}
pub fn require(mut self, var_name: impl Into<String>) -> Self {
self.required_vars.insert(var_name.into());
self
}
pub fn require_all(mut self, var_names: &[&str]) -> Self {
for name in var_names {
self.required_vars.insert(name.to_string());
}
self
}
}
#[cfg(feature = "watch")]
use std::path::PathBuf;
impl Source for Env {
fn load(&self, env: &dyn ConfigEnv) -> Result<ConfigValues, ConfigErrors> {
use crate::error::ConfigError;
let mut errors = Vec::new();
for var_name in &self.required_vars {
let full_name = if self.prefix.is_empty() {
var_name.clone()
} else {
format!("{}{}", self.prefix, var_name)
};
if env.get_env(&full_name).is_none() {
errors.push(ConfigError::MissingField {
path: suffix_to_path(var_name, &self.separator),
source_location: Some(SourceLocation::env(&full_name)),
searched_sources: vec!["environment".to_string()],
});
}
}
if !errors.is_empty() {
return Err(ConfigErrors::from_vec(errors).expect("errors vec is not empty"));
}
let mut values = ConfigValues::empty();
let prefix_for_comparison = if self.case_sensitive {
self.prefix.clone()
} else {
self.prefix.to_lowercase()
};
let env_vars = if self.prefix.is_empty() {
env.all_env_vars()
} else {
env.env_vars_with_prefix(&self.prefix)
};
for (key, value) in env_vars {
let key_for_comparison = if self.case_sensitive {
key.clone()
} else {
key.to_lowercase()
};
if !key_for_comparison.starts_with(&prefix_for_comparison) {
continue;
}
if self.excluded.contains(&key) {
continue;
}
let suffix = &key[self.prefix.len()..];
let suffix = suffix.strip_prefix(&self.separator).unwrap_or(suffix);
let path = if let Some(mapped) = self.custom_mappings.get(suffix) {
mapped.clone()
} else {
suffix_to_path(suffix, &self.separator)
};
let parsed_value = parse_env_value(&value, self.list_separator.as_deref());
let source = SourceLocation::env(&key);
values.insert(
path,
ConfigValue {
value: parsed_value,
source,
},
);
}
Ok(values)
}
fn name(&self) -> &str {
"environment"
}
#[cfg(feature = "watch")]
fn watch_path(&self) -> Option<PathBuf> {
None
}
#[cfg(feature = "watch")]
fn clone_box(&self) -> Box<dyn Source> {
Box::new(self.clone())
}
}
fn suffix_to_path(suffix: &str, separator: &str) -> String {
let parts: Vec<&str> = suffix.split(separator).collect();
let mut path_parts = Vec::new();
for part in parts {
let lower = part.to_lowercase();
if let Ok(idx) = lower.parse::<usize>() {
if let Some(last) = path_parts.last_mut() {
*last = format!("{}[{}]", last, idx);
continue;
}
}
path_parts.push(lower);
}
path_parts.join(".")
}
fn parse_env_value(value: &str, list_separator: Option<&str>) -> Value {
if let Some(sep) = list_separator {
if value.contains(sep) {
let items: Vec<Value> = value.split(sep).map(|s| parse_scalar(s.trim())).collect();
return Value::Array(items);
}
}
parse_scalar(value)
}
fn parse_scalar(value: &str) -> Value {
if let Ok(i) = value.parse::<i64>() {
return Value::Integer(i);
}
match value.to_lowercase().as_str() {
"true" | "yes" | "on" => return Value::Bool(true),
"false" | "no" | "off" => return Value::Bool(false),
_ => {}
}
if let Ok(f) = value.parse::<f64>() {
return Value::Float(f);
}
Value::String(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::MockEnv;
#[test]
fn test_env_basic_prefix() {
let env = MockEnv::new()
.with_env("APP_HOST", "localhost")
.with_env("APP_PORT", "8080")
.with_env("OTHER_VAR", "ignored");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
assert!(values.get("other_var").is_none());
}
#[test]
fn test_env_nested_paths() {
let env = MockEnv::new()
.with_env("APP_DATABASE_HOST", "localhost")
.with_env("APP_DATABASE_PORT", "5432")
.with_env("APP_DATABASE_POOL_SIZE", "10");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("database.host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("database.port").map(|v| v.value.as_integer()),
Some(Some(5432))
);
assert_eq!(
values
.get("database.pool.size")
.map(|v| v.value.as_integer()),
Some(Some(10))
);
}
#[test]
fn test_env_custom_separator() {
let env = MockEnv::new()
.with_env("APP__DATABASE__HOST", "localhost")
.with_env("APP__SERVER__PORT", "8080");
let source = Env::prefix("APP__").separator("__");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("database.host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("server.port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
}
#[test]
fn test_env_custom_mapping() {
let env = MockEnv::new()
.with_env("APP_DB_HOST", "localhost")
.with_env("APP_DB_PORT", "5432");
let source = Env::prefix("APP_")
.map("DB_HOST", "database.host")
.map("DB_PORT", "database.port");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("database.host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("database.port").map(|v| v.value.as_integer()),
Some(Some(5432))
);
}
#[test]
fn test_env_exclusions() {
let env = MockEnv::new()
.with_env("APP_HOST", "localhost")
.with_env("APP_SECRET", "super_secret");
let source = Env::prefix("APP_").exclude("APP_SECRET");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert!(values.get("secret").is_none());
}
#[test]
fn test_env_list_separator() {
let env = MockEnv::new()
.with_env("APP_HOSTS", "host1.com,host2.com,host3.com")
.with_env("APP_SINGLE", "just_one");
let source = Env::prefix("APP_").list_separator(",");
let values = source.load(&env).expect("should load successfully");
let hosts = values.get("hosts").map(|v| v.value.as_array());
assert!(hosts.is_some());
let hosts = hosts.unwrap().unwrap();
assert_eq!(hosts.len(), 3);
assert_eq!(hosts[0].as_str(), Some("host1.com"));
assert_eq!(hosts[1].as_str(), Some("host2.com"));
assert_eq!(hosts[2].as_str(), Some("host3.com"));
assert_eq!(
values.get("single").map(|v| v.value.as_str()),
Some(Some("just_one"))
);
}
#[test]
fn test_env_type_inference_bool() {
let env = MockEnv::new()
.with_env("APP_TRUE1", "true")
.with_env("APP_TRUE2", "yes")
.with_env("APP_TRUE3", "on")
.with_env("APP_FALSE1", "false")
.with_env("APP_FALSE2", "no")
.with_env("APP_FALSE3", "off")
.with_env("APP_INT_ZERO", "0")
.with_env("APP_INT_ONE", "1");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("true1").map(|v| v.value.as_bool()),
Some(Some(true))
);
assert_eq!(
values.get("true2").map(|v| v.value.as_bool()),
Some(Some(true))
);
assert_eq!(
values.get("true3").map(|v| v.value.as_bool()),
Some(Some(true))
);
assert_eq!(
values.get("false1").map(|v| v.value.as_bool()),
Some(Some(false))
);
assert_eq!(
values.get("false2").map(|v| v.value.as_bool()),
Some(Some(false))
);
assert_eq!(
values.get("false3").map(|v| v.value.as_bool()),
Some(Some(false))
);
assert_eq!(
values.get("int.zero").map(|v| v.value.as_integer()),
Some(Some(0))
);
assert_eq!(
values.get("int.one").map(|v| v.value.as_integer()),
Some(Some(1))
);
}
#[test]
fn test_env_type_inference_numbers() {
let env = MockEnv::new()
.with_env("APP_INT", "42")
.with_env("APP_NEGATIVE", "-10")
.with_env("APP_FLOAT", "3.25")
.with_env("APP_SCIENTIFIC", "1.5e10");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("int").map(|v| v.value.as_integer()),
Some(Some(42))
);
assert_eq!(
values.get("negative").map(|v| v.value.as_integer()),
Some(Some(-10))
);
assert_eq!(
values.get("float").map(|v| v.value.as_float()),
Some(Some(3.25))
);
let scientific = values.get("scientific").map(|v| v.value.as_float());
assert!(scientific.is_some());
assert!((scientific.unwrap().unwrap() - 1.5e10).abs() < 1.0);
}
#[test]
fn test_env_type_inference_string() {
let env = MockEnv::new()
.with_env("APP_STRING", "hello world")
.with_env("APP_URL", "https://example.com");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("string").map(|v| v.value.as_str()),
Some(Some("hello world"))
);
assert_eq!(
values.get("url").map(|v| v.value.as_str()),
Some(Some("https://example.com"))
);
}
#[test]
fn test_env_array_indices() {
let env = MockEnv::new()
.with_env("APP_SERVERS_0_HOST", "host1.com")
.with_env("APP_SERVERS_0_PORT", "8080")
.with_env("APP_SERVERS_1_HOST", "host2.com")
.with_env("APP_SERVERS_1_PORT", "8081");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("servers[0].host").map(|v| v.value.as_str()),
Some(Some("host1.com"))
);
assert_eq!(
values.get("servers[0].port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
assert_eq!(
values.get("servers[1].host").map(|v| v.value.as_str()),
Some(Some("host2.com"))
);
assert_eq!(
values.get("servers[1].port").map(|v| v.value.as_integer()),
Some(Some(8081))
);
}
#[test]
fn test_env_case_insensitive_default() {
let env = MockEnv::new()
.with_env("app_host", "lowercase")
.with_env("APP_PORT", "8080");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
}
#[test]
fn test_env_source_location() {
let env = MockEnv::new().with_env("APP_HOST", "localhost");
let source = Env::prefix("APP_");
let values = source.load(&env).expect("should load successfully");
let host_value = values.get("host").expect("host should exist");
assert_eq!(host_value.source.source, "env:APP_HOST");
}
#[test]
fn test_env_empty_prefix() {
let env = MockEnv::new()
.with_env("HOST", "localhost")
.with_env("PORT", "8080");
let source = Env::all();
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
}
#[test]
fn test_env_name() {
let source = Env::prefix("APP_");
assert_eq!(source.name(), "environment");
}
#[test]
fn test_suffix_to_path_simple() {
assert_eq!(suffix_to_path("HOST", "_"), "host");
assert_eq!(suffix_to_path("DATABASE_HOST", "_"), "database.host");
assert_eq!(
suffix_to_path("DATABASE_POOL_SIZE", "_"),
"database.pool.size"
);
}
#[test]
fn test_suffix_to_path_custom_separator() {
assert_eq!(suffix_to_path("DATABASE__HOST", "__"), "database.host");
}
#[test]
fn test_suffix_to_path_array_index() {
assert_eq!(suffix_to_path("SERVERS_0_HOST", "_"), "servers[0].host");
assert_eq!(suffix_to_path("ITEMS_1", "_"), "items[1]");
}
#[test]
fn test_parse_scalar_bool() {
assert_eq!(parse_scalar("true"), Value::Bool(true));
assert_eq!(parse_scalar("TRUE"), Value::Bool(true));
assert_eq!(parse_scalar("yes"), Value::Bool(true));
assert_eq!(parse_scalar("YES"), Value::Bool(true));
assert_eq!(parse_scalar("on"), Value::Bool(true));
assert_eq!(parse_scalar("ON"), Value::Bool(true));
assert_eq!(parse_scalar("false"), Value::Bool(false));
assert_eq!(parse_scalar("FALSE"), Value::Bool(false));
assert_eq!(parse_scalar("no"), Value::Bool(false));
assert_eq!(parse_scalar("NO"), Value::Bool(false));
assert_eq!(parse_scalar("off"), Value::Bool(false));
assert_eq!(parse_scalar("OFF"), Value::Bool(false));
assert_eq!(parse_scalar("0"), Value::Integer(0));
assert_eq!(parse_scalar("1"), Value::Integer(1));
}
#[test]
fn test_parse_scalar_integer() {
assert_eq!(parse_scalar("42"), Value::Integer(42));
assert_eq!(parse_scalar("-10"), Value::Integer(-10));
assert_eq!(parse_scalar("1000000"), Value::Integer(1000000));
}
#[test]
fn test_parse_scalar_float() {
assert_eq!(parse_scalar("3.25"), Value::Float(3.25));
assert_eq!(parse_scalar("-2.5"), Value::Float(-2.5));
}
#[test]
fn test_parse_scalar_string() {
assert_eq!(parse_scalar("hello"), Value::String("hello".to_string()));
assert_eq!(
parse_scalar("hello world"),
Value::String("hello world".to_string())
);
assert_eq!(parse_scalar("True!"), Value::String("True!".to_string()));
}
#[test]
fn test_parse_env_value_list() {
let value = parse_env_value("a,b,c", Some(","));
match value {
Value::Array(items) => {
assert_eq!(items.len(), 3);
assert_eq!(items[0], Value::String("a".to_string()));
assert_eq!(items[1], Value::String("b".to_string()));
assert_eq!(items[2], Value::String("c".to_string()));
}
_ => panic!("Expected array"),
}
}
#[test]
fn test_parse_env_value_list_with_numbers() {
let value = parse_env_value("1,2,3", Some(","));
match value {
Value::Array(items) => {
assert_eq!(items.len(), 3);
assert_eq!(items[0], Value::Integer(1));
assert_eq!(items[1], Value::Integer(2));
assert_eq!(items[2], Value::Integer(3));
}
_ => panic!("Expected array"),
}
}
#[test]
fn test_parse_env_value_no_list_separator() {
let value = parse_env_value("a,b,c", None);
assert_eq!(value, Value::String("a,b,c".to_string()));
}
#[test]
fn test_env_list_with_trimming() {
let env = MockEnv::new().with_env("APP_HOSTS", "host1 , host2 , host3");
let source = Env::prefix("APP_").list_separator(",");
let values = source.load(&env).expect("should load successfully");
let hosts = values.get("hosts").map(|v| v.value.as_array());
assert!(hosts.is_some());
let hosts = hosts.unwrap().unwrap();
assert_eq!(hosts.len(), 3);
assert_eq!(hosts[0].as_str(), Some("host1"));
assert_eq!(hosts[1].as_str(), Some("host2"));
assert_eq!(hosts[2].as_str(), Some("host3"));
}
#[test]
fn test_require_single_var_present() {
let env = MockEnv::new().with_env("APP_JWT_SECRET", "secret123");
let source = Env::prefix("APP_").require("JWT_SECRET");
let result = source.load(&env);
assert!(result.is_ok());
}
#[test]
fn test_require_single_var_missing() {
let env = MockEnv::new();
let source = Env::prefix("APP_").require("JWT_SECRET");
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
let error = errors.first();
if let Some(source_loc) = error.source_location() {
assert_eq!(source_loc.source, "env:APP_JWT_SECRET");
} else {
panic!("Expected source location");
}
}
#[test]
fn test_require_all_present() {
let env = MockEnv::new()
.with_env("APP_JWT_SECRET", "secret")
.with_env("APP_DATABASE_URL", "postgresql://localhost/db")
.with_env("APP_API_KEY", "key123");
let source = Env::prefix("APP_").require_all(&["JWT_SECRET", "DATABASE_URL", "API_KEY"]);
let result = source.load(&env);
assert!(result.is_ok());
}
#[test]
fn test_require_all_accumulates_errors() {
let env = MockEnv::new().with_env("APP_JWT_SECRET", "secret");
let source = Env::prefix("APP_").require_all(&["JWT_SECRET", "DATABASE_URL", "API_KEY"]);
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 2);
let error_sources: Vec<_> = errors
.iter()
.filter_map(|e| e.source_location().map(|sl| sl.source.as_str()))
.collect();
assert!(error_sources.contains(&"env:APP_DATABASE_URL"));
assert!(error_sources.contains(&"env:APP_API_KEY"));
}
#[test]
fn test_require_with_empty_prefix() {
let env = MockEnv::new().with_env("DATABASE_URL", "postgresql://localhost/db");
let source = Env::all().require("DATABASE_URL");
let result = source.load(&env);
assert!(result.is_ok());
}
#[test]
fn test_require_with_empty_prefix_missing() {
let env = MockEnv::new();
let source = Env::all().require("DATABASE_URL");
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
let error = errors.first();
if let Some(source_loc) = error.source_location() {
assert_eq!(source_loc.source, "env:DATABASE_URL");
} else {
panic!("Expected source location");
}
}
#[test]
fn test_require_partial_missing() {
let env = MockEnv::new()
.with_env("APP_HOST", "localhost")
.with_env("APP_PORT", "8080");
let source = Env::prefix("APP_").require("JWT_SECRET");
let result = source.load(&env);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
let error = errors.first();
if let Some(source_loc) = error.source_location() {
assert_eq!(source_loc.source, "env:APP_JWT_SECRET");
} else {
panic!("Expected source location");
}
}
#[test]
fn test_require_does_not_affect_normal_loading() {
let env = MockEnv::new()
.with_env("APP_JWT_SECRET", "secret123")
.with_env("APP_HOST", "localhost")
.with_env("APP_PORT", "8080");
let source = Env::prefix("APP_").require("JWT_SECRET");
let values = source.load(&env).expect("should load successfully");
assert_eq!(
values.get("jwt.secret").map(|v| v.value.as_str()),
Some(Some("secret123"))
);
assert_eq!(
values.get("host").map(|v| v.value.as_str()),
Some(Some("localhost"))
);
assert_eq!(
values.get("port").map(|v| v.value.as_integer()),
Some(Some(8080))
);
}
}