use indexmap::IndexMap;
use std::env;
use std::path::PathBuf;
pub use super::env_loader::EnvLoader;
pub use super::env_parser::{DatabaseUrl, parse_bool, parse_database_url, parse_list};
#[derive(Debug, Clone)]
pub struct Env {
pub prefix: Option<String>,
pub interpolate: bool,
#[allow(dead_code)]
cache: IndexMap<String, String>,
}
impl Env {
pub fn new() -> Self {
Self {
prefix: None,
interpolate: false,
cache: IndexMap::new(),
}
}
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
}
fn get_key_name(&self, key: &str) -> String {
match &self.prefix {
Some(prefix) => format!("{}{}", prefix, key),
None => key.to_string(),
}
}
pub fn str(&self, key: &str) -> Result<String, EnvError> {
self.str_with_default(key, None)
}
pub fn str_with_default(&self, key: &str, default: Option<&str>) -> Result<String, EnvError> {
let full_key = self.get_key_name(key);
validate_env_var_name(&full_key)?;
match env::var(&full_key) {
Ok(val) => Ok(val),
Err(_) => match default {
Some(d) => Ok(d.to_string()),
None => Err(EnvError::MissingVariable(full_key)),
},
}
}
pub fn bool(&self, key: &str) -> Result<bool, EnvError> {
self.bool_with_default(key, None)
}
pub fn bool_with_default(&self, key: &str, default: Option<bool>) -> Result<bool, EnvError> {
let full_key = self.get_key_name(key);
validate_env_var_name(&full_key)?;
match env::var(&full_key) {
Ok(val) => parse_bool(&val).map_err(|e| EnvError::ParseError {
key: full_key,
value_len: val.len(),
error: e,
}),
Err(_) => match default {
Some(d) => Ok(d),
None => Err(EnvError::MissingVariable(full_key)),
},
}
}
pub fn int(&self, key: &str) -> Result<i64, EnvError> {
self.int_with_default(key, None)
}
pub fn int_with_default(&self, key: &str, default: Option<i64>) -> Result<i64, EnvError> {
let full_key = self.get_key_name(key);
validate_env_var_name(&full_key)?;
match env::var(&full_key) {
Ok(val) => val.parse::<i64>().map_err(|e| EnvError::ParseError {
key: full_key,
value_len: val.len(),
error: e.to_string(),
}),
Err(_) => match default {
Some(d) => Ok(d),
None => Err(EnvError::MissingVariable(full_key)),
},
}
}
pub fn list(&self, key: &str) -> Result<Vec<String>, EnvError> {
self.list_with_default(key, None)
}
pub fn list_with_default(
&self,
key: &str,
default: Option<Vec<String>>,
) -> Result<Vec<String>, EnvError> {
let full_key = self.get_key_name(key);
validate_env_var_name(&full_key)?;
match env::var(&full_key) {
Ok(val) => Ok(parse_list(&val)),
Err(_) => match default {
Some(d) => Ok(d),
None => Err(EnvError::MissingVariable(full_key)),
},
}
}
pub fn database_url(&self, key: &str) -> Result<DatabaseUrl, EnvError> {
self.database_url_with_default(key, None)
}
pub fn database_url_with_default(
&self,
key: &str,
default: Option<&str>,
) -> Result<DatabaseUrl, EnvError> {
let full_key = self.get_key_name(key);
validate_env_var_name(&full_key)?;
let url_str = match env::var(&full_key) {
Ok(val) => val,
Err(_) => match default {
Some(d) => d.to_string(),
None => return Err(EnvError::MissingVariable(full_key)),
},
};
parse_database_url(&url_str).map_err(|e| EnvError::ParseError {
key: full_key,
value_len: url_str.len(),
error: e,
})
}
pub fn path(&self, key: &str) -> Result<PathBuf, EnvError> {
self.path_with_default(key, None)
}
pub fn path_with_default(
&self,
key: &str,
default: Option<PathBuf>,
) -> Result<PathBuf, EnvError> {
let full_key = self.get_key_name(key);
validate_env_var_name(&full_key)?;
match env::var(&full_key) {
Ok(val) => Ok(PathBuf::from(val)),
Err(_) => match default {
Some(d) => Ok(d),
None => Err(EnvError::MissingVariable(full_key)),
},
}
}
}
impl Default for Env {
fn default() -> Self {
Self::new()
}
}
pub fn validate_env_var_name(name: &str) -> Result<(), EnvError> {
if name.is_empty() {
return Err(EnvError::InvalidVariableName {
name: name.to_string(),
reason: "environment variable name must not be empty".to_string(),
});
}
if let Some(pos) = name.find(|c: char| c.is_control()) {
return Err(EnvError::InvalidVariableName {
name: name.to_string(),
reason: format!(
"environment variable name contains control character at position {}",
pos
),
});
}
if name.contains('=') {
return Err(EnvError::InvalidVariableName {
name: name.to_string(),
reason: "environment variable name must not contain '='".to_string(),
});
}
Ok(())
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum EnvError {
#[error("Missing environment variable: {0}")]
MissingVariable(String),
#[error("Failed to parse environment variable '{key}' (value length: {value_len}): {error}")]
ParseError {
key: String,
value_len: usize,
error: String,
},
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Invalid format: {0}")]
InvalidFormat(String),
#[error("Invalid environment variable name '{name}': {reason}")]
InvalidVariableName {
name: String,
reason: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use serial_test::serial;
#[rstest]
#[serial(env)]
fn test_env_str() {
unsafe {
env::set_var("TEST_STR", "hello");
}
let env = Env::new();
assert_eq!(env.str("TEST_STR").unwrap(), "hello");
unsafe {
env::remove_var("TEST_STR");
}
}
#[rstest]
fn test_env_str_with_default() {
let env = Env::new();
assert_eq!(
env.str_with_default("NONEXISTENT", Some("default"))
.unwrap(),
"default"
);
}
#[rstest]
#[serial(env)]
fn test_env_bool() {
unsafe {
env::set_var("TEST_BOOL_TRUE", "true");
env::set_var("TEST_BOOL_FALSE", "false");
env::set_var("TEST_BOOL_1", "1");
env::set_var("TEST_BOOL_0", "0");
}
let env = Env::new();
assert!(env.bool("TEST_BOOL_TRUE").unwrap());
assert!(!env.bool("TEST_BOOL_FALSE").unwrap());
assert!(env.bool("TEST_BOOL_1").unwrap());
assert!(!env.bool("TEST_BOOL_0").unwrap());
unsafe {
env::remove_var("TEST_BOOL_TRUE");
env::remove_var("TEST_BOOL_FALSE");
env::remove_var("TEST_BOOL_1");
env::remove_var("TEST_BOOL_0");
}
}
#[rstest]
#[serial(env)]
fn test_env_int() {
unsafe {
env::set_var("TEST_INT", "42");
}
let env = Env::new();
assert_eq!(env.int("TEST_INT").unwrap(), 42);
unsafe {
env::remove_var("TEST_INT");
}
}
#[rstest]
#[serial(env)]
fn test_env_list() {
unsafe {
env::set_var("TEST_LIST", "a,b,c");
}
let env = Env::new();
assert_eq!(env.list("TEST_LIST").unwrap(), vec!["a", "b", "c"]);
unsafe {
env::remove_var("TEST_LIST");
}
}
#[rstest]
#[serial(env)]
fn test_settings_env_with_prefix() {
unsafe {
env::set_var("REINHARDT_DEBUG", "true");
}
let env = Env::new().with_prefix("REINHARDT_");
assert!(env.bool("DEBUG").unwrap());
unsafe {
env::remove_var("REINHARDT_DEBUG");
}
}
#[rstest]
#[serial(env)]
fn test_env_path() {
unsafe {
env::set_var("TEST_PATH", "/tmp/test");
}
let env = Env::new();
assert_eq!(env.path("TEST_PATH").unwrap(), PathBuf::from("/tmp/test"));
unsafe {
env::remove_var("TEST_PATH");
}
}
#[rstest]
fn test_validate_env_var_name_rejects_empty() {
let result = validate_env_var_name("");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
EnvError::InvalidVariableName { .. }
));
}
#[rstest]
fn test_validate_env_var_name_rejects_control_chars() {
let result = validate_env_var_name("MY\x00VAR");
assert!(result.is_err());
let err = result.unwrap_err();
match &err {
EnvError::InvalidVariableName { reason, .. } => {
assert!(reason.contains("control character"));
}
_ => panic!("Expected InvalidVariableName error"),
}
}
#[rstest]
fn test_validate_env_var_name_rejects_equals_sign() {
let result = validate_env_var_name("MY=VAR");
assert!(result.is_err());
let err = result.unwrap_err();
match &err {
EnvError::InvalidVariableName { reason, .. } => {
assert!(reason.contains("'='"));
}
_ => panic!("Expected InvalidVariableName error"),
}
}
#[rstest]
fn test_validate_env_var_name_accepts_valid_name() {
assert!(validate_env_var_name("MY_VALID_VAR_123").is_ok());
assert!(validate_env_var_name("REINHARDT_DEBUG").is_ok());
}
#[rstest]
fn test_parse_error_does_not_leak_value() {
let err = EnvError::ParseError {
key: "SECRET_KEY".to_string(),
value_len: 32,
error: "invalid format".to_string(),
};
let error_msg = format!("{}", err);
assert!(error_msg.contains("value length: 32"));
assert!(!error_msg.contains("secret"));
}
#[rstest]
fn test_env_rejects_empty_key_name() {
let env = Env::new();
let result = env.str("");
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
EnvError::InvalidVariableName { .. }
));
}
}