reinhardt_conf/settings/sources.rs
1//! Configuration sources for layered settings system
2//!
3//! Provides different sources of configuration that can be merged together
4//! in priority order (environment variables > .env files > config files > defaults).
5
6use super::env::EnvError;
7use super::env_loader::EnvLoader;
8use super::profile::Profile;
9use indexmap::IndexMap;
10use serde_json::Value;
11use std::collections::HashMap;
12use std::fs;
13use std::path::PathBuf;
14
15/// Trait for configuration sources
16pub trait ConfigSource: Send + Sync {
17 /// Load configuration from this source
18 fn load(&self) -> Result<IndexMap<String, Value>, SourceError>;
19
20 /// Get the priority of this source (higher = more important)
21 fn priority(&self) -> u8;
22
23 /// Get a description of this source
24 fn description(&self) -> String;
25}
26
27/// Error type for configuration sources
28#[non_exhaustive]
29#[derive(Debug, thiserror::Error)]
30pub enum SourceError {
31 /// An I/O error occurred while reading the configuration source.
32 #[error("IO error: {0}")]
33 Io(#[from] std::io::Error),
34
35 /// The configuration content could not be parsed.
36 #[error("Parse error: {0}")]
37 Parse(String),
38
39 /// An error occurred reading environment variables.
40 #[error("Environment error: {0}")]
41 Env(#[from] EnvError),
42
43 /// The TOML configuration file could not be parsed.
44 #[error("TOML error: {0}")]
45 Toml(#[from] toml::de::Error),
46
47 /// The JSON configuration file could not be parsed.
48 #[error("JSON error: {0}")]
49 Json(#[from] serde_json::Error),
50
51 /// The configuration source is invalid or misconfigured.
52 #[error("Invalid source: {0}")]
53 InvalidSource(String),
54
55 /// A `${VAR}` interpolation failed during TOML loading.
56 ///
57 /// `InterpolationError` is boxed so that adding this variant does
58 /// not push `BuildError::Source` over the `result_large_err` clippy
59 /// threshold (the `Syntax` variant carries four heap-owning fields).
60 #[error("Interpolation error: {0}")]
61 Interpolation(#[from] Box<super::interpolation::InterpolationError>),
62}
63
64// Allow the `?` operator to convert a bare `InterpolationError` into a
65// `SourceError::Interpolation`. The auto-derived `From<Box<...>>` from
66// `#[from]` would otherwise force every call site to box explicitly.
67impl From<super::interpolation::InterpolationError> for SourceError {
68 fn from(err: super::interpolation::InterpolationError) -> Self {
69 SourceError::Interpolation(Box::new(err))
70 }
71}
72
73/// Environment variable configuration source
74pub struct EnvSource {
75 prefix: Option<String>,
76 interpolate: bool,
77}
78
79impl EnvSource {
80 /// Create a new environment variable configuration source
81 ///
82 /// # Examples
83 ///
84 /// ```
85 /// use reinhardt_conf::settings::sources::EnvSource;
86 ///
87 /// let source = EnvSource::new();
88 /// // Loads all environment variables
89 /// ```
90 pub fn new() -> Self {
91 Self {
92 prefix: None,
93 interpolate: false,
94 }
95 }
96 /// Set a prefix filter for environment variables
97 ///
98 /// # Examples
99 ///
100 /// ```
101 /// use reinhardt_conf::settings::sources::EnvSource;
102 ///
103 /// let source = EnvSource::new()
104 /// .with_prefix("APP_");
105 /// // Only loads env vars starting with APP_
106 /// ```
107 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
108 self.prefix = Some(prefix.into());
109 self
110 }
111 /// Enable variable interpolation for environment values
112 ///
113 /// # Examples
114 ///
115 /// ```
116 /// use reinhardt_conf::settings::sources::EnvSource;
117 ///
118 /// let source = EnvSource::new()
119 /// .with_interpolation(true);
120 /// // Environment variables will support $VAR expansion
121 /// ```
122 pub fn with_interpolation(mut self, enabled: bool) -> Self {
123 self.interpolate = enabled;
124 self
125 }
126}
127
128impl Default for EnvSource {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134impl ConfigSource for EnvSource {
135 fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
136 let mut config = IndexMap::new();
137
138 // Get all environment variables
139 for (key, value) in std::env::vars() {
140 // Skip if prefix is set and key doesn't start with it
141 if let Some(prefix) = &self.prefix
142 && !key.starts_with(prefix)
143 {
144 continue;
145 }
146
147 // Remove prefix if present
148 let clean_key = if let Some(prefix) = &self.prefix {
149 key.strip_prefix(prefix).unwrap_or(&key).to_string()
150 } else {
151 key.clone()
152 };
153
154 // Convert to lowercase for consistency
155 let lower_key = clean_key.to_lowercase();
156
157 // Try to parse as appropriate type
158 let parsed_value = if lower_key == "debug" {
159 // Parse debug value with support for "1", "0", "true", "false", etc.
160 match value.trim().to_lowercase().as_str() {
161 "true" | "1" | "yes" | "on" => Value::Bool(true),
162 "false" | "0" | "no" | "off" => Value::Bool(false),
163 _ => {
164 if let Ok(b) = value.parse::<bool>() {
165 Value::Bool(b)
166 } else {
167 Value::String(value)
168 }
169 }
170 }
171 } else if lower_key == "allowed_hosts" {
172 // Parse comma-separated list
173 let list: Vec<_> = value
174 .split(',')
175 .map(|s| Value::String(s.trim().to_string()))
176 .collect();
177 Value::Array(list)
178 } else if let Ok(num) = value.parse::<i64>() {
179 Value::Number(num.into())
180 } else if let Ok(b) = value.parse::<bool>() {
181 Value::Bool(b)
182 } else {
183 Value::String(value)
184 };
185
186 config.insert(lower_key, parsed_value);
187 }
188
189 Ok(config)
190 }
191
192 fn priority(&self) -> u8 {
193 100 // Highest priority
194 }
195
196 fn description(&self) -> String {
197 match &self.prefix {
198 Some(prefix) => format!("Environment variables (prefix: {})", prefix),
199 None => "Environment variables".to_string(),
200 }
201 }
202}
203
204/// .env file configuration source
205pub struct DotEnvSource {
206 path: Option<PathBuf>,
207 profile: Option<Profile>,
208 interpolate: bool,
209}
210
211impl DotEnvSource {
212 /// Create a new .env file configuration source
213 ///
214 /// # Examples
215 ///
216 /// ```
217 /// use reinhardt_conf::settings::sources::DotEnvSource;
218 ///
219 /// let source = DotEnvSource::new();
220 /// // Loads from .env file
221 /// ```
222 pub fn new() -> Self {
223 Self {
224 path: None,
225 profile: None,
226 interpolate: false,
227 }
228 }
229 /// Set a specific path for the .env file
230 ///
231 /// # Examples
232 ///
233 /// ```
234 /// use reinhardt_conf::settings::sources::DotEnvSource;
235 /// use std::path::PathBuf;
236 ///
237 /// let source = DotEnvSource::new()
238 /// .with_path(PathBuf::from(".env.local"));
239 /// ```
240 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
241 self.path = Some(path.into());
242 self
243 }
244 /// Set the profile to determine .env file name
245 ///
246 /// # Examples
247 ///
248 /// ```
249 /// use reinhardt_conf::settings::sources::DotEnvSource;
250 /// use reinhardt_conf::settings::profile::Profile;
251 ///
252 /// let source = DotEnvSource::new()
253 /// .with_profile(Profile::Production);
254 /// // Will load .env.production
255 /// ```
256 pub fn with_profile(mut self, profile: Profile) -> Self {
257 self.profile = Some(profile);
258 self
259 }
260 /// Enable variable interpolation in .env files
261 ///
262 /// # Examples
263 ///
264 /// ```
265 /// use reinhardt_conf::settings::sources::DotEnvSource;
266 ///
267 /// let source = DotEnvSource::new()
268 /// .with_interpolation(true);
269 /// // .env file variables will support $VAR expansion
270 /// ```
271 pub fn with_interpolation(mut self, enabled: bool) -> Self {
272 self.interpolate = enabled;
273 self
274 }
275}
276
277impl Default for DotEnvSource {
278 fn default() -> Self {
279 Self::new()
280 }
281}
282
283impl ConfigSource for DotEnvSource {
284 fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
285 let path = match &self.path {
286 Some(p) => p.clone(),
287 None => {
288 let filename = match &self.profile {
289 Some(profile) => profile.env_file_name(),
290 None => ".env".to_string(),
291 };
292 PathBuf::from(filename)
293 }
294 };
295
296 // Load .env file if it exists
297 let loader = EnvLoader::new()
298 .path(&path)
299 .interpolate(self.interpolate)
300 .overwrite(false);
301
302 // Try to load, but don't fail if file doesn't exist
303 let _ = loader.load_optional()?;
304
305 // Return empty config - the env vars are already loaded
306 // The EnvSource will pick them up
307 Ok(IndexMap::new())
308 }
309
310 fn priority(&self) -> u8 {
311 90 // High priority, but below direct env vars
312 }
313
314 fn description(&self) -> String {
315 match &self.path {
316 Some(path) => format!(".env file: {}", path.display()),
317 None => match &self.profile {
318 Some(profile) => format!(".env file: {}", profile.env_file_name()),
319 None => ".env file".to_string(),
320 },
321 }
322 }
323}
324
325/// TOML file configuration source
326pub struct TomlFileSource {
327 path: PathBuf,
328 interpolate: bool,
329}
330
331impl TomlFileSource {
332 /// Create a new TOML file configuration source.
333 ///
334 /// `${VAR}` interpolation is **enabled by default** because the vast
335 /// majority of real-world settings files (secrets, per-environment
336 /// hosts, 12-factor overrides) require it. Call
337 /// [`Self::without_interpolation`] to opt out and preserve raw TOML
338 /// strings verbatim.
339 ///
340 /// See [`Self::with_interpolation`] for the supported syntax.
341 ///
342 /// # Examples
343 ///
344 /// ```
345 /// use reinhardt_conf::settings::sources::TomlFileSource;
346 /// use std::path::PathBuf;
347 ///
348 /// // Interpolation enabled by default — `${VAR}` is substituted from env.
349 /// let source = TomlFileSource::new(PathBuf::from("config.toml"));
350 /// ```
351 pub fn new(path: impl Into<PathBuf>) -> Self {
352 Self {
353 path: path.into(),
354 interpolate: true,
355 }
356 }
357
358 /// Explicitly opt **in** to `${VAR}` interpolation.
359 ///
360 /// This is a no-op for the default state — interpolation is on by
361 /// default since `0.1.0-rc.27`. The method exists so call sites can
362 /// document intent or re-enable interpolation after a previous
363 /// [`Self::without_interpolation`] call in a builder chain.
364 ///
365 /// Supported syntax (applied to every `toml::Value::String` in the tree):
366 ///
367 /// | Token | Meaning |
368 /// |--------------------|--------------------------------------------------|
369 /// | `${VAR}` | required — fails if `VAR` is unset or empty |
370 /// | `${VAR:-default}` | substitutes `default` if `VAR` is unset or empty |
371 /// | `${VAR:?message}` | fails with `message` if `VAR` is unset or empty |
372 /// | `$$` | escape — produces a literal `$` |
373 ///
374 /// Only string nodes are scanned, but the walker recurses into nested
375 /// tables and arrays. Numeric, boolean, and datetime values are
376 /// never rewritten.
377 ///
378 /// # Examples
379 ///
380 /// ```
381 /// use reinhardt_conf::settings::sources::TomlFileSource;
382 /// use std::path::PathBuf;
383 ///
384 /// let source = TomlFileSource::new(PathBuf::from("settings.toml"))
385 /// .with_interpolation();
386 /// ```
387 pub fn with_interpolation(mut self) -> Self {
388 self.interpolate = true;
389 self
390 }
391
392 /// Opt **out** of `${VAR}` interpolation and keep all TOML strings as
393 /// literal values.
394 ///
395 /// Use this when you intend `${...}` substrings to survive the load —
396 /// for example, when the configuration is itself a template that
397 /// downstream code expands later.
398 ///
399 /// # Examples
400 ///
401 /// ```
402 /// use reinhardt_conf::settings::sources::TomlFileSource;
403 /// use std::path::PathBuf;
404 ///
405 /// let source = TomlFileSource::new(PathBuf::from("template.toml"))
406 /// .without_interpolation();
407 /// ```
408 pub fn without_interpolation(mut self) -> Self {
409 self.interpolate = false;
410 self
411 }
412}
413
414impl ConfigSource for TomlFileSource {
415 fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
416 if !self.path.exists() {
417 return Ok(IndexMap::new());
418 }
419
420 let content = fs::read_to_string(&self.path)?;
421 let mut toml_value: toml::Value = toml::from_str(&content)?;
422
423 // Apply ${VAR} interpolation if enabled. The lookup closure
424 // resolves variables from process env at load time.
425 if self.interpolate {
426 let lookup = |name: &str| std::env::var(name).ok();
427 let interpolator = super::interpolation::Interpolator::new(&lookup);
428 interpolator.interpolate_value(&mut toml_value, &self.path)?;
429 }
430
431 // Convert TOML value to JSON value
432 let json_str = serde_json::to_string(&toml_value)?;
433 let json_value: Value = serde_json::from_str(&json_str)?;
434
435 // Flatten into IndexMap
436 let map = json_value
437 .as_object()
438 .ok_or_else(|| SourceError::Parse("Expected object at root".to_string()))?;
439
440 Ok(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
441 }
442
443 fn priority(&self) -> u8 {
444 50 // Medium priority
445 }
446
447 fn description(&self) -> String {
448 format!("TOML file: {}", self.path.display())
449 }
450}
451
452/// Default values configuration source
453pub struct DefaultSource {
454 values: IndexMap<String, Value>,
455}
456
457impl DefaultSource {
458 /// Create a new default values configuration source
459 ///
460 /// # Examples
461 ///
462 /// ```
463 /// use reinhardt_conf::settings::sources::DefaultSource;
464 /// use serde_json::Value;
465 ///
466 /// let source = DefaultSource::new()
467 /// .with_value("debug", Value::Bool(false))
468 /// .with_value("port", Value::Number(8000.into()));
469 /// ```
470 pub fn new() -> Self {
471 Self {
472 values: IndexMap::new(),
473 }
474 }
475 /// Add a default value for a configuration key
476 ///
477 /// # Examples
478 ///
479 /// ```
480 /// use reinhardt_conf::settings::sources::DefaultSource;
481 /// use serde_json::Value;
482 ///
483 /// let source = DefaultSource::new()
484 /// .with_value("timeout", Value::Number(30.into()));
485 /// ```
486 pub fn with_value(mut self, key: impl Into<String>, value: Value) -> Self {
487 self.values.insert(key.into(), value);
488 self
489 }
490 /// Add multiple default values from a HashMap
491 ///
492 /// # Examples
493 ///
494 /// ```
495 /// use reinhardt_conf::settings::sources::DefaultSource;
496 /// use serde_json::Value;
497 /// use std::collections::HashMap;
498 ///
499 /// let mut defaults = HashMap::new();
500 /// defaults.insert("key1".to_string(), Value::String("value1".to_string()));
501 /// defaults.insert("key2".to_string(), Value::Bool(true));
502 ///
503 /// let source = DefaultSource::new()
504 /// .with_defaults(defaults);
505 /// ```
506 pub fn with_defaults(mut self, defaults: HashMap<String, Value>) -> Self {
507 self.values.extend(defaults);
508 self
509 }
510}
511
512impl Default for DefaultSource {
513 fn default() -> Self {
514 Self::new()
515 }
516}
517
518impl ConfigSource for DefaultSource {
519 fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
520 Ok(self.values.clone())
521 }
522
523 fn priority(&self) -> u8 {
524 0 // Lowest priority
525 }
526
527 fn description(&self) -> String {
528 "Default values".to_string()
529 }
530}
531/// Low-priority environment variable configuration source
532///
533/// This wrapper provides the same functionality as `EnvSource` but with lower priority
534/// than TOML files, allowing TOML configuration to override environment variables.
535///
536/// Priority: 40 (lower than TOML files at 50)
537///
538/// # Examples
539///
540/// ```
541/// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
542/// use reinhardt_conf::settings::builder::SettingsBuilder;
543///
544/// let settings = SettingsBuilder::new()
545/// .add_source(LowPriorityEnvSource::new())
546/// .build()
547/// .unwrap();
548/// ```
549pub struct LowPriorityEnvSource {
550 inner: EnvSource,
551}
552
553impl LowPriorityEnvSource {
554 /// Create a new low-priority environment variable configuration source
555 ///
556 /// # Examples
557 ///
558 /// ```
559 /// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
560 ///
561 /// let source = LowPriorityEnvSource::new();
562 /// ```
563 pub fn new() -> Self {
564 Self {
565 inner: EnvSource::new(),
566 }
567 }
568
569 /// Set a prefix filter for environment variables
570 ///
571 /// # Examples
572 ///
573 /// ```
574 /// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
575 ///
576 /// let source = LowPriorityEnvSource::new()
577 /// .with_prefix("REINHARDT_");
578 /// ```
579 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
580 self.inner = self.inner.with_prefix(prefix);
581 self
582 }
583
584 /// Enable variable interpolation for environment values
585 ///
586 /// # Examples
587 ///
588 /// ```
589 /// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
590 ///
591 /// let source = LowPriorityEnvSource::new()
592 /// .with_interpolation(true);
593 /// ```
594 pub fn with_interpolation(mut self, enabled: bool) -> Self {
595 self.inner = self.inner.with_interpolation(enabled);
596 self
597 }
598}
599
600impl Default for LowPriorityEnvSource {
601 fn default() -> Self {
602 Self::new()
603 }
604}
605
606impl ConfigSource for LowPriorityEnvSource {
607 fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
608 self.inner.load()
609 }
610
611 fn priority(&self) -> u8 {
612 40 // Lower than TOML files (50), allowing TOML to override env vars
613 }
614
615 fn description(&self) -> String {
616 format!("{} (low priority)", self.inner.description())
617 }
618}
619
620/// High-priority environment variable configuration source for test overrides
621///
622/// This wrapper provides the same functionality as `EnvSource` but with higher priority
623/// than TOML files, allowing environment variables to override TOML configuration.
624/// Intended for integration tests where dynamic values (e.g., TestContainer ports)
625/// must override file-based settings.
626///
627/// Priority: 60 (higher than TOML files at 50, lower than `DotEnvSource` at 90)
628///
629/// # Examples
630///
631/// ```
632/// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
633/// use reinhardt_conf::settings::builder::SettingsBuilder;
634///
635/// let settings = SettingsBuilder::new()
636/// .add_source(HighPriorityEnvSource::new().with_prefix("REINHARDT_TEST_"))
637/// .build()
638/// .unwrap();
639/// ```
640pub struct HighPriorityEnvSource {
641 inner: EnvSource,
642}
643
644impl HighPriorityEnvSource {
645 /// Create a new high-priority environment variable configuration source
646 ///
647 /// # Examples
648 ///
649 /// ```
650 /// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
651 ///
652 /// let source = HighPriorityEnvSource::new();
653 /// ```
654 pub fn new() -> Self {
655 Self {
656 inner: EnvSource::new(),
657 }
658 }
659
660 /// Set a prefix filter for environment variables
661 ///
662 /// # Examples
663 ///
664 /// ```
665 /// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
666 ///
667 /// let source = HighPriorityEnvSource::new()
668 /// .with_prefix("REINHARDT_TEST_");
669 /// ```
670 pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
671 self.inner = self.inner.with_prefix(prefix);
672 self
673 }
674
675 /// Enable variable interpolation for environment values
676 ///
677 /// # Examples
678 ///
679 /// ```
680 /// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
681 ///
682 /// let source = HighPriorityEnvSource::new()
683 /// .with_interpolation(true);
684 /// ```
685 pub fn with_interpolation(mut self, enabled: bool) -> Self {
686 self.inner = self.inner.with_interpolation(enabled);
687 self
688 }
689}
690
691impl Default for HighPriorityEnvSource {
692 fn default() -> Self {
693 Self::new()
694 }
695}
696
697impl ConfigSource for HighPriorityEnvSource {
698 fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
699 self.inner.load()
700 }
701
702 fn priority(&self) -> u8 {
703 60 // Higher than TOML files (50), allowing env vars to override TOML config
704 }
705
706 fn description(&self) -> String {
707 format!("{} (high priority)", self.inner.description())
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use std::env;
715 use std::fs::File;
716 use std::io::Write;
717 use tempfile::TempDir;
718
719 #[test]
720 fn test_env_source() {
721 // SAFETY: Setting environment variables is unsafe in multi-threaded programs.
722 // This test uses #[serial] to ensure exclusive access to environment variables.
723 unsafe {
724 env::set_var("SECRET_KEY", "test-secret");
725 env::set_var("DEBUG", "true");
726 }
727
728 let source = EnvSource::new();
729 let config = source.load().unwrap();
730
731 assert_eq!(
732 config.get("secret_key").unwrap(),
733 &Value::String("test-secret".to_string())
734 );
735 assert_eq!(config.get("debug").unwrap(), &Value::Bool(true));
736
737 // SAFETY: Removing environment variables is unsafe in multi-threaded programs.
738 // This test uses #[serial] to ensure exclusive access to environment variables.
739 unsafe {
740 env::remove_var("SECRET_KEY");
741 env::remove_var("DEBUG");
742 }
743 }
744
745 #[test]
746 fn test_toml_source() {
747 let temp_dir = TempDir::new().unwrap();
748 let config_path = temp_dir.path().join("config.toml");
749
750 let mut file = File::create(&config_path).unwrap();
751 writeln!(
752 file,
753 r#"
754debug = true
755secret_key = "test-key"
756 "#
757 )
758 .unwrap();
759
760 let source = TomlFileSource::new(&config_path);
761 let config = source.load().unwrap();
762
763 assert_eq!(config.get("debug").unwrap(), &Value::Bool(true));
764 assert_eq!(
765 config.get("secret_key").unwrap(),
766 &Value::String("test-key".to_string())
767 );
768 }
769
770 #[test]
771 fn test_default_source() {
772 let source = DefaultSource::new()
773 .with_value("key1", Value::String("value1".to_string()))
774 .with_value("key2", Value::Bool(true));
775
776 let config = source.load().unwrap();
777
778 assert_eq!(
779 config.get("key1").unwrap(),
780 &Value::String("value1".to_string())
781 );
782 assert_eq!(config.get("key2").unwrap(), &Value::Bool(true));
783 }
784
785 #[test]
786 fn test_source_priority() {
787 assert_eq!(EnvSource::new().priority(), 100);
788 assert_eq!(DotEnvSource::new().priority(), 90);
789 assert_eq!(HighPriorityEnvSource::new().priority(), 60);
790 assert_eq!(TomlFileSource::new("test.toml").priority(), 50);
791 assert_eq!(LowPriorityEnvSource::new().priority(), 40);
792 assert_eq!(DefaultSource::new().priority(), 0);
793 }
794
795 #[test]
796 fn test_high_priority_env_source_wraps_env_source() {
797 // Arrange
798 let source = HighPriorityEnvSource::new();
799
800 // Act
801 let priority = source.priority();
802 let description = source.description();
803
804 // Assert
805 assert_eq!(priority, 60);
806 assert!(description.contains("high priority"));
807 }
808
809 #[test]
810 fn test_high_priority_env_source_with_prefix() {
811 // Arrange
812 let source = HighPriorityEnvSource::new().with_prefix("REINHARDT_TEST_");
813
814 // Act
815 let description = source.description();
816
817 // Assert
818 assert!(description.contains("REINHARDT_TEST_"));
819 assert!(description.contains("high priority"));
820 }
821
822 #[test]
823 fn toml_file_source_without_interpolation_preserves_literal() {
824 // Arrange — issue #4224: explicit opt-out keeps `${...}` verbatim.
825 let temp_dir = TempDir::new().unwrap();
826 let config_path = temp_dir.path().join("config.toml");
827 let mut file = File::create(&config_path).unwrap();
828 writeln!(file, r#"host = "${{LITERAL_VAR}}""#).unwrap();
829
830 // Act
831 let source = TomlFileSource::new(&config_path).without_interpolation();
832 let config = source.load().unwrap();
833
834 // Assert
835 assert_eq!(
836 config.get("host").unwrap(),
837 &Value::String("${LITERAL_VAR}".to_string())
838 );
839 }
840
841 #[test]
842 fn test_high_priority_env_source_overrides_toml() {
843 // Arrange
844 let temp_dir = TempDir::new().unwrap();
845 let config_path = temp_dir.path().join("config.toml");
846 let mut file = File::create(&config_path).unwrap();
847 writeln!(file, r#"port = 1025"#).unwrap();
848
849 let prefix = "HPENV_TEST_3518_";
850 let env_key = format!("{prefix}PORT");
851
852 // SAFETY: Single-threaded test, no concurrent env access.
853 unsafe { env::set_var(&env_key, "9999") };
854
855 // Act
856 let settings = crate::settings::builder::SettingsBuilder::new()
857 .add_source(TomlFileSource::new(&config_path))
858 .add_source(HighPriorityEnvSource::new().with_prefix(prefix))
859 .build()
860 .unwrap();
861
862 // Assert — HighPriorityEnvSource (60) overrides TOML (50)
863 let port: i64 = settings.get("port").unwrap();
864 assert_eq!(port, 9999);
865
866 // Cleanup
867 unsafe { env::remove_var(&env_key) };
868 }
869}