1use serde::{Deserialize, Serialize};
73use std::collections::{HashMap, HashSet};
74use std::env;
75use std::fs;
76use std::path::Path;
77use thiserror::Error;
78
79#[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#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Environment {
123 Development,
124 Testing,
125 Production,
126}
127
128impl Environment {
129 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 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#[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 #[serde(default)]
194 pub env: HashMap<String, String>,
195 #[serde(flatten, default)]
200 pub custom: HashMap<String, toml::Value>,
201}
202
203#[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#[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#[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#[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#[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#[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
320fn 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
413struct NamespaceEntry {
415 env_prefix: String,
417 config_path: Vec<String>,
419}
420
421fn coerce_env_value(raw: &str) -> toml::Value {
425 let trimmed = raw.trim();
426
427 match trimmed.to_lowercase().as_str() {
429 "true" => return toml::Value::Boolean(true),
430 "false" => return toml::Value::Boolean(false),
431 _ => {}
432 }
433
434 if let Ok(n) = trimmed.parse::<i64>() {
436 return toml::Value::Integer(n);
437 }
438
439 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 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 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 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; }
509 Self::inject_namespaced_env(&key, &value);
510 }
511 }
512 }
513 }
514
515 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 fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
554 Ok(())
555 }
556
557 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 fn build_namespace_registry(&self) -> Result<Vec<NamespaceEntry>, ConfigError> {
607 let mut registry: Vec<NamespaceEntry> = Vec::new();
608
609 for (key, value) in &self.custom {
611 Self::collect_custom_paths(key, value, &[], &mut registry);
612 }
613
614 let mut seen: HashMap<String, Vec<String>> = HashMap::new();
616 for entry in ®istry {
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 registry.sort_by(|a, b| b.env_prefix.len().cmp(&a.env_prefix.len()));
634 Ok(registry)
635 }
636
637 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 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 for (sub_key, sub_val) in table {
665 Self::collect_custom_paths(sub_key, sub_val, &path, registry);
666 }
667 }
668 }
669
670 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 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 pub fn load() -> Result<Self, ConfigError> {
770 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 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 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 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 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 pub fn get_u16(&self, key: &str) -> Result<u16, ConfigError> {
904 self.get_required(key)
905 }
906
907 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 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 for &(k, _) in env_vars {
932 let _ = env::remove_var(k);
933 }
934 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 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 #[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 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 #[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]
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]
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]
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]
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]
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]
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]
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]
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]
1368 fn test_os_env_vars_ignored() {
1369 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 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]
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]
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]
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 assert!(config.custom.get("unknown").is_none());
1446 assert_eq!(
1448 config.get::<String>("demo.url").unwrap(),
1449 "ok"
1450 );
1451 }
1452
1453 #[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 assert_eq!(
1466 config.get::<String>("demo.service.url").unwrap(),
1467 "nested"
1468 );
1469 }
1470
1471 #[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]
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]
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]
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 match coerce_env_value("3.14") {
1519 toml::Value::Float(_) => {}
1520 _ => panic!("expected Float"),
1521 }
1522 }
1523
1524 #[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]
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}