1use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, HashSet};
22use std::env;
23use std::fs;
24use std::path::Path;
25use thiserror::Error;
26
27#[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#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum Environment {
71 Development,
72 Testing,
73 Production,
74}
75
76impl Environment {
77 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 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#[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 #[serde(default)]
142 pub env: HashMap<String, String>,
143 #[serde(flatten, default)]
148 pub custom: HashMap<String, toml::Value>,
149}
150
151#[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#[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#[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#[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#[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#[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
268fn 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
361struct NamespaceEntry {
363 env_prefix: String,
365 config_path: Vec<String>,
367}
368
369fn coerce_env_value(raw: &str) -> toml::Value {
373 let trimmed = raw.trim();
374
375 match trimmed.to_lowercase().as_str() {
377 "true" => return toml::Value::Boolean(true),
378 "false" => return toml::Value::Boolean(false),
379 _ => {}
380 }
381
382 if let Ok(n) = trimmed.parse::<i64>() {
384 return toml::Value::Integer(n);
385 }
386
387 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 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 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 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; }
457 Self::inject_namespaced_env(&key, &value);
458 }
459 }
460 }
461 }
462
463 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 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 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 fn build_namespace_registry(&self) -> Result<Vec<NamespaceEntry>, ConfigError> {
572 let mut registry: Vec<NamespaceEntry> = Vec::new();
573
574 for (key, value) in &self.custom {
576 Self::collect_custom_paths(key, value, &[], &mut registry);
577 }
578
579 let mut seen: HashMap<String, Vec<String>> = HashMap::new();
581 for entry in ®istry {
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 registry.sort_by(|a, b| b.env_prefix.len().cmp(&a.env_prefix.len()));
599 Ok(registry)
600 }
601
602 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 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 for (sub_key, sub_val) in table {
630 Self::collect_custom_paths(sub_key, sub_val, &path, registry);
631 }
632 }
633 }
634
635 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 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 pub fn load() -> Result<Self, ConfigError> {
735 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 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 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 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 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 pub fn get_u16(&self, key: &str) -> Result<u16, ConfigError> {
869 self.get_required(key)
870 }
871
872 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 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 for &(k, _) in env_vars {
897 let _ = env::remove_var(k);
898 }
899 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 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 #[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 #[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]
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]
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]
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]
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]
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]
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]
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]
1327 fn test_os_env_vars_ignored() {
1328 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 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]
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]
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]
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 assert!(config.custom.get("unknown").is_none());
1405 assert_eq!(
1407 config.get::<String>("demo.url").unwrap(),
1408 "ok"
1409 );
1410 }
1411
1412 #[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 assert_eq!(
1425 config.get::<String>("demo.service.url").unwrap(),
1426 "nested"
1427 );
1428 }
1429
1430 #[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]
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]
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]
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 match coerce_env_value("3.14") {
1478 toml::Value::Float(_) => {}
1479 _ => panic!("expected Float"),
1480 }
1481 }
1482
1483 #[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]
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}