Skip to main content

toxi_config/
lib.rs

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