Skip to main content

oxidite_config/
lib.rs

1//! # Oxidite Config
2//!
3//! Configuration and environment variable management for Oxidite applications.
4//! Loads `oxidite.toml` with support for typed config sections, flat `[env]` tables,
5//! namespaced tables, nested overrides, and `.env` file loading.
6//!
7//! # Usage
8//!
9//! ```rust
10//! use oxidite_config::Config;
11//!
12//! let config = Config::load()
13//!     .map_err(|e| eprintln!("config error: {e}")).unwrap();
14//!
15//! let host: String = config.get("server.host")
16//!     .unwrap_or_else(|| "127.0.0.1".to_string());
17//! println!("Server: {host}");
18//! ```
19
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, HashSet};
22use std::env;
23use std::fs;
24use std::path::Path;
25use thiserror::Error;
26
27/// Errors that can occur during configuration loading and parsing
28///
29/// ```rust
30/// use oxidite_config::ConfigError;
31///
32/// let err = ConfigError::MissingKey("server.port".to_string());
33/// assert_eq!(format!("{}", err), "missing configuration key: server.port");
34/// ```
35#[derive(Debug, Error)]
36pub enum ConfigError {
37    #[error("I/O error: {0}")]
38    Io(#[from] std::io::Error),
39    #[error("TOML parse error: {0}")]
40    TomlDe(#[from] toml::de::Error),
41    #[error("YAML parse error: {0}")]
42    YamlDe(#[from] serde_yaml::Error),
43    #[error("invalid value for environment variable `{name}`: `{value}`")]
44    InvalidEnvValue { name: String, value: String },
45    #[error("missing configuration key: {0}")]
46    MissingKey(String),
47    #[error("invalid type for configuration key: {0}")]
48    InvalidType(String),
49    #[error("Ambiguous namespace prefix '{prefix}' matches multiple config paths: {candidates:?}. Rename one of the conflicting tables or properties in oxidite.toml.")]
50    AmbiguousNamespace {
51        prefix: String,
52        candidates: Vec<String>,
53    },
54    #[error("Environment variable '{var_name}' matches namespace '{namespace}' but has an empty property key. Table-level overrides are not supported.")]
55    EmptyPropertyKey {
56        var_name: String,
57        namespace: String,
58    },
59}
60
61/// Application environment mode
62///
63/// ```rust
64/// use oxidite_config::Environment;
65///
66/// let env = Environment::from_str("production");
67/// assert_eq!(env.as_str(), "production");
68/// ```
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Environment {
71    Development,
72    Testing,
73    Production,
74}
75
76impl Environment {
77    /// Parse an environment string into an `Environment` variant
78    ///
79    /// Recognises `"production"` / `"prod"`, `"testing"` / `"test"`;
80    /// everything else defaults to `Development`.
81    ///
82    /// ```rust
83    /// use oxidite_config::Environment;
84    ///
85    /// assert_eq!(Environment::from_str("prod"), Environment::Production);
86    /// assert_eq!(Environment::from_str("test"), Environment::Testing);
87    /// assert_eq!(Environment::from_str("staging"), Environment::Development);
88    /// ```
89    pub fn from_str(s: &str) -> Self {
90        match s.to_lowercase().as_str() {
91            "production" | "prod" => Self::Production,
92            "testing" | "test" => Self::Testing,
93            _ => Self::Development,
94        }
95    }
96
97    /// Return the string representation of the environment variant
98    ///
99    /// ```rust
100    /// use oxidite_config::Environment;
101    ///
102    /// assert_eq!(Environment::Production.as_str(), "production");
103    /// ```
104    pub fn as_str(&self) -> &str {
105        match self {
106            Self::Development => "development",
107            Self::Testing => "testing",
108            Self::Production => "production",
109        }
110    }
111}
112
113/// Root configuration struct representing an `oxidite.toml` file
114///
115/// Contains typed sections (`app`, `server`, `database`, `cache`, `queue`, `security`),
116/// a flat `[env]` table, and any unknown root-level tables captured via `#[serde(flatten)]`
117/// for namespaced environment variable injection.
118///
119/// ```rust
120/// use oxidite_config::Config;
121///
122/// let config = Config::default();
123/// assert_eq!(config.server.port, 3000);
124/// ```
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Config {
127    #[serde(default)]
128    pub app: AppConfig,
129    #[serde(default)]
130    pub server: ServerConfig,
131    #[serde(default)]
132    pub database: DatabaseConfig,
133    #[serde(default)]
134    pub cache: CacheConfig,
135    #[serde(default)]
136    pub queue: QueueConfig,
137    #[serde(default)]
138    pub security: SecurityConfig,
139    /// Custom environment variables defined in `[env]` of `oxidite.toml`.
140    /// Injected into the process environment at load time.
141    #[serde(default)]
142    pub env: HashMap<String, String>,
143    /// Any unknown root-level TOML tables are captured here via `#[serde(flatten)]`.
144    ///
145    /// Enables namespaced environment variables: table `[google]` with `client_id = "abc"`
146    /// becomes `GOOGLE_CLIENT_ID=abc`. Nested tables (`[google.oauth]`) flatten recursively.
147    #[serde(flatten, default)]
148    pub custom: HashMap<String, toml::Value>,
149}
150
151/// Application metadata configuration
152///
153/// ```rust
154/// use oxidite_config::AppConfig;
155///
156/// let app = AppConfig::default();
157/// assert_eq!(app.name, "oxidite-app");
158/// ```
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct AppConfig {
161    #[serde(default = "default_app_name")]
162    pub name: String,
163    #[serde(default)]
164    pub version: String,
165    #[serde(default)]
166    pub environment: String,
167    #[serde(default)]
168    pub debug: bool,
169}
170
171/// HTTP server configuration (host, port, worker count)
172///
173/// ```rust
174/// use oxidite_config::ServerConfig;
175///
176/// let srv = ServerConfig::default();
177/// assert_eq!(srv.host, "127.0.0.1");
178/// assert_eq!(srv.port, 3000);
179/// ```
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ServerConfig {
182    #[serde(default = "default_host")]
183    pub host: String,
184    #[serde(default = "default_port")]
185    pub port: u16,
186    #[serde(default)]
187    pub workers: usize,
188}
189
190/// Database connection configuration
191///
192/// ```rust
193/// use oxidite_config::DatabaseConfig;
194///
195/// let db = DatabaseConfig::default();
196/// assert_eq!(db.pool_size, 10);
197/// ```
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct DatabaseConfig {
200    #[serde(default)]
201    pub url: String,
202    #[serde(default = "default_pool_size")]
203    pub pool_size: u32,
204    #[serde(default)]
205    pub ssl: bool,
206}
207
208/// Cache driver configuration (memory or Redis)
209///
210/// ```rust
211/// use oxidite_config::CacheConfig;
212///
213/// let cache = CacheConfig::default();
214/// assert_eq!(cache.driver, "memory");
215/// ```
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct CacheConfig {
218    #[serde(default)]
219    pub driver: String,
220    #[serde(default)]
221    pub redis_url: String,
222    #[serde(default = "default_ttl")]
223    pub default_ttl: u64,
224}
225
226/// Background job queue configuration
227///
228/// ```rust
229/// use oxidite_config::QueueConfig;
230///
231/// let queue = QueueConfig::default();
232/// assert_eq!(queue.driver, "memory");
233/// ```
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct QueueConfig {
236    #[serde(default)]
237    pub driver: String,
238    #[serde(default)]
239    pub redis_url: String,
240    #[serde(default = "default_workers")]
241    pub workers: usize,
242}
243
244/// Security configuration (JWT, CORS, rate limiting)
245///
246/// ```rust
247/// use oxidite_config::SecurityConfig;
248///
249/// let sec = SecurityConfig::default();
250/// assert_eq!(sec.jwt_expiry, 900);
251/// ```
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SecurityConfig {
254    #[serde(default)]
255    pub jwt_secret: String,
256    #[serde(default = "default_jwt_expiry")]
257    pub jwt_expiry: u64,
258    #[serde(default)]
259    pub cors_origins: Vec<String>,
260    #[serde(default)]
261    pub cors_methods: Vec<String>,
262    #[serde(default)]
263    pub cors_headers: Vec<String>,
264    #[serde(default)]
265    pub rate_limit: u32,
266}
267
268// Default functions
269fn default_app_name() -> String {
270    "oxidite-app".to_string()
271}
272
273fn default_host() -> String {
274    "127.0.0.1".to_string()
275}
276
277fn default_port() -> u16 {
278    3000
279}
280
281fn default_pool_size() -> u32 {
282    10
283}
284
285fn default_ttl() -> u64 {
286    3600
287}
288
289fn default_workers() -> usize {
290    4
291}
292
293fn default_jwt_expiry() -> u64 {
294    900
295}
296
297impl Default for AppConfig {
298    fn default() -> Self {
299        Self {
300            name: default_app_name(),
301            version: env!("CARGO_PKG_VERSION").to_string(),
302            environment: "development".to_string(),
303            debug: true,
304        }
305    }
306}
307
308impl Default for ServerConfig {
309    fn default() -> Self {
310        Self {
311            host: default_host(),
312            port: default_port(),
313            workers: num_cpus::get(),
314        }
315    }
316}
317
318impl Default for DatabaseConfig {
319    fn default() -> Self {
320        Self {
321            url: String::new(),
322            pool_size: default_pool_size(),
323            ssl: false,
324        }
325    }
326}
327
328impl Default for CacheConfig {
329    fn default() -> Self {
330        Self {
331            driver: "memory".to_string(),
332            redis_url: String::new(),
333            default_ttl: default_ttl(),
334        }
335    }
336}
337
338impl Default for QueueConfig {
339    fn default() -> Self {
340        Self {
341            driver: "memory".to_string(),
342            redis_url: String::new(),
343            workers: default_workers(),
344        }
345    }
346}
347
348impl Default for SecurityConfig {
349    fn default() -> Self {
350        Self {
351            jwt_secret: String::new(),
352            jwt_expiry: default_jwt_expiry(),
353            cors_origins: vec![],
354            cors_methods: vec![],
355            cors_headers: vec![],
356            rate_limit: 0,
357        }
358    }
359}
360
361/// A registry entry mapping an uppercase env prefix to a config path within `custom`.
362struct NamespaceEntry {
363    /// Uppercase prefix with trailing underscore, e.g. `"DEMO_SERVICE_"`.
364    env_prefix: String,
365    /// Segments of the config path, e.g. `["demo", "service"]`.
366    config_path: Vec<String>,
367}
368
369/// Coerce a raw environment variable string into the most specific `toml::Value` variant.
370///
371/// Order of precedence: Boolean → Integer → Float → String.
372fn coerce_env_value(raw: &str) -> toml::Value {
373    let trimmed = raw.trim();
374
375    // Stage 1 — Boolean (exact case-insensitive match only)
376    match trimmed.to_lowercase().as_str() {
377        "true" => return toml::Value::Boolean(true),
378        "false" => return toml::Value::Boolean(false),
379        _ => {}
380    }
381
382    // Stage 2 — Integer (base-10 digits, optional leading minus)
383    if let Ok(n) = trimmed.parse::<i64>() {
384        return toml::Value::Integer(n);
385    }
386
387    // Stage 3 — Float (must contain `.`, `e`, `E`, `inf`, or `nan`)
388    if trimmed.contains('.')
389        || trimmed.contains('e')
390        || trimmed.contains('E')
391        || trimmed.eq_ignore_ascii_case("inf")
392        || trimmed.eq_ignore_ascii_case("-inf")
393        || trimmed.eq_ignore_ascii_case("nan")
394    {
395        if let Ok(f) = trimmed.parse::<f64>() {
396            return toml::Value::Float(f);
397        }
398    }
399
400    // Stage 4 — String fallthrough (preserve original casing)
401    toml::Value::String(raw.to_string())
402}
403
404impl Default for Config {
405    fn default() -> Self {
406        Self {
407            app: AppConfig::default(),
408            server: ServerConfig::default(),
409            database: DatabaseConfig::default(),
410            cache: CacheConfig::default(),
411            queue: QueueConfig::default(),
412            security: SecurityConfig::default(),
413            env: HashMap::new(),
414            custom: HashMap::new(),
415        }
416    }
417}
418
419impl Config {
420    /// Inject `[env]` entries, known config sections, and namespaced tables into the
421    /// process environment.
422    ///
423    /// Every TOML section becomes an uppercase env prefix: `[server] host = "x"` produces
424    /// `SERVER_HOST=x`. This means `oxidite.toml` and `.env` are equivalent — a value can
425    /// be defined in either place and both `config.get("section.key")` and
426    /// `std::env::var("SECTION_KEY")` work.
427    ///
428    /// **Resolution order** (highest to lowest priority):
429    /// 1. Real OS environment variables
430    /// 2. `.env` file entries (loaded earlier via `dotenv`)
431    /// 3. `[env]` flat table entries
432    /// 4. Known sections (`[server]`, `[app]`, `[database]`, etc.)
433    /// 5. Custom namespaced tables (`[google]`, `[platform]`, etc.)
434    ///
435    /// A variable is only set if it is not already defined (or is empty) in the
436    /// OS environment, so real env vars and `.env` entries always take precedence.
437    fn inject_env_vars(&self) {
438        for (key, value) in &self.env {
439            let already_set = env::var(key)
440                .map(|v| !v.is_empty())
441                .unwrap_or(false);
442            if !already_set {
443                env::set_var(key, value);
444            }
445        }
446
447        // Serialize self to TOML and inject every top-level table as namespaced env vars.
448        // Because `custom` uses #[serde(flatten)], both known sections (server, app, …)
449        // and custom tables (google, platform, …) appear at the top level — so they all
450        // produce the same env-var pattern: e.g. `[server] host = "x"` → `SERVER_HOST=x`.
451        if let Ok(root) = toml::Value::try_from(self) {
452            if let toml::Value::Table(table) = root {
453                for (key, value) in table {
454                    if key == "env" {
455                        continue; // already handled above
456                    }
457                    Self::inject_namespaced_env(&key, &value);
458                }
459            }
460        }
461    }
462
463    /// Recursively flatten a TOML value into environment variables.
464    ///
465    /// - Table `[google]` with `client_id = "abc"` produces `GOOGLE_CLIENT_ID=abc`.
466    /// - Nested `[google.oauth]` with `client_id = "abc"` produces `GOOGLE_OAUTH_CLIENT_ID=abc`.
467    /// - Non-string values (integers, booleans) are converted to strings.
468    /// - Existing (non-empty) OS env vars are never overwritten.
469    fn inject_namespaced_env(prefix: &str, value: &toml::Value) {
470        let upper_prefix = prefix.to_uppercase();
471        match value {
472            toml::Value::Table(table) => {
473                for (key, val) in table {
474                    let env_key = format!("{}_{}", upper_prefix, key.to_uppercase());
475                    Self::inject_namespaced_env(&env_key, val);
476                }
477            }
478            _ => {
479                let already_set = env::var(prefix)
480                    .map(|v| !v.is_empty())
481                    .unwrap_or(false);
482                if !already_set {
483                    let s = match value {
484                        toml::Value::String(s) => s.clone(),
485                        other => other.to_string(),
486                    };
487                    env::set_var(prefix, s);
488                }
489            }
490        }
491    }
492
493    /// Apply well-known environment variable overrides to typed config fields
494    fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
495        if let Ok(val) = env::var("APP_NAME") {
496            self.app.name = val;
497        }
498        if let Ok(val) = env::var("SERVER_HOST") {
499            self.server.host = val;
500        }
501        if let Ok(val) = env::var("SERVER_PORT") {
502            self.server.port = val
503                .parse()
504                .map_err(|_| ConfigError::InvalidEnvValue {
505                    name: "SERVER_PORT".to_string(),
506                    value: val,
507                })?;
508        }
509        if let Ok(val) = env::var("DATABASE_URL") {
510            self.database.url = val;
511        }
512        if let Ok(val) = env::var("REDIS_URL") {
513            self.cache.redis_url = val.clone();
514            self.queue.redis_url = val;
515        }
516        if let Ok(val) = env::var("JWT_SECRET") {
517            self.security.jwt_secret = val;
518        }
519        Ok(())
520    }
521
522    /// Collect environment variable overrides and inject them into `self.custom`.
523    ///
524    /// Scans all current `std::env::vars()`, matches them against the existing
525    /// namespace registry (built from `self.custom` keys), and injects typed values
526    /// into the correct nested path within the `custom` HashMap.
527    ///
528    /// `pre_dotenv_keys` is a snapshot of env var names taken **before** `.env`
529    /// was loaded, so real OS-level variables (PATH, HOME, XDG_*) are excluded.
530    fn collect_env_overrides(&mut self, pre_dotenv_keys: &HashSet<String>) -> Result<(), ConfigError> {
531        let registry = self.build_namespace_registry()?;
532
533        for (env_name, raw_value) in env::vars() {
534            if pre_dotenv_keys.contains(&env_name) {
535                continue;
536            }
537
538            let Some(entry) = registry.iter().find(|e| {
539                env_name.starts_with(&e.env_prefix)
540                    || env_name == e.env_prefix.trim_end_matches('_')
541            }) else {
542                continue;
543            };
544
545            let remaining = if env_name.starts_with(&entry.env_prefix) {
546                &env_name[entry.env_prefix.len()..]
547            } else {
548                ""
549            };
550
551            if remaining.is_empty() {
552                continue;
553            }
554            let field_key = remaining.to_lowercase();
555            let value = coerce_env_value(&raw_value);
556
557            Self::inject_env_override(&mut self.custom, &entry.config_path, &field_key, value);
558        }
559
560        Ok(())
561    }
562
563    /// Build a sorted namespace registry from known sections and custom HashMap keys.
564    ///
565    /// Each entry maps an uppercase env prefix (e.g., `DEMO_SERVICE_`) to a config
566    /// path (e.g., `["demo", "service"]`). The registry is sorted longest-prefix-first
567    /// so that `DEMO_SERVICE_` is matched before `DEMO_`.
568    ///
569    /// If two different config paths produce the same env prefix, a
570    /// `ConfigError::AmbiguousNamespace` is returned.
571    fn build_namespace_registry(&self) -> Result<Vec<NamespaceEntry>, ConfigError> {
572        let mut registry: Vec<NamespaceEntry> = Vec::new();
573
574        // Recursively collect paths from custom HashMap
575        for (key, value) in &self.custom {
576            Self::collect_custom_paths(key, value, &[], &mut registry);
577        }
578
579        // Check for ambiguous prefixes
580        let mut seen: HashMap<String, Vec<String>> = HashMap::new();
581        for entry in &registry {
582            seen.entry(entry.env_prefix.clone())
583                .or_default()
584                .push(entry.config_path.join("."));
585        }
586
587        for (prefix, paths) in &seen {
588            let unique: HashSet<&str> = paths.iter().map(|s| s.as_str()).collect();
589            if unique.len() > 1 {
590                return Err(ConfigError::AmbiguousNamespace {
591                    prefix: prefix.clone(),
592                    candidates: unique.into_iter().map(|s| s.to_string()).collect(),
593                });
594            }
595        }
596
597        // Sort longest prefix first for longest-match resolution
598        registry.sort_by(|a, b| b.env_prefix.len().cmp(&a.env_prefix.len()));
599        Ok(registry)
600    }
601
602    /// Recursively walk a TOML value tree and register every table path as a namespace entry.
603    fn collect_custom_paths(
604        key: &str,
605        value: &toml::Value,
606        ancestors: &[String],
607        registry: &mut Vec<NamespaceEntry>,
608    ) {
609        if let toml::Value::Table(table) = value {
610            let mut path: Vec<String> = ancestors.to_vec();
611            // If the key itself contains dots (from serde flatten of [demo.service]),
612            // split into individual segments
613            for segment in key.split('.') {
614                path.push(segment.to_string());
615            }
616
617            let prefix = path.iter()
618                .map(|s| s.to_uppercase())
619                .collect::<Vec<_>>()
620                .join("_")
621                + "_";
622
623            registry.push(NamespaceEntry {
624                env_prefix: prefix,
625                config_path: path.clone(),
626            });
627
628            // Recurse into sub-tables for dotted sub-table detection
629            for (sub_key, sub_val) in table {
630                Self::collect_custom_paths(sub_key, sub_val, &path, registry);
631            }
632        }
633    }
634
635    /// Inject a typed value into `self.custom` at the path described by `config_path`
636    /// with the leaf key `field_key`.
637    ///
638    /// For a flat namespace `["demo"]` with field_key `"url"`, this writes
639    /// `custom["demo"]["url"] = value`.
640    ///
641    /// For a nested path `["demo", "service"]` with field_key `"url"`, this writes
642    /// `custom["demo"]["service"]["url"] = value`.
643    fn inject_env_override(
644        custom: &mut HashMap<String, toml::Value>,
645        config_path: &[String],
646        field_key: &str,
647        value: toml::Value,
648    ) {
649        if config_path.is_empty() {
650            return;
651        }
652
653        let namespace = &config_path[0];
654        let Some(toml::Value::Table(ref mut top_table)) = custom.get_mut(namespace) else {
655            return;
656        };
657
658        if config_path.len() == 1 {
659            top_table.insert(field_key.to_string(), value);
660            return;
661        }
662
663        let mut current = top_table;
664        for segment in &config_path[1..] {
665            match current.get_mut(segment) {
666                Some(toml::Value::Table(ref mut next)) => current = next,
667                _ => return,
668            }
669        }
670        current.insert(field_key.to_string(), value);
671    }
672
673    /// Check if a given dotted key exists in the configuration
674    ///
675    /// Checks custom namespaced tables first, then falls back to known config fields.
676    ///
677    /// ```rust
678    /// use oxidite_config::Config;
679    ///
680    /// let config = Config::default();
681    /// assert!(config.has_key("server.port"));
682    /// assert!(!config.has_key("nonexistent.key"));
683    /// ```
684    pub fn has_key(&self, key: &str) -> bool {
685        {
686            let mut parts = key.split('.');
687            if let Some(first) = parts.next() {
688                if let Some(val) = self.custom.get(first) {
689                    let mut cur = val;
690                    let mut found = true;
691                    for part in parts {
692                        if let Some(next) = cur.get(part) {
693                            cur = next;
694                        } else {
695                            found = false;
696                            break;
697                        }
698                    }
699                    if found {
700                        return true;
701                    }
702                }
703            }
704        }
705
706        let root = toml::Value::try_from(self).ok();
707        if let Some(root) = root {
708            let mut cursor = &root;
709            for part in key.split('.') {
710                if let Some(next) = cursor.get(part) {
711                    cursor = next;
712                } else {
713                    return false;
714                }
715            }
716            return true;
717        }
718        false
719    }
720
721    /// Load configuration from `oxidite.toml` in the current directory
722    ///
723    /// Falls back to `Config::default()` if the file does not exist.
724    /// Loads `.env` file first (unless `OXIDITE_SKIP_DOTENV` is set),
725    /// then injects config env vars, then applies known env overrides.
726    ///
727    /// ```rust
728    /// use oxidite_config::Config;
729    ///
730    /// let config = Config::load()
731    ///     .map_err(|e| eprintln!("config error: {e}")).unwrap_or_default();
732    /// println!("App: {}", config.app.name);
733    /// ```
734    pub fn load() -> Result<Self, ConfigError> {
735        // Snapshot OS env vars BEFORE loading .env (so we can distinguish
736        // host-level vars like PATH, HOME from user-override vars in .env)
737        let pre_dotenv_keys: HashSet<String> = env::vars()
738            .map(|(k, _)| k)
739            .collect();
740
741        if env::var("OXIDITE_SKIP_DOTENV").is_err() {
742            let _ = dotenv::dotenv();
743        }
744
745        let env_val = env::var("OXIDITE_ENV")
746            .or_else(|_| env::var("ENVIRONMENT"))
747            .unwrap_or_else(|_| "development".to_string());
748
749        let mut config = if Path::new("oxidite.toml").exists() {
750            let content = fs::read_to_string("oxidite.toml")?;
751            toml::from_str(&content)?
752        } else {
753            Config::default()
754        };
755
756        // NEW: Pull env overrides into custom namespaces
757        config.collect_env_overrides(&pre_dotenv_keys)?;
758
759        config.inject_env_vars();
760        config.apply_env_overrides()?;
761        config.app.environment = env_val;
762        Ok(config)
763    }
764
765    /// Load configuration from a custom file path
766    ///
767    /// Same as `load()` but reads from an explicit path instead of `oxidite.toml`.
768    ///
769    /// ```rust
770    /// use oxidite_config::Config;
771    ///
772    /// let config = Config::load_from("/etc/myapp/config.toml")
773    ///     .map_err(|e| eprintln!("config error: {e}")).unwrap_or_default();
774    /// ```
775    pub fn load_from(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
776        let pre_dotenv_keys: HashSet<String> = env::vars()
777            .map(|(k, _)| k)
778            .collect();
779
780        if env::var("OXIDITE_SKIP_DOTENV").is_err() {
781            let _ = dotenv::dotenv();
782        }
783
784        let env_name = env::var("OXIDITE_ENV")
785            .or_else(|_| env::var("ENVIRONMENT"))
786            .unwrap_or_else(|_| "development".to_string());
787
788        let mut config = if path.as_ref().exists() {
789            let content = fs::read_to_string(path)?;
790            toml::from_str(&content)?
791        } else {
792            Config::default()
793        };
794
795        config.collect_env_overrides(&pre_dotenv_keys)?;
796        config.inject_env_vars();
797        config.app.environment = env_name;
798        config.apply_env_overrides()?;
799        Ok(config)
800    }
801
802    /// Get a typed configuration value by dotted key path
803    ///
804    /// Checks custom namespaced tables first, then known config fields.
805    /// Returns `None` if the key is missing or the type cannot be deserialized.
806    ///
807    /// ```rust
808    /// use oxidite_config::Config;
809    ///
810    /// let config = Config::default();
811    /// let port: Option<u16> = config.get("server.port");
812    /// assert_eq!(port, Some(3000));
813    /// ```
814    pub fn get<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
815        {
816            let mut parts = key.split('.');
817            if let Some(first) = parts.next() {
818                if let Some(val) = self.custom.get(first) {
819                    let mut cursor = val;
820                    let mut found = true;
821                    for part in parts {
822                        if let Some(next) = cursor.get(part) {
823                            cursor = next;
824                        } else {
825                            found = false;
826                            break;
827                        }
828                    }
829                    if found {
830                        if let Ok(parsed) = T::deserialize(cursor.clone()) {
831                            return Some(parsed);
832                        }
833                    }
834                }
835            }
836        }
837
838        let root = toml::Value::try_from(self).ok()?;
839        let mut cursor = &root;
840        for part in key.split('.') {
841            cursor = cursor.get(part)?;
842        }
843
844        T::deserialize(cursor.clone()).ok()
845    }
846
847    /// Get a required typed configuration value, returning a `ConfigError` on failure
848    ///
849    /// ```rust
850    /// use oxidite_config::Config;
851    ///
852    /// let config = Config::default();
853    /// let port = config.get_required::<u16>("server.port")
854    ///     .map_err(|e| eprintln!("missing config: {e}")).unwrap();
855    /// assert_eq!(port, 3000);
856    /// ```
857    pub fn get_required<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T, ConfigError> {
858        self.get(key).ok_or_else(|| {
859            if self.has_key(key) {
860                ConfigError::InvalidType(key.to_string())
861            } else {
862                ConfigError::MissingKey(key.to_string())
863            }
864        })
865    }
866
867    /// Convenience method: get a `u16` value or return a `ConfigError`
868    pub fn get_u16(&self, key: &str) -> Result<u16, ConfigError> {
869        self.get_required(key)
870    }
871
872    /// Convenience method: get a `bool` value or return a `ConfigError`
873    pub fn get_bool(&self, key: &str) -> Result<bool, ConfigError> {
874        self.get_required(key)
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881
882    // ── Helper ──────────────────────────────────────────────────────────
883
884    /// Parse TOML and run the full load pipeline (collect_env_overrides,
885    /// inject_env_vars, apply_env_overrides) with isolation.
886    ///
887    /// Takes a **pre-set** snapshot of env vars so any OS-level or leaked
888    /// env vars are blacklisted. Only the env vars passed in `env_vars`
889    /// (which are set AFTER the snapshot) pass through.
890    fn config_from_toml_with_env(
891        toml_str: &str,
892        env_vars: &[(&str, &str)],
893    ) -> Result<Config, ConfigError> {
894        let _lock = SERIAL_TEST.lock().unwrap();
895        // Purge any env vars leaked from a previous test that panicked
896        for &(k, _) in env_vars {
897            let _ = env::remove_var(k);
898        }
899        // Snapshot — captures any leaked env vars from previous tests
900        let pre_set_keys: HashSet<String> = env::vars().map(|(k, _)| k).collect();
901
902        let mut backups: Vec<(String, Option<String>)> = Vec::new();
903        for &(k, v) in env_vars {
904            backups.push((k.to_string(), env::var(k).ok()));
905            env::set_var(k, v);
906        }
907
908        let mut config: Config = toml::from_str(toml_str).unwrap();
909        // pre_set_keys blacklists anything that existed before we set our test vars
910        config.collect_env_overrides(&pre_set_keys)?;
911        config.inject_env_vars();
912        config.apply_env_overrides()?;
913
914        for (k, prev) in backups {
915            match prev {
916                Some(v) => env::set_var(&k, v),
917                None => env::remove_var(&k),
918            }
919        }
920        Ok(config)
921    }
922
923    // ── Existing tests (preserved) ──────────────────────────────────────
924
925    #[test]
926    fn test_default_config() {
927        let config = Config::default();
928        assert_eq!(config.server.host, "127.0.0.1");
929        assert_eq!(config.server.port, 3000);
930    }
931
932    #[test]
933    fn test_environment_parsing() {
934        assert_eq!(Environment::from_str("production"), Environment::Production);
935        assert_eq!(Environment::from_str("PROD"), Environment::Production);
936    }
937
938    #[test]
939    fn test_get_required_typed_values() {
940        let config = Config::default();
941        assert_eq!(config.get_u16("server.port").unwrap(), 3000);
942    }
943
944    #[test]
945    fn test_invalid_server_port_env_returns_error() {
946        let _lock = SERIAL_TEST.lock().unwrap();
947        let prev = env::var("SERVER_PORT").ok();
948        env::set_var("SERVER_PORT", "not-a-port");
949        let result = Config::load();
950        if let Some(v) = prev {
951            env::set_var("SERVER_PORT", v);
952        } else {
953            env::remove_var("SERVER_PORT");
954        }
955        assert!(result.is_err());
956    }
957
958    #[test]
959    fn test_load_from_applies_env_overrides() {
960        let _lock = SERIAL_TEST.lock().unwrap();
961        let prev_host = env::var("SERVER_HOST").ok();
962        env::set_var("SERVER_HOST", "0.0.0.0");
963        let cfg = Config::load_from("non-existent.toml").unwrap();
964        if let Some(v) = prev_host {
965            env::set_var("SERVER_HOST", v);
966        } else {
967            env::remove_var("SERVER_HOST");
968        }
969        assert_eq!(cfg.server.host, "0.0.0.0");
970    }
971
972    #[test]
973    fn test_flat_env_table_injection() {
974        let _lock = SERIAL_TEST.lock().unwrap();
975        let toml_str = r#"
976            [env]
977            FLAT_TEST_VAR = "flat_value"
978        "#;
979        let prev = env::var("FLAT_TEST_VAR").ok();
980        env::remove_var("FLAT_TEST_VAR");
981
982        let config: Config = toml::from_str(toml_str).unwrap();
983        config.inject_env_vars();
984
985        assert_eq!(env::var("FLAT_TEST_VAR").unwrap(), "flat_value");
986
987        if let Some(v) = prev {
988            env::set_var("FLAT_TEST_VAR", v);
989        } else {
990            env::remove_var("FLAT_TEST_VAR");
991        }
992    }
993
994    #[test]
995    fn test_namespaced_env_injection() {
996        let _lock = SERIAL_TEST.lock().unwrap();
997        let toml_str = r#"
998            [google]
999            client_id = "g-123"
1000            client_secret = "g-secret"
1001        "#;
1002        let prev_id = env::var("GOOGLE_CLIENT_ID").ok();
1003        let prev_secret = env::var("GOOGLE_CLIENT_SECRET").ok();
1004        env::remove_var("GOOGLE_CLIENT_ID");
1005        env::remove_var("GOOGLE_CLIENT_SECRET");
1006
1007        let config: Config = toml::from_str(toml_str).unwrap();
1008        config.inject_env_vars();
1009
1010        assert_eq!(env::var("GOOGLE_CLIENT_ID").unwrap(), "g-123");
1011        assert_eq!(env::var("GOOGLE_CLIENT_SECRET").unwrap(), "g-secret");
1012
1013        if let Some(v) = prev_id {
1014            env::set_var("GOOGLE_CLIENT_ID", v);
1015        } else {
1016            env::remove_var("GOOGLE_CLIENT_ID");
1017        }
1018        if let Some(v) = prev_secret {
1019            env::set_var("GOOGLE_CLIENT_SECRET", v);
1020        } else {
1021            env::remove_var("GOOGLE_CLIENT_SECRET");
1022        }
1023    }
1024
1025    #[test]
1026    fn test_nested_namespaced_env_injection() {
1027        let _lock = SERIAL_TEST.lock().unwrap();
1028        let toml_str = r#"
1029            [google.oauth]
1030            client_id = "nested-123"
1031            client_secret = "nested-secret"
1032        "#;
1033        let prev_id = env::var("GOOGLE_OAUTH_CLIENT_ID").ok();
1034        let prev_secret = env::var("GOOGLE_OAUTH_CLIENT_SECRET").ok();
1035        env::remove_var("GOOGLE_OAUTH_CLIENT_ID");
1036        env::remove_var("GOOGLE_OAUTH_CLIENT_SECRET");
1037
1038        let config: Config = toml::from_str(toml_str).unwrap();
1039        config.inject_env_vars();
1040
1041        assert_eq!(env::var("GOOGLE_OAUTH_CLIENT_ID").unwrap(), "nested-123");
1042        assert_eq!(env::var("GOOGLE_OAUTH_CLIENT_SECRET").unwrap(), "nested-secret");
1043
1044        if let Some(v) = prev_id {
1045            env::set_var("GOOGLE_OAUTH_CLIENT_ID", v);
1046        } else {
1047            env::remove_var("GOOGLE_OAUTH_CLIENT_ID");
1048        }
1049        if let Some(v) = prev_secret {
1050            env::set_var("GOOGLE_OAUTH_CLIENT_SECRET", v);
1051        } else {
1052            env::remove_var("GOOGLE_OAUTH_CLIENT_SECRET");
1053        }
1054    }
1055
1056    #[test]
1057    fn test_single_name_var_in_namespace() {
1058        let _lock = SERIAL_TEST.lock().unwrap();
1059        let toml_str = r#"
1060            [platform]
1061            name = "myapp"
1062        "#;
1063        let prev = env::var("PLATFORM_NAME").ok();
1064        env::remove_var("PLATFORM_NAME");
1065
1066        let config: Config = toml::from_str(toml_str).unwrap();
1067        config.inject_env_vars();
1068
1069        assert_eq!(env::var("PLATFORM_NAME").unwrap(), "myapp");
1070
1071        if let Some(v) = prev {
1072            env::set_var("PLATFORM_NAME", v);
1073        } else {
1074            env::remove_var("PLATFORM_NAME");
1075        }
1076    }
1077
1078    #[test]
1079    fn test_os_env_takes_precedence_over_namespaced() {
1080        let _lock = SERIAL_TEST.lock().unwrap();
1081        let toml_str = r#"
1082            [google]
1083            client_id = "toml-value"
1084        "#;
1085        let prev = env::var("GOOGLE_CLIENT_ID").ok();
1086        env::set_var("GOOGLE_CLIENT_ID", "os-value");
1087
1088        let config: Config = toml::from_str(toml_str).unwrap();
1089        config.inject_env_vars();
1090
1091        assert_eq!(env::var("GOOGLE_CLIENT_ID").unwrap(), "os-value");
1092
1093        if let Some(v) = prev {
1094            env::set_var("GOOGLE_CLIENT_ID", v);
1095        } else {
1096            env::remove_var("GOOGLE_CLIENT_ID");
1097        }
1098    }
1099
1100    #[test]
1101    fn test_env_table_takes_precedence_over_namespace() {
1102        let _lock = SERIAL_TEST.lock().unwrap();
1103        let toml_str = r#"
1104            [env]
1105            GOOGLE_CLIENT_ID = "from-env-table"
1106
1107            [google]
1108            client_id = "from-namespace"
1109        "#;
1110        let prev = env::var("GOOGLE_CLIENT_ID").ok();
1111        env::remove_var("GOOGLE_CLIENT_ID");
1112
1113        let config: Config = toml::from_str(toml_str).unwrap();
1114        config.inject_env_vars();
1115
1116        assert_eq!(env::var("GOOGLE_CLIENT_ID").unwrap(), "from-env-table");
1117
1118        if let Some(v) = prev {
1119            env::set_var("GOOGLE_CLIENT_ID", v);
1120        } else {
1121            env::remove_var("GOOGLE_CLIENT_ID");
1122        }
1123    }
1124
1125    #[test]
1126    fn test_get_namespaced_custom_value() {
1127        let toml_str = r#"
1128            [google]
1129            client_id = "abc"
1130
1131            [google.oauth]
1132            redirect_url = "http://localhost/callback"
1133        "#;
1134        let config: Config = toml::from_str(toml_str).unwrap();
1135        assert_eq!(config.get::<String>("google.client_id").unwrap(), "abc");
1136        assert_eq!(
1137            config.get::<String>("google.oauth.redirect_url").unwrap(),
1138            "http://localhost/callback"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_has_key_namespaced() {
1144        let toml_str = r#"
1145            [platform]
1146            name = "test"
1147
1148            [platform.api]
1149            key = "secret"
1150        "#;
1151        let config: Config = toml::from_str(toml_str).unwrap();
1152        assert!(config.has_key("platform.name"));
1153        assert!(config.has_key("platform.api.key"));
1154        assert!(!config.has_key("platform.missing"));
1155    }
1156
1157    #[test]
1158    fn test_non_string_namespaced_values() {
1159        let _lock = SERIAL_TEST.lock().unwrap();
1160        let toml_str = r#"
1161            [myapp]
1162            port = 8080
1163            debug = true
1164        "#;
1165        let prev_port = env::var("MYAPP_PORT").ok();
1166        let prev_debug = env::var("MYAPP_DEBUG").ok();
1167        env::remove_var("MYAPP_PORT");
1168        env::remove_var("MYAPP_DEBUG");
1169
1170        let config: Config = toml::from_str(toml_str).unwrap();
1171        config.inject_env_vars();
1172
1173        assert_eq!(env::var("MYAPP_PORT").unwrap(), "8080");
1174        assert_eq!(env::var("MYAPP_DEBUG").unwrap(), "true");
1175
1176        if let Some(v) = prev_port {
1177            env::set_var("MYAPP_PORT", v);
1178        } else {
1179            env::remove_var("MYAPP_PORT");
1180        }
1181        if let Some(v) = prev_debug {
1182            env::set_var("MYAPP_DEBUG", v);
1183        } else {
1184            env::remove_var("MYAPP_DEBUG");
1185        }
1186    }
1187
1188    // ── New: Scenario Tests ─────────────────────────────────────────────
1189
1190    // Test 1: Flat custom namespace with string override
1191    #[test]
1192    fn test_flat_custom_string_override() {
1193        let toml = r#"[demo]
1194            url = "from-toml""#;
1195        let config = config_from_toml_with_env(
1196            toml,
1197            &[("DEMO_URL", "from-dotenv")],
1198        )
1199        .unwrap();
1200        assert_eq!(
1201            config.get::<String>("demo.url").unwrap(),
1202            "from-dotenv"
1203        );
1204    }
1205
1206    // Test 2: Flat custom namespace with integer override
1207    #[test]
1208    fn test_flat_custom_integer_override() {
1209        let toml = r#"[demo]
1210            timeout = 10"#;
1211        let config = config_from_toml_with_env(
1212            toml,
1213            &[("DEMO_TIMEOUT", "30")],
1214        )
1215        .unwrap();
1216        assert_eq!(config.get::<i64>("demo.timeout").unwrap(), 30);
1217    }
1218
1219    // Test 3: Flat custom namespace with boolean override
1220    #[test]
1221    fn test_flat_custom_boolean_override() {
1222        let toml = r#"[demo]
1223            debug = false"#;
1224        let config = config_from_toml_with_env(
1225            toml,
1226            &[("DEMO_DEBUG", "true")],
1227        )
1228        .unwrap();
1229        assert_eq!(config.get::<bool>("demo.debug").unwrap(), true);
1230    }
1231
1232    // Test 4: Dotted sub-table namespace with string override
1233    #[test]
1234    fn test_dotted_subtable_override() {
1235        let toml = r#"[demo.service]
1236            url = "from-toml-nested""#;
1237        let config = config_from_toml_with_env(
1238            toml,
1239            &[("DEMO_SERVICE_URL", "from-dotenv-nested")],
1240        )
1241        .unwrap();
1242        assert_eq!(
1243            config.get::<String>("demo.service.url").unwrap(),
1244            "from-dotenv-nested"
1245        );
1246    }
1247
1248    // Test 5: Multiple custom namespaces
1249    #[test]
1250    fn test_multiple_custom_namespace_overrides() {
1251        let toml = r#"
1252            [demo]
1253            url = "demo-toml"
1254
1255            [google]
1256            client_id = "google-toml"
1257        "#;
1258        let config = config_from_toml_with_env(
1259            toml,
1260            &[
1261                ("DEMO_URL", "demo-dotenv"),
1262                ("GOOGLE_CLIENT_ID", "google-dotenv"),
1263            ],
1264        )
1265        .unwrap();
1266        assert_eq!(
1267            config.get::<String>("demo.url").unwrap(),
1268            "demo-dotenv"
1269        );
1270        assert_eq!(
1271            config.get::<String>("google.client_id").unwrap(),
1272            "google-dotenv"
1273        );
1274    }
1275
1276    // Test 6: Known section (SERVER_PORT) still works
1277    #[test]
1278    fn test_known_section_server_port_still_works() {
1279        let toml = r#"[server]
1280            port = 3000"#;
1281        let config = config_from_toml_with_env(
1282            toml,
1283            &[("SERVER_PORT", "9000")],
1284        )
1285        .unwrap();
1286        assert_eq!(config.server.port, 9000);
1287        assert_eq!(
1288            config.get::<u16>("server.port").unwrap(),
1289            9000
1290        );
1291    }
1292
1293    // Test 7: No env override — TOML value preserved
1294    #[test]
1295    fn test_no_env_override_preserves_toml() {
1296        let toml = r#"[demo]
1297            url = "from-toml"
1298            timeout = 10"#;
1299        let config = config_from_toml_with_env(toml, &[]).unwrap();
1300        assert_eq!(
1301            config.get::<String>("demo.url").unwrap(),
1302            "from-toml"
1303        );
1304        assert_eq!(config.get::<i64>("demo.timeout").unwrap(), 10);
1305    }
1306
1307    // Test 8: Partial override — some properties overridden, others not
1308    #[test]
1309    fn test_partial_override() {
1310        let toml = r#"[demo]
1311            url = "toml-url"
1312            timeout = 10"#;
1313        let config = config_from_toml_with_env(
1314            toml,
1315            &[("DEMO_URL", "env-url")],
1316        )
1317        .unwrap();
1318        assert_eq!(
1319            config.get::<String>("demo.url").unwrap(),
1320            "env-url"
1321        );
1322        assert_eq!(config.get::<i64>("demo.timeout").unwrap(), 10);
1323    }
1324
1325    // Test 9: OS env vars are ignored (pre_dotenv_keys guard)
1326    #[test]
1327    fn test_os_env_vars_ignored() {
1328        // Use the real load_path which takes a real pre_dotenv_keys snapshot.
1329        // We create a minimal TOML with no custom namespaces to avoid collision.
1330        let _lock = SERIAL_TEST.lock().unwrap();
1331        let prev_path = env::var("PATH").ok();
1332        let prev_home = env::var("HOME").ok();
1333        env::set_var("PATH", "/usr/bin:/bin");
1334        env::set_var("HOME", "/root");
1335        // load_from takes a snapshot internally and will blacklist PATH/HOME
1336        let config = Config::load_from("non-existent.toml").unwrap();
1337        assert!(config.get::<String>("path").is_none());
1338        assert!(config.get::<String>("home").is_none());
1339        if let Some(v) = prev_path {
1340            env::set_var("PATH", v);
1341        } else {
1342            env::remove_var("PATH");
1343        }
1344        if let Some(v) = prev_home {
1345            env::set_var("HOME", v);
1346        } else {
1347            env::remove_var("HOME");
1348        }
1349    }
1350
1351    // Test 10: Ambiguous namespace detection — dotted vs flat collision
1352    #[test]
1353    fn test_ambiguous_namespace_dotted_vs_flat() {
1354        let toml = r#"
1355            [demo_service]
1356            url = "flat"
1357
1358            [demo.service]
1359            url = "dotted"
1360        "#;
1361        let result = config_from_toml_with_env(toml, &[]);
1362        assert!(result.is_err());
1363        match result {
1364            Err(ConfigError::AmbiguousNamespace { prefix, candidates }) => {
1365                assert_eq!(prefix, "DEMO_SERVICE_");
1366                assert!(candidates.contains(&"demo_service".to_string()));
1367                assert!(candidates.contains(&"demo.service".to_string()));
1368            }
1369            _ => panic!("expected AmbiguousNamespace error"),
1370        }
1371    }
1372
1373    // Test 11: Case-sensitive collision detection
1374    #[test]
1375    fn test_case_sensitive_collision() {
1376        let toml = r#"
1377            [demo]
1378            url = "lower"
1379
1380            [DEMO]
1381            url = "upper"
1382        "#;
1383        let result = config_from_toml_with_env(toml, &[]);
1384        assert!(result.is_err());
1385        match result {
1386            Err(ConfigError::AmbiguousNamespace { prefix, .. }) => {
1387                assert_eq!(prefix, "DEMO_");
1388            }
1389            _ => panic!("expected AmbiguousNamespace error"),
1390        }
1391    }
1392
1393    // Test 12: Env var with no matching namespace is ignored
1394    #[test]
1395    fn test_unknown_env_var_ignored() {
1396        let toml = r#"[demo]
1397            url = "ok""#;
1398        let config = config_from_toml_with_env(
1399            toml,
1400            &[("UNKNOWN_KEY", "somevalue")],
1401        )
1402        .unwrap();
1403        // Unknown key does not appear in custom
1404        assert!(config.custom.get("unknown").is_none());
1405        // Known namespace still works
1406        assert_eq!(
1407            config.get::<String>("demo.url").unwrap(),
1408            "ok"
1409        );
1410    }
1411
1412    // Test 13: Table-level override is silently skipped
1413    #[test]
1414    fn test_table_level_override_skipped() {
1415        let toml = r#"[demo.service]
1416            url = "nested""#;
1417        let config = config_from_toml_with_env(
1418            toml,
1419            &[("DEMO_SERVICE", "not-a-property")],
1420        )
1421        .unwrap();
1422        // The table-level env var (no remaining key) is skipped
1423        // The nested value from TOML is preserved
1424        assert_eq!(
1425            config.get::<String>("demo.service.url").unwrap(),
1426            "nested"
1427        );
1428    }
1429
1430    // Test 14: Float coercion
1431    #[test]
1432    fn test_float_coercion() {
1433        let toml = r#"[demo]
1434            threshold = 0.5"#;
1435        let config = config_from_toml_with_env(
1436            toml,
1437            &[("DEMO_THRESHOLD", "0.75")],
1438        )
1439        .unwrap();
1440        let val: f64 = config.get("demo.threshold").unwrap();
1441        assert!((val - 0.75).abs() < 1e-10);
1442    }
1443
1444    // Test 15: String that looks numeric — coerced to typed Value
1445    // "2.0" contains '.' → Float(2.0). The type in custom is Float,
1446    // so reading as f64 works correctly. Reading as String would fail
1447    // because serde's String visitor does not accept visit_f64.
1448    #[test]
1449    fn test_numeric_like_string_override() {
1450        let toml = r#"[demo]
1451            version = "1.0""#;
1452        let config = config_from_toml_with_env(
1453            toml,
1454            &[("DEMO_VERSION", "2.0")],
1455        )
1456        .unwrap();
1457        let val: f64 = config.get("demo.version").unwrap();
1458        assert!((val - 2.0).abs() < 1e-10);
1459    }
1460
1461    // Test: coerce_env_value boolean
1462    #[test]
1463    fn test_coerce_boolean() {
1464        assert_eq!(coerce_env_value("true"), toml::Value::Boolean(true));
1465        assert_eq!(coerce_env_value("TRUE"), toml::Value::Boolean(true));
1466        assert_eq!(coerce_env_value("false"), toml::Value::Boolean(false));
1467        assert_eq!(coerce_env_value("FALSE"), toml::Value::Boolean(false));
1468    }
1469
1470    // Test: coerce_env_value integer
1471    #[test]
1472    fn test_coerce_integer() {
1473        assert_eq!(coerce_env_value("30"), toml::Value::Integer(30));
1474        assert_eq!(coerce_env_value("-5"), toml::Value::Integer(-5));
1475        assert_eq!(coerce_env_value("0"), toml::Value::Integer(0));
1476        // Not an integer (has decimal)
1477        match coerce_env_value("3.14") {
1478            toml::Value::Float(_) => {}
1479            _ => panic!("expected Float"),
1480        }
1481    }
1482
1483    // Test: coerce_env_value float
1484    #[test]
1485    fn test_coerce_float() {
1486        match coerce_env_value("3.14") {
1487            toml::Value::Float(f) => assert!((f - 3.14).abs() < 1e-10),
1488            _ => panic!("expected Float"),
1489        }
1490    }
1491
1492    // Test: coerce_env_value string fallthrough
1493    #[test]
1494    fn test_coerce_string() {
1495        assert_eq!(
1496            coerce_env_value("hello"),
1497            toml::Value::String("hello".to_string())
1498        );
1499        assert_eq!(
1500            coerce_env_value("abc123"),
1501            toml::Value::String("abc123".to_string())
1502        );
1503    }
1504
1505    use std::sync::Mutex;
1506    static SERIAL_TEST: Mutex<()> = Mutex::new(());
1507}