use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
TomlDe(#[from] toml::de::Error),
#[error("YAML parse error: {0}")]
YamlDe(#[from] serde_yaml::Error),
#[error("invalid value for environment variable `{name}`: `{value}`")]
InvalidEnvValue { name: String, value: String },
#[error("missing configuration key: {0}")]
MissingKey(String),
#[error("invalid type for configuration key: {0}")]
InvalidType(String),
#[error("Ambiguous namespace prefix '{prefix}' matches multiple config paths: {candidates:?}. Rename one of the conflicting tables or properties in oxidite.toml.")]
AmbiguousNamespace {
prefix: String,
candidates: Vec<String>,
},
#[error("Environment variable '{var_name}' matches namespace '{namespace}' but has an empty property key. Table-level overrides are not supported.")]
EmptyPropertyKey {
var_name: String,
namespace: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Environment {
Development,
Testing,
Production,
}
impl Environment {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"production" | "prod" => Self::Production,
"testing" | "test" => Self::Testing,
_ => Self::Development,
}
}
pub fn as_str(&self) -> &str {
match self {
Self::Development => "development",
Self::Testing => "testing",
Self::Production => "production",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub app: AppConfig,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub queue: QueueConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(flatten, default)]
pub custom: HashMap<String, toml::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
#[serde(default = "default_app_name")]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub environment: String,
#[serde(default)]
pub debug: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub workers: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub url: String,
#[serde(default = "default_pool_size")]
pub pool_size: u32,
#[serde(default)]
pub ssl: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default)]
pub driver: String,
#[serde(default)]
pub redis_url: String,
#[serde(default = "default_ttl")]
pub default_ttl: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueueConfig {
#[serde(default)]
pub driver: String,
#[serde(default)]
pub redis_url: String,
#[serde(default = "default_workers")]
pub workers: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
#[serde(default)]
pub jwt_secret: String,
#[serde(default = "default_jwt_expiry")]
pub jwt_expiry: u64,
#[serde(default)]
pub cors_origins: Vec<String>,
#[serde(default)]
pub cors_methods: Vec<String>,
#[serde(default)]
pub cors_headers: Vec<String>,
#[serde(default)]
pub rate_limit: u32,
}
fn default_app_name() -> String {
"oxidite-app".to_string()
}
fn default_host() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
3000
}
fn default_pool_size() -> u32 {
10
}
fn default_ttl() -> u64 {
3600
}
fn default_workers() -> usize {
4
}
fn default_jwt_expiry() -> u64 {
900
}
impl Default for AppConfig {
fn default() -> Self {
Self {
name: default_app_name(),
version: env!("CARGO_PKG_VERSION").to_string(),
environment: "development".to_string(),
debug: true,
}
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
host: default_host(),
port: default_port(),
workers: num_cpus::get(),
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: String::new(),
pool_size: default_pool_size(),
ssl: false,
}
}
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
driver: "memory".to_string(),
redis_url: String::new(),
default_ttl: default_ttl(),
}
}
}
impl Default for QueueConfig {
fn default() -> Self {
Self {
driver: "memory".to_string(),
redis_url: String::new(),
workers: default_workers(),
}
}
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
jwt_secret: String::new(),
jwt_expiry: default_jwt_expiry(),
cors_origins: vec![],
cors_methods: vec![],
cors_headers: vec![],
rate_limit: 0,
}
}
}
struct NamespaceEntry {
env_prefix: String,
config_path: Vec<String>,
}
fn coerce_env_value(raw: &str) -> toml::Value {
let trimmed = raw.trim();
match trimmed.to_lowercase().as_str() {
"true" => return toml::Value::Boolean(true),
"false" => return toml::Value::Boolean(false),
_ => {}
}
if let Ok(n) = trimmed.parse::<i64>() {
return toml::Value::Integer(n);
}
if trimmed.contains('.')
|| trimmed.contains('e')
|| trimmed.contains('E')
|| trimmed.eq_ignore_ascii_case("inf")
|| trimmed.eq_ignore_ascii_case("-inf")
|| trimmed.eq_ignore_ascii_case("nan")
{
if let Ok(f) = trimmed.parse::<f64>() {
return toml::Value::Float(f);
}
}
toml::Value::String(raw.to_string())
}
impl Default for Config {
fn default() -> Self {
Self {
app: AppConfig::default(),
server: ServerConfig::default(),
database: DatabaseConfig::default(),
cache: CacheConfig::default(),
queue: QueueConfig::default(),
security: SecurityConfig::default(),
env: HashMap::new(),
custom: HashMap::new(),
}
}
}
impl Config {
fn inject_env_vars(&self) {
for (key, value) in &self.env {
let already_set = env::var(key)
.map(|v| !v.is_empty())
.unwrap_or(false);
if !already_set {
env::set_var(key, value);
}
}
if let Ok(root) = toml::Value::try_from(self) {
if let toml::Value::Table(table) = root {
for (key, value) in table {
if key == "env" {
continue; }
Self::inject_namespaced_env(&key, &value);
}
}
}
}
fn inject_namespaced_env(prefix: &str, value: &toml::Value) {
let upper_prefix = prefix.to_uppercase();
match value {
toml::Value::Table(table) => {
for (key, val) in table {
let env_key = format!("{}_{}", upper_prefix, key.to_uppercase());
Self::inject_namespaced_env(&env_key, val);
}
}
_ => {
let already_set = env::var(prefix)
.map(|v| !v.is_empty())
.unwrap_or(false);
if !already_set {
let s = match value {
toml::Value::String(s) => s.clone(),
other => other.to_string(),
};
env::set_var(prefix, s);
}
}
}
}
fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
if let Ok(val) = env::var("APP_NAME") {
self.app.name = val;
}
if let Ok(val) = env::var("SERVER_HOST") {
self.server.host = val;
}
if let Ok(val) = env::var("SERVER_PORT") {
self.server.port = val
.parse()
.map_err(|_| ConfigError::InvalidEnvValue {
name: "SERVER_PORT".to_string(),
value: val,
})?;
}
if let Ok(val) = env::var("DATABASE_URL") {
self.database.url = val;
}
if let Ok(val) = env::var("REDIS_URL") {
self.cache.redis_url = val.clone();
self.queue.redis_url = val;
}
if let Ok(val) = env::var("JWT_SECRET") {
self.security.jwt_secret = val;
}
Ok(())
}
fn collect_env_overrides(&mut self, pre_dotenv_keys: &HashSet<String>) -> Result<(), ConfigError> {
let registry = self.build_namespace_registry()?;
for (env_name, raw_value) in env::vars() {
if pre_dotenv_keys.contains(&env_name) {
continue;
}
let Some(entry) = registry.iter().find(|e| {
env_name.starts_with(&e.env_prefix)
|| env_name == e.env_prefix.trim_end_matches('_')
}) else {
continue;
};
let remaining = if env_name.starts_with(&entry.env_prefix) {
&env_name[entry.env_prefix.len()..]
} else {
""
};
if remaining.is_empty() {
continue;
}
let field_key = remaining.to_lowercase();
let value = coerce_env_value(&raw_value);
Self::inject_env_override(&mut self.custom, &entry.config_path, &field_key, value);
}
Ok(())
}
fn build_namespace_registry(&self) -> Result<Vec<NamespaceEntry>, ConfigError> {
let mut registry: Vec<NamespaceEntry> = Vec::new();
for (key, value) in &self.custom {
Self::collect_custom_paths(key, value, &[], &mut registry);
}
let mut seen: HashMap<String, Vec<String>> = HashMap::new();
for entry in ®istry {
seen.entry(entry.env_prefix.clone())
.or_default()
.push(entry.config_path.join("."));
}
for (prefix, paths) in &seen {
let unique: HashSet<&str> = paths.iter().map(|s| s.as_str()).collect();
if unique.len() > 1 {
return Err(ConfigError::AmbiguousNamespace {
prefix: prefix.clone(),
candidates: unique.into_iter().map(|s| s.to_string()).collect(),
});
}
}
registry.sort_by(|a, b| b.env_prefix.len().cmp(&a.env_prefix.len()));
Ok(registry)
}
fn collect_custom_paths(
key: &str,
value: &toml::Value,
ancestors: &[String],
registry: &mut Vec<NamespaceEntry>,
) {
if let toml::Value::Table(table) = value {
let mut path: Vec<String> = ancestors.to_vec();
for segment in key.split('.') {
path.push(segment.to_string());
}
let prefix = path.iter()
.map(|s| s.to_uppercase())
.collect::<Vec<_>>()
.join("_")
+ "_";
registry.push(NamespaceEntry {
env_prefix: prefix,
config_path: path.clone(),
});
for (sub_key, sub_val) in table {
Self::collect_custom_paths(sub_key, sub_val, &path, registry);
}
}
}
fn inject_env_override(
custom: &mut HashMap<String, toml::Value>,
config_path: &[String],
field_key: &str,
value: toml::Value,
) {
if config_path.is_empty() {
return;
}
let namespace = &config_path[0];
let Some(toml::Value::Table(ref mut top_table)) = custom.get_mut(namespace) else {
return;
};
if config_path.len() == 1 {
top_table.insert(field_key.to_string(), value);
return;
}
let mut current = top_table;
for segment in &config_path[1..] {
match current.get_mut(segment) {
Some(toml::Value::Table(ref mut next)) => current = next,
_ => return,
}
}
current.insert(field_key.to_string(), value);
}
pub fn has_key(&self, key: &str) -> bool {
{
let mut parts = key.split('.');
if let Some(first) = parts.next() {
if let Some(val) = self.custom.get(first) {
let mut cur = val;
let mut found = true;
for part in parts {
if let Some(next) = cur.get(part) {
cur = next;
} else {
found = false;
break;
}
}
if found {
return true;
}
}
}
}
let root = toml::Value::try_from(self).ok();
if let Some(root) = root {
let mut cursor = &root;
for part in key.split('.') {
if let Some(next) = cursor.get(part) {
cursor = next;
} else {
return false;
}
}
return true;
}
false
}
pub fn load() -> Result<Self, ConfigError> {
let pre_dotenv_keys: HashSet<String> = env::vars()
.map(|(k, _)| k)
.collect();
if env::var("OXIDITE_SKIP_DOTENV").is_err() {
let _ = dotenv::dotenv();
}
let env_val = env::var("OXIDITE_ENV")
.or_else(|_| env::var("ENVIRONMENT"))
.unwrap_or_else(|_| "development".to_string());
let mut config = if Path::new("oxidite.toml").exists() {
let content = fs::read_to_string("oxidite.toml")?;
toml::from_str(&content)?
} else {
Config::default()
};
config.collect_env_overrides(&pre_dotenv_keys)?;
config.inject_env_vars();
config.apply_env_overrides()?;
config.app.environment = env_val;
Ok(config)
}
pub fn load_from(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let pre_dotenv_keys: HashSet<String> = env::vars()
.map(|(k, _)| k)
.collect();
if env::var("OXIDITE_SKIP_DOTENV").is_err() {
let _ = dotenv::dotenv();
}
let env_name = env::var("OXIDITE_ENV")
.or_else(|_| env::var("ENVIRONMENT"))
.unwrap_or_else(|_| "development".to_string());
let mut config = if path.as_ref().exists() {
let content = fs::read_to_string(path)?;
toml::from_str(&content)?
} else {
Config::default()
};
config.collect_env_overrides(&pre_dotenv_keys)?;
config.inject_env_vars();
config.app.environment = env_name;
config.apply_env_overrides()?;
Ok(config)
}
pub fn get<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
{
let mut parts = key.split('.');
if let Some(first) = parts.next() {
if let Some(val) = self.custom.get(first) {
let mut cursor = val;
let mut found = true;
for part in parts {
if let Some(next) = cursor.get(part) {
cursor = next;
} else {
found = false;
break;
}
}
if found {
if let Ok(parsed) = T::deserialize(cursor.clone()) {
return Some(parsed);
}
}
}
}
}
let root = toml::Value::try_from(self).ok()?;
let mut cursor = &root;
for part in key.split('.') {
cursor = cursor.get(part)?;
}
T::deserialize(cursor.clone()).ok()
}
pub fn get_required<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T, ConfigError> {
self.get(key).ok_or_else(|| {
if self.has_key(key) {
ConfigError::InvalidType(key.to_string())
} else {
ConfigError::MissingKey(key.to_string())
}
})
}
pub fn get_u16(&self, key: &str) -> Result<u16, ConfigError> {
self.get_required(key)
}
pub fn get_bool(&self, key: &str) -> Result<bool, ConfigError> {
self.get_required(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_from_toml_with_env(
toml_str: &str,
env_vars: &[(&str, &str)],
) -> Result<Config, ConfigError> {
let _lock = SERIAL_TEST.lock().unwrap();
for &(k, _) in env_vars {
let _ = env::remove_var(k);
}
let pre_set_keys: HashSet<String> = env::vars().map(|(k, _)| k).collect();
let mut backups: Vec<(String, Option<String>)> = Vec::new();
for &(k, v) in env_vars {
backups.push((k.to_string(), env::var(k).ok()));
env::set_var(k, v);
}
let mut config: Config = toml::from_str(toml_str).unwrap();
config.collect_env_overrides(&pre_set_keys)?;
config.inject_env_vars();
config.apply_env_overrides()?;
for (k, prev) in backups {
match prev {
Some(v) => env::set_var(&k, v),
None => env::remove_var(&k),
}
}
Ok(config)
}
#[test]
fn test_default_config() {
let config = Config::default();
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 3000);
}
#[test]
fn test_environment_parsing() {
assert_eq!(Environment::from_str("production"), Environment::Production);
assert_eq!(Environment::from_str("PROD"), Environment::Production);
}
#[test]
fn test_get_required_typed_values() {
let config = Config::default();
assert_eq!(config.get_u16("server.port").unwrap(), 3000);
}
#[test]
fn test_invalid_server_port_env_returns_error() {
let _lock = SERIAL_TEST.lock().unwrap();
let prev = env::var("SERVER_PORT").ok();
env::set_var("SERVER_PORT", "not-a-port");
let result = Config::load();
if let Some(v) = prev {
env::set_var("SERVER_PORT", v);
} else {
env::remove_var("SERVER_PORT");
}
assert!(result.is_err());
}
#[test]
fn test_load_from_applies_env_overrides() {
let _lock = SERIAL_TEST.lock().unwrap();
let prev_host = env::var("SERVER_HOST").ok();
env::set_var("SERVER_HOST", "0.0.0.0");
let cfg = Config::load_from("non-existent.toml").unwrap();
if let Some(v) = prev_host {
env::set_var("SERVER_HOST", v);
} else {
env::remove_var("SERVER_HOST");
}
assert_eq!(cfg.server.host, "0.0.0.0");
}
#[test]
fn test_flat_env_table_injection() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[env]
FLAT_TEST_VAR = "flat_value"
"#;
let prev = env::var("FLAT_TEST_VAR").ok();
env::remove_var("FLAT_TEST_VAR");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("FLAT_TEST_VAR").unwrap(), "flat_value");
if let Some(v) = prev {
env::set_var("FLAT_TEST_VAR", v);
} else {
env::remove_var("FLAT_TEST_VAR");
}
}
#[test]
fn test_namespaced_env_injection() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[google]
client_id = "g-123"
client_secret = "g-secret"
"#;
let prev_id = env::var("GOOGLE_CLIENT_ID").ok();
let prev_secret = env::var("GOOGLE_CLIENT_SECRET").ok();
env::remove_var("GOOGLE_CLIENT_ID");
env::remove_var("GOOGLE_CLIENT_SECRET");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("GOOGLE_CLIENT_ID").unwrap(), "g-123");
assert_eq!(env::var("GOOGLE_CLIENT_SECRET").unwrap(), "g-secret");
if let Some(v) = prev_id {
env::set_var("GOOGLE_CLIENT_ID", v);
} else {
env::remove_var("GOOGLE_CLIENT_ID");
}
if let Some(v) = prev_secret {
env::set_var("GOOGLE_CLIENT_SECRET", v);
} else {
env::remove_var("GOOGLE_CLIENT_SECRET");
}
}
#[test]
fn test_nested_namespaced_env_injection() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[google.oauth]
client_id = "nested-123"
client_secret = "nested-secret"
"#;
let prev_id = env::var("GOOGLE_OAUTH_CLIENT_ID").ok();
let prev_secret = env::var("GOOGLE_OAUTH_CLIENT_SECRET").ok();
env::remove_var("GOOGLE_OAUTH_CLIENT_ID");
env::remove_var("GOOGLE_OAUTH_CLIENT_SECRET");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("GOOGLE_OAUTH_CLIENT_ID").unwrap(), "nested-123");
assert_eq!(env::var("GOOGLE_OAUTH_CLIENT_SECRET").unwrap(), "nested-secret");
if let Some(v) = prev_id {
env::set_var("GOOGLE_OAUTH_CLIENT_ID", v);
} else {
env::remove_var("GOOGLE_OAUTH_CLIENT_ID");
}
if let Some(v) = prev_secret {
env::set_var("GOOGLE_OAUTH_CLIENT_SECRET", v);
} else {
env::remove_var("GOOGLE_OAUTH_CLIENT_SECRET");
}
}
#[test]
fn test_single_name_var_in_namespace() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[platform]
name = "myapp"
"#;
let prev = env::var("PLATFORM_NAME").ok();
env::remove_var("PLATFORM_NAME");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("PLATFORM_NAME").unwrap(), "myapp");
if let Some(v) = prev {
env::set_var("PLATFORM_NAME", v);
} else {
env::remove_var("PLATFORM_NAME");
}
}
#[test]
fn test_os_env_takes_precedence_over_namespaced() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[google]
client_id = "toml-value"
"#;
let prev = env::var("GOOGLE_CLIENT_ID").ok();
env::set_var("GOOGLE_CLIENT_ID", "os-value");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("GOOGLE_CLIENT_ID").unwrap(), "os-value");
if let Some(v) = prev {
env::set_var("GOOGLE_CLIENT_ID", v);
} else {
env::remove_var("GOOGLE_CLIENT_ID");
}
}
#[test]
fn test_env_table_takes_precedence_over_namespace() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[env]
GOOGLE_CLIENT_ID = "from-env-table"
[google]
client_id = "from-namespace"
"#;
let prev = env::var("GOOGLE_CLIENT_ID").ok();
env::remove_var("GOOGLE_CLIENT_ID");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("GOOGLE_CLIENT_ID").unwrap(), "from-env-table");
if let Some(v) = prev {
env::set_var("GOOGLE_CLIENT_ID", v);
} else {
env::remove_var("GOOGLE_CLIENT_ID");
}
}
#[test]
fn test_get_namespaced_custom_value() {
let toml_str = r#"
[google]
client_id = "abc"
[google.oauth]
redirect_url = "http://localhost/callback"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.get::<String>("google.client_id").unwrap(), "abc");
assert_eq!(
config.get::<String>("google.oauth.redirect_url").unwrap(),
"http://localhost/callback"
);
}
#[test]
fn test_has_key_namespaced() {
let toml_str = r#"
[platform]
name = "test"
[platform.api]
key = "secret"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.has_key("platform.name"));
assert!(config.has_key("platform.api.key"));
assert!(!config.has_key("platform.missing"));
}
#[test]
fn test_non_string_namespaced_values() {
let _lock = SERIAL_TEST.lock().unwrap();
let toml_str = r#"
[myapp]
port = 8080
debug = true
"#;
let prev_port = env::var("MYAPP_PORT").ok();
let prev_debug = env::var("MYAPP_DEBUG").ok();
env::remove_var("MYAPP_PORT");
env::remove_var("MYAPP_DEBUG");
let config: Config = toml::from_str(toml_str).unwrap();
config.inject_env_vars();
assert_eq!(env::var("MYAPP_PORT").unwrap(), "8080");
assert_eq!(env::var("MYAPP_DEBUG").unwrap(), "true");
if let Some(v) = prev_port {
env::set_var("MYAPP_PORT", v);
} else {
env::remove_var("MYAPP_PORT");
}
if let Some(v) = prev_debug {
env::set_var("MYAPP_DEBUG", v);
} else {
env::remove_var("MYAPP_DEBUG");
}
}
#[test]
fn test_flat_custom_string_override() {
let toml = r#"[demo]
url = "from-toml""#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_URL", "from-dotenv")],
)
.unwrap();
assert_eq!(
config.get::<String>("demo.url").unwrap(),
"from-dotenv"
);
}
#[test]
fn test_flat_custom_integer_override() {
let toml = r#"[demo]
timeout = 10"#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_TIMEOUT", "30")],
)
.unwrap();
assert_eq!(config.get::<i64>("demo.timeout").unwrap(), 30);
}
#[test]
fn test_flat_custom_boolean_override() {
let toml = r#"[demo]
debug = false"#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_DEBUG", "true")],
)
.unwrap();
assert_eq!(config.get::<bool>("demo.debug").unwrap(), true);
}
#[test]
fn test_dotted_subtable_override() {
let toml = r#"[demo.service]
url = "from-toml-nested""#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_SERVICE_URL", "from-dotenv-nested")],
)
.unwrap();
assert_eq!(
config.get::<String>("demo.service.url").unwrap(),
"from-dotenv-nested"
);
}
#[test]
fn test_multiple_custom_namespace_overrides() {
let toml = r#"
[demo]
url = "demo-toml"
[google]
client_id = "google-toml"
"#;
let config = config_from_toml_with_env(
toml,
&[
("DEMO_URL", "demo-dotenv"),
("GOOGLE_CLIENT_ID", "google-dotenv"),
],
)
.unwrap();
assert_eq!(
config.get::<String>("demo.url").unwrap(),
"demo-dotenv"
);
assert_eq!(
config.get::<String>("google.client_id").unwrap(),
"google-dotenv"
);
}
#[test]
fn test_known_section_server_port_still_works() {
let toml = r#"[server]
port = 3000"#;
let config = config_from_toml_with_env(
toml,
&[("SERVER_PORT", "9000")],
)
.unwrap();
assert_eq!(config.server.port, 9000);
assert_eq!(
config.get::<u16>("server.port").unwrap(),
9000
);
}
#[test]
fn test_no_env_override_preserves_toml() {
let toml = r#"[demo]
url = "from-toml"
timeout = 10"#;
let config = config_from_toml_with_env(toml, &[]).unwrap();
assert_eq!(
config.get::<String>("demo.url").unwrap(),
"from-toml"
);
assert_eq!(config.get::<i64>("demo.timeout").unwrap(), 10);
}
#[test]
fn test_partial_override() {
let toml = r#"[demo]
url = "toml-url"
timeout = 10"#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_URL", "env-url")],
)
.unwrap();
assert_eq!(
config.get::<String>("demo.url").unwrap(),
"env-url"
);
assert_eq!(config.get::<i64>("demo.timeout").unwrap(), 10);
}
#[test]
fn test_os_env_vars_ignored() {
let _lock = SERIAL_TEST.lock().unwrap();
let prev_path = env::var("PATH").ok();
let prev_home = env::var("HOME").ok();
env::set_var("PATH", "/usr/bin:/bin");
env::set_var("HOME", "/root");
let config = Config::load_from("non-existent.toml").unwrap();
assert!(config.get::<String>("path").is_none());
assert!(config.get::<String>("home").is_none());
if let Some(v) = prev_path {
env::set_var("PATH", v);
} else {
env::remove_var("PATH");
}
if let Some(v) = prev_home {
env::set_var("HOME", v);
} else {
env::remove_var("HOME");
}
}
#[test]
fn test_ambiguous_namespace_dotted_vs_flat() {
let toml = r#"
[demo_service]
url = "flat"
[demo.service]
url = "dotted"
"#;
let result = config_from_toml_with_env(toml, &[]);
assert!(result.is_err());
match result {
Err(ConfigError::AmbiguousNamespace { prefix, candidates }) => {
assert_eq!(prefix, "DEMO_SERVICE_");
assert!(candidates.contains(&"demo_service".to_string()));
assert!(candidates.contains(&"demo.service".to_string()));
}
_ => panic!("expected AmbiguousNamespace error"),
}
}
#[test]
fn test_case_sensitive_collision() {
let toml = r#"
[demo]
url = "lower"
[DEMO]
url = "upper"
"#;
let result = config_from_toml_with_env(toml, &[]);
assert!(result.is_err());
match result {
Err(ConfigError::AmbiguousNamespace { prefix, .. }) => {
assert_eq!(prefix, "DEMO_");
}
_ => panic!("expected AmbiguousNamespace error"),
}
}
#[test]
fn test_unknown_env_var_ignored() {
let toml = r#"[demo]
url = "ok""#;
let config = config_from_toml_with_env(
toml,
&[("UNKNOWN_KEY", "somevalue")],
)
.unwrap();
assert!(config.custom.get("unknown").is_none());
assert_eq!(
config.get::<String>("demo.url").unwrap(),
"ok"
);
}
#[test]
fn test_table_level_override_skipped() {
let toml = r#"[demo.service]
url = "nested""#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_SERVICE", "not-a-property")],
)
.unwrap();
assert_eq!(
config.get::<String>("demo.service.url").unwrap(),
"nested"
);
}
#[test]
fn test_float_coercion() {
let toml = r#"[demo]
threshold = 0.5"#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_THRESHOLD", "0.75")],
)
.unwrap();
let val: f64 = config.get("demo.threshold").unwrap();
assert!((val - 0.75).abs() < 1e-10);
}
#[test]
fn test_numeric_like_string_override() {
let toml = r#"[demo]
version = "1.0""#;
let config = config_from_toml_with_env(
toml,
&[("DEMO_VERSION", "2.0")],
)
.unwrap();
let val: f64 = config.get("demo.version").unwrap();
assert!((val - 2.0).abs() < 1e-10);
}
#[test]
fn test_coerce_boolean() {
assert_eq!(coerce_env_value("true"), toml::Value::Boolean(true));
assert_eq!(coerce_env_value("TRUE"), toml::Value::Boolean(true));
assert_eq!(coerce_env_value("false"), toml::Value::Boolean(false));
assert_eq!(coerce_env_value("FALSE"), toml::Value::Boolean(false));
}
#[test]
fn test_coerce_integer() {
assert_eq!(coerce_env_value("30"), toml::Value::Integer(30));
assert_eq!(coerce_env_value("-5"), toml::Value::Integer(-5));
assert_eq!(coerce_env_value("0"), toml::Value::Integer(0));
match coerce_env_value("3.14") {
toml::Value::Float(_) => {}
_ => panic!("expected Float"),
}
}
#[test]
fn test_coerce_float() {
match coerce_env_value("3.14") {
toml::Value::Float(f) => assert!((f - 3.14).abs() < 1e-10),
_ => panic!("expected Float"),
}
}
#[test]
fn test_coerce_string() {
assert_eq!(
coerce_env_value("hello"),
toml::Value::String("hello".to_string())
);
assert_eq!(
coerce_env_value("abc123"),
toml::Value::String("abc123".to_string())
);
}
use std::sync::Mutex;
static SERIAL_TEST: Mutex<()> = Mutex::new(());
}