Skip to main content

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}