use std::collections::HashMap;
use crate::constants::{DEFAULT_ENV_PREFIX, ENV_KEY_SEPARATOR};
use crate::domain::{Format, RawContent, Result, Source, SourceError, SourceKind, SourceMetadata};
#[derive(Debug, Clone)]
pub struct EnvConfig {
pub prefix: String,
pub separator: char,
pub lowercase_keys: bool,
pub key_mappings: HashMap<String, String>,
}
impl Default for EnvConfig {
fn default() -> Self {
Self {
prefix: DEFAULT_ENV_PREFIX.to_string(),
separator: ENV_KEY_SEPARATOR,
lowercase_keys: true,
key_mappings: HashMap::new(),
}
}
}
impl EnvConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
#[must_use]
pub const fn separator(mut self, sep: char) -> Self {
self.separator = sep;
self
}
#[must_use]
pub const fn lowercase_keys(mut self, lowercase: bool) -> Self {
self.lowercase_keys = lowercase;
self
}
#[must_use]
pub fn with_mapping(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
self.key_mappings.insert(from.into(), to.into());
self
}
}
#[derive(Debug)]
pub struct EnvSourceBuilder {
config: EnvConfig,
required: bool,
overrides: HashMap<String, String>,
}
impl EnvSourceBuilder {
#[must_use]
pub fn new() -> Self {
Self {
config: EnvConfig::default(),
required: false,
overrides: HashMap::new(),
}
}
#[must_use]
pub fn config(mut self, config: EnvConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.config.prefix = prefix.into();
self
}
#[must_use]
pub const fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[must_use]
pub fn with_override(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.overrides.insert(key.into(), value.into());
self
}
#[must_use]
pub fn with_overrides(mut self, overrides: HashMap<String, String>) -> Self {
self.overrides.extend(overrides);
self
}
#[must_use]
pub fn build(self) -> EnvSource {
EnvSource {
config: self.config,
required: self.required,
overrides: self.overrides,
}
}
}
impl Default for EnvSourceBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub struct EnvSource {
config: EnvConfig,
required: bool,
overrides: HashMap<String, String>,
}
impl EnvSource {
#[must_use]
pub fn new() -> Self {
Self {
config: EnvConfig::default(),
required: false,
overrides: HashMap::new(),
}
}
#[must_use]
pub fn builder() -> EnvSourceBuilder {
EnvSourceBuilder::new()
}
#[must_use]
pub fn with_prefix(prefix: impl Into<String>) -> Self {
Self {
config: EnvConfig {
prefix: prefix.into(),
..EnvConfig::default()
},
required: false,
overrides: HashMap::new(),
}
}
fn collect_vars(&self) -> HashMap<String, String> {
let mut result = HashMap::new();
let prefix_with_sep = format!("{}{}", self.config.prefix, self.config.separator);
for (key, value) in &self.overrides {
if key.starts_with(&prefix_with_sep) {
result.insert(key.clone(), value.clone());
}
}
for (key, value) in std::env::vars() {
if key.starts_with(&prefix_with_sep) {
result.insert(key, value);
}
}
result
}
fn vars_to_nested_map(&self, vars: HashMap<String, String>) -> serde_json::Value {
let prefix_with_sep = format!("{}{}", self.config.prefix, self.config.separator);
let mut result = serde_json::Map::new();
for (key, value) in vars {
let key_without_prefix = key.strip_prefix(&prefix_with_sep).unwrap_or(&key);
let mapped_key = self
.config
.key_mappings
.get(key_without_prefix)
.map_or(key_without_prefix, String::as_str);
let final_key = if self.config.lowercase_keys {
mapped_key.to_lowercase()
} else {
mapped_key.to_string()
};
let parts: Vec<&str> = final_key.split(self.config.separator).collect();
Self::insert_nested(&mut result, &parts, value);
}
serde_json::Value::Object(result)
}
fn insert_nested(
map: &mut serde_json::Map<String, serde_json::Value>,
parts: &[&str],
value: String,
) {
if parts.is_empty() {
return;
}
if parts.len() == 1 {
let json_value =
serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value));
map.insert(parts[0].to_string(), json_value);
return;
}
let first = parts[0];
let rest = &parts[1..];
let child = map
.entry(first.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let serde_json::Value::Object(child_map) = child {
Self::insert_nested(child_map, rest, value);
}
}
}
impl Default for EnvSource {
fn default() -> Self {
Self::new()
}
}
impl Source for EnvSource {
fn kind(&self) -> SourceKind {
SourceKind::Env
}
fn metadata(&self) -> SourceMetadata {
SourceMetadata::new(format!("env:{}", self.config.prefix))
.with_env_var(&self.config.prefix)
.with_priority(200)
.with_optional(!self.required)
}
fn load_raw(&self) -> Result<RawContent> {
let vars = self.collect_vars();
if vars.is_empty() && self.required {
return Err(SourceError::not_found(
"No matching environment variables found",
));
}
let json_value = self.vars_to_nested_map(vars);
let content = serde_json::to_string(&json_value)
.map_err(|e| SourceError::serialization(&e.to_string()))?;
Ok(RawContent::from_string(content))
}
fn detect_format(&self) -> Option<Format> {
Some(Format::Json)
}
fn is_required(&self) -> bool {
self.required
}
}
#[cfg(test)]
#[allow(
clippy::items_after_statements,
clippy::iter_on_single_items,
clippy::approx_constant
)]
mod tests {
use super::*;
#[test]
fn test_env_config_default() {
let config = EnvConfig::default();
assert_eq!(config.prefix, "APP");
assert_eq!(config.separator, '_');
assert!(config.lowercase_keys);
}
#[test]
fn test_env_config_builder() {
let config = EnvConfig::new()
.prefix("MYAPP")
.separator(':')
.lowercase_keys(false);
assert_eq!(config.prefix, "MYAPP");
assert_eq!(config.separator, ':');
assert!(!config.lowercase_keys);
}
#[test]
fn test_env_source_builder() {
let source = EnvSource::builder().prefix("TEST").required(true).build();
assert!(source.is_required());
assert_eq!(source.config.prefix, "TEST");
}
#[test]
fn test_env_source_with_overrides() {
let source = EnvSource::builder()
.prefix("TEST")
.with_override("TEST_NAME", "test-value")
.with_override("TEST_NESTED_KEY", "nested-value")
.build();
assert_eq!(
source.overrides.get("TEST_NAME"),
Some(&"test-value".to_string())
);
assert_eq!(
source.overrides.get("TEST_NESTED_KEY"),
Some(&"nested-value".to_string())
);
}
#[test]
fn test_vars_to_nested_map() {
let source = EnvSource::with_prefix("TEST");
let vars: HashMap<String, String> = [
("TEST_NAME".to_string(), "myapp".to_string()),
("TEST_SERVER_HOST".to_string(), "localhost".to_string()),
("TEST_SERVER_PORT".to_string(), "8080".to_string()),
]
.into_iter()
.collect();
let nested = source.vars_to_nested_map(vars);
assert_eq!(nested.get("name"), Some(&serde_json::json!("myapp")));
assert!(nested.get("server").is_some());
let server = nested.get("server").unwrap().as_object().unwrap();
assert_eq!(server.get("host"), Some(&serde_json::json!("localhost")));
assert_eq!(server.get("port"), Some(&serde_json::json!(8080)));
}
#[test]
fn test_load_raw_with_overrides() {
let source = EnvSource {
config: EnvConfig::new().prefix("TEST"),
required: false,
overrides: [
("TEST_NAME".to_string(), "test-app".to_string()),
("TEST_VALUE".to_string(), "42".to_string()),
]
.into_iter()
.collect(),
};
let raw = source.load_raw().unwrap();
let content = raw.as_str().unwrap();
#[derive(Debug, serde::Deserialize, PartialEq)]
struct TestConfig {
name: String,
value: u32,
}
let config: TestConfig = serde_json::from_str(content.as_ref()).unwrap();
assert_eq!(config.name, "test-app");
assert_eq!(config.value, 42);
}
#[test]
fn test_metadata() {
let source = EnvSource::with_prefix("MYAPP");
let meta = source.metadata();
assert_eq!(meta.name, "env:MYAPP");
assert_eq!(source.kind(), SourceKind::Env);
assert_eq!(meta.priority, 200);
}
#[test]
fn test_key_mapping() {
let config = EnvConfig::new()
.prefix("TEST")
.with_mapping("OLD_KEY", "remapped");
let source = EnvSource {
config,
required: false,
overrides: [("TEST_OLD_KEY".to_string(), "value".to_string())]
.into_iter()
.collect(),
};
let vars = source.collect_vars();
assert!(!vars.is_empty());
let nested = source.vars_to_nested_map(vars);
assert_eq!(nested.get("remapped"), Some(&serde_json::json!("value")));
}
#[test]
fn test_empty_source() {
let source = EnvSource::builder()
.prefix("NONEXISTENT_PREFIX_XYZ")
.required(false)
.build();
let raw = source.load_raw().unwrap();
let content = raw.as_str().unwrap();
assert_eq!(content.as_ref(), "{}");
}
#[test]
fn test_json_value_parsing() {
let source = EnvSource {
config: EnvConfig::new().prefix("TEST"),
required: false,
overrides: [
("TEST_NUMBERS".to_string(), "[1, 2, 3]".to_string()),
("TEST_ENABLED".to_string(), "true".to_string()),
("TEST_RATIO".to_string(), "3.14".to_string()),
]
.into_iter()
.collect(),
};
let raw = source.load_raw().unwrap();
let content = raw.as_str().unwrap();
#[derive(Debug, serde::Deserialize, PartialEq)]
struct TestConfig {
numbers: Vec<i32>,
enabled: bool,
ratio: f64,
}
let config: TestConfig = serde_json::from_str(content.as_ref()).unwrap();
assert_eq!(config.numbers, vec![1, 2, 3]);
assert!(config.enabled);
assert!((config.ratio - 3.14).abs() < 0.001);
}
}