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::{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	/// Set interpolation explicitly via a boolean flag.
414	///
415	/// This is the legacy 0.1.0-rc API surface. New code should use
416	/// [`Self::with_interpolation`] / [`Self::without_interpolation`]
417	/// instead.
418	#[deprecated(
419		since = "0.1.0-rc.27",
420		note = "Use with_interpolation()/without_interpolation() instead; will be removed in 0.2.0 (issue #4224)"
421	)]
422	pub fn set_interpolation(mut self, enabled: bool) -> Self {
423		self.interpolate = enabled;
424		self
425	}
426}
427
428impl ConfigSource for TomlFileSource {
429	fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
430		if !self.path.exists() {
431			return Ok(IndexMap::new());
432		}
433
434		let content = fs::read_to_string(&self.path)?;
435		let mut toml_value: toml::Value = toml::from_str(&content)?;
436
437		// Apply ${VAR} interpolation if enabled. The lookup closure
438		// resolves variables from process env at load time.
439		if self.interpolate {
440			let lookup = |name: &str| std::env::var(name).ok();
441			let interpolator = super::interpolation::Interpolator::new(&lookup);
442			interpolator.interpolate_value(&mut toml_value, &self.path)?;
443		}
444
445		// Convert TOML value to JSON value
446		let json_str = serde_json::to_string(&toml_value)?;
447		let json_value: Value = serde_json::from_str(&json_str)?;
448
449		// Flatten into IndexMap
450		let map = json_value
451			.as_object()
452			.ok_or_else(|| SourceError::Parse("Expected object at root".to_string()))?;
453
454		Ok(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
455	}
456
457	fn priority(&self) -> u8 {
458		50 // Medium priority
459	}
460
461	fn description(&self) -> String {
462		format!("TOML file: {}", self.path.display())
463	}
464}
465
466/// JSON file configuration source.
467///
468/// TOML is the canonical Reinhardt configuration format; this type will be
469/// removed in 0.2.0. Migrate `.json` files to `.toml` (TOML is a superset of
470/// typical JSON config use cases) or implement the public `ConfigSource` trait
471/// against `serde_json` if you need to keep JSON support out of tree. See
472/// <https://github.com/kent8192/reinhardt-web/issues/4087>.
473#[deprecated(
474	since = "0.1.0-rc.26",
475	note = "Use TomlFileSource instead. JsonFileSource will be removed in 0.2.0 (issue #4087)"
476)]
477pub struct JsonFileSource {
478	path: PathBuf,
479}
480
481#[allow(deprecated)] // impl block on a deprecated type (issue #4087).
482impl JsonFileSource {
483	/// Create a new JSON file configuration source
484	///
485	/// # Examples
486	///
487	/// ```
488	/// # #![allow(deprecated)]
489	/// use reinhardt_conf::settings::sources::JsonFileSource;
490	/// use std::path::PathBuf;
491	///
492	/// let source = JsonFileSource::new(PathBuf::from("config.json"));
493	/// ```
494	#[deprecated(
495		since = "0.1.0-rc.26",
496		note = "Use TomlFileSource::new instead. JsonFileSource will be removed in 0.2.0 (issue #4087)"
497	)]
498	pub fn new(path: impl Into<PathBuf>) -> Self {
499		Self { path: path.into() }
500	}
501}
502
503#[allow(deprecated)] // ConfigSource impl on a deprecated type (issue #4087).
504impl ConfigSource for JsonFileSource {
505	fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
506		if !self.path.exists() {
507			return Ok(IndexMap::new());
508		}
509
510		let content = fs::read_to_string(&self.path)?;
511		let json_value: Value = serde_json::from_str(&content)?;
512
513		// Flatten into IndexMap
514		let map = json_value
515			.as_object()
516			.ok_or_else(|| SourceError::Parse("Expected object at root".to_string()))?;
517
518		Ok(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
519	}
520
521	fn priority(&self) -> u8 {
522		50 // Medium priority
523	}
524
525	fn description(&self) -> String {
526		format!("JSON file: {}", self.path.display())
527	}
528}
529
530/// Default values configuration source
531pub struct DefaultSource {
532	values: IndexMap<String, Value>,
533}
534
535impl DefaultSource {
536	/// Create a new default values configuration source
537	///
538	/// # Examples
539	///
540	/// ```
541	/// use reinhardt_conf::settings::sources::DefaultSource;
542	/// use serde_json::Value;
543	///
544	/// let source = DefaultSource::new()
545	///     .with_value("debug", Value::Bool(false))
546	///     .with_value("port", Value::Number(8000.into()));
547	/// ```
548	pub fn new() -> Self {
549		Self {
550			values: IndexMap::new(),
551		}
552	}
553	/// Add a default value for a configuration key
554	///
555	/// # Examples
556	///
557	/// ```
558	/// use reinhardt_conf::settings::sources::DefaultSource;
559	/// use serde_json::Value;
560	///
561	/// let source = DefaultSource::new()
562	///     .with_value("timeout", Value::Number(30.into()));
563	/// ```
564	pub fn with_value(mut self, key: impl Into<String>, value: Value) -> Self {
565		self.values.insert(key.into(), value);
566		self
567	}
568	/// Add multiple default values from a HashMap
569	///
570	/// # Examples
571	///
572	/// ```
573	/// use reinhardt_conf::settings::sources::DefaultSource;
574	/// use serde_json::Value;
575	/// use std::collections::HashMap;
576	///
577	/// let mut defaults = HashMap::new();
578	/// defaults.insert("key1".to_string(), Value::String("value1".to_string()));
579	/// defaults.insert("key2".to_string(), Value::Bool(true));
580	///
581	/// let source = DefaultSource::new()
582	///     .with_defaults(defaults);
583	/// ```
584	pub fn with_defaults(mut self, defaults: HashMap<String, Value>) -> Self {
585		self.values.extend(defaults);
586		self
587	}
588}
589
590impl Default for DefaultSource {
591	fn default() -> Self {
592		Self::new()
593	}
594}
595
596impl ConfigSource for DefaultSource {
597	fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
598		Ok(self.values.clone())
599	}
600
601	fn priority(&self) -> u8 {
602		0 // Lowest priority
603	}
604
605	fn description(&self) -> String {
606		"Default values".to_string()
607	}
608}
609/// Auto-detect configuration source based on file extension.
610///
611/// The `*.json` branch is going away in 0.2.0 alongside [`JsonFileSource`].
612/// For TOML usage, prefer constructing [`TomlFileSource::new`] directly — it
613/// makes the configuration format explicit at the call site. See
614/// <https://github.com/kent8192/reinhardt-web/issues/4087>.
615///
616/// # Examples
617///
618/// ```
619/// # #![allow(deprecated)]
620/// use reinhardt_conf::settings::sources::auto_source;
621/// use std::path::PathBuf;
622///
623/// // Automatically detects TOML source from extension
624/// let source = auto_source(PathBuf::from("config.toml")).unwrap();
625///
626/// // Or JSON source (deprecated; will be removed in 0.2.0)
627/// let source = auto_source(PathBuf::from("settings.json")).unwrap();
628/// ```
629#[deprecated(
630	since = "0.1.0-rc.26",
631	note = "Use TomlFileSource::new directly. The *.json branch will be removed in 0.2.0 (issue #4087)"
632)]
633pub fn auto_source(path: impl AsRef<Path>) -> Result<Box<dyn ConfigSource>, SourceError> {
634	let path = path.as_ref();
635	let ext = path
636		.extension()
637		.and_then(|e| e.to_str())
638		.ok_or_else(|| SourceError::InvalidSource("No file extension".to_string()))?;
639
640	match ext {
641		"toml" => Ok(Box::new(TomlFileSource::new(path))),
642		// JsonFileSource is deprecated alongside this function (issue #4087);
643		// keep wiring it through until the *.json branch is removed in 0.2.0.
644		#[allow(deprecated)]
645		"json" => Ok(Box::new(JsonFileSource::new(path))),
646		_ => Err(SourceError::InvalidSource(format!(
647			"Unsupported file extension: {}",
648			ext
649		))),
650	}
651}
652
653/// Low-priority environment variable configuration source
654///
655/// This wrapper provides the same functionality as `EnvSource` but with lower priority
656/// than TOML files, allowing TOML configuration to override environment variables.
657///
658/// Priority: 40 (lower than TOML files at 50)
659///
660/// # Examples
661///
662/// ```
663/// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
664/// use reinhardt_conf::settings::builder::SettingsBuilder;
665///
666/// let settings = SettingsBuilder::new()
667///     .add_source(LowPriorityEnvSource::new())
668///     .build()
669///     .unwrap();
670/// ```
671pub struct LowPriorityEnvSource {
672	inner: EnvSource,
673}
674
675impl LowPriorityEnvSource {
676	/// Create a new low-priority environment variable configuration source
677	///
678	/// # Examples
679	///
680	/// ```
681	/// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
682	///
683	/// let source = LowPriorityEnvSource::new();
684	/// ```
685	pub fn new() -> Self {
686		Self {
687			inner: EnvSource::new(),
688		}
689	}
690
691	/// Set a prefix filter for environment variables
692	///
693	/// # Examples
694	///
695	/// ```
696	/// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
697	///
698	/// let source = LowPriorityEnvSource::new()
699	///     .with_prefix("REINHARDT_");
700	/// ```
701	pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
702		self.inner = self.inner.with_prefix(prefix);
703		self
704	}
705
706	/// Enable variable interpolation for environment values
707	///
708	/// # Examples
709	///
710	/// ```
711	/// use reinhardt_conf::settings::sources::LowPriorityEnvSource;
712	///
713	/// let source = LowPriorityEnvSource::new()
714	///     .with_interpolation(true);
715	/// ```
716	pub fn with_interpolation(mut self, enabled: bool) -> Self {
717		self.inner = self.inner.with_interpolation(enabled);
718		self
719	}
720}
721
722impl Default for LowPriorityEnvSource {
723	fn default() -> Self {
724		Self::new()
725	}
726}
727
728impl ConfigSource for LowPriorityEnvSource {
729	fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
730		self.inner.load()
731	}
732
733	fn priority(&self) -> u8 {
734		40 // Lower than TOML files (50), allowing TOML to override env vars
735	}
736
737	fn description(&self) -> String {
738		format!("{} (low priority)", self.inner.description())
739	}
740}
741
742/// High-priority environment variable configuration source for test overrides
743///
744/// This wrapper provides the same functionality as `EnvSource` but with higher priority
745/// than TOML files, allowing environment variables to override TOML configuration.
746/// Intended for integration tests where dynamic values (e.g., TestContainer ports)
747/// must override file-based settings.
748///
749/// Priority: 60 (higher than TOML files at 50, lower than `DotEnvSource` at 90)
750///
751/// # Examples
752///
753/// ```
754/// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
755/// use reinhardt_conf::settings::builder::SettingsBuilder;
756///
757/// let settings = SettingsBuilder::new()
758///     .add_source(HighPriorityEnvSource::new().with_prefix("REINHARDT_TEST_"))
759///     .build()
760///     .unwrap();
761/// ```
762pub struct HighPriorityEnvSource {
763	inner: EnvSource,
764}
765
766impl HighPriorityEnvSource {
767	/// Create a new high-priority environment variable configuration source
768	///
769	/// # Examples
770	///
771	/// ```
772	/// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
773	///
774	/// let source = HighPriorityEnvSource::new();
775	/// ```
776	pub fn new() -> Self {
777		Self {
778			inner: EnvSource::new(),
779		}
780	}
781
782	/// Set a prefix filter for environment variables
783	///
784	/// # Examples
785	///
786	/// ```
787	/// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
788	///
789	/// let source = HighPriorityEnvSource::new()
790	///     .with_prefix("REINHARDT_TEST_");
791	/// ```
792	pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
793		self.inner = self.inner.with_prefix(prefix);
794		self
795	}
796
797	/// Enable variable interpolation for environment values
798	///
799	/// # Examples
800	///
801	/// ```
802	/// use reinhardt_conf::settings::sources::HighPriorityEnvSource;
803	///
804	/// let source = HighPriorityEnvSource::new()
805	///     .with_interpolation(true);
806	/// ```
807	pub fn with_interpolation(mut self, enabled: bool) -> Self {
808		self.inner = self.inner.with_interpolation(enabled);
809		self
810	}
811}
812
813impl Default for HighPriorityEnvSource {
814	fn default() -> Self {
815		Self::new()
816	}
817}
818
819impl ConfigSource for HighPriorityEnvSource {
820	fn load(&self) -> Result<IndexMap<String, Value>, SourceError> {
821		self.inner.load()
822	}
823
824	fn priority(&self) -> u8 {
825		60 // Higher than TOML files (50), allowing env vars to override TOML config
826	}
827
828	fn description(&self) -> String {
829		format!("{} (high priority)", self.inner.description())
830	}
831}
832
833#[cfg(test)]
834mod tests {
835	use super::*;
836	use std::env;
837	use std::fs::File;
838	use std::io::Write;
839	use tempfile::TempDir;
840
841	#[test]
842	fn test_env_source() {
843		// SAFETY: Setting environment variables is unsafe in multi-threaded programs.
844		// This test uses #[serial] to ensure exclusive access to environment variables.
845		unsafe {
846			env::set_var("SECRET_KEY", "test-secret");
847			env::set_var("DEBUG", "true");
848		}
849
850		let source = EnvSource::new();
851		let config = source.load().unwrap();
852
853		assert_eq!(
854			config.get("secret_key").unwrap(),
855			&Value::String("test-secret".to_string())
856		);
857		assert_eq!(config.get("debug").unwrap(), &Value::Bool(true));
858
859		// SAFETY: Removing environment variables is unsafe in multi-threaded programs.
860		// This test uses #[serial] to ensure exclusive access to environment variables.
861		unsafe {
862			env::remove_var("SECRET_KEY");
863			env::remove_var("DEBUG");
864		}
865	}
866
867	#[test]
868	fn test_toml_source() {
869		let temp_dir = TempDir::new().unwrap();
870		let config_path = temp_dir.path().join("config.toml");
871
872		let mut file = File::create(&config_path).unwrap();
873		writeln!(
874			file,
875			r#"
876debug = true
877secret_key = "test-key"
878        "#
879		)
880		.unwrap();
881
882		let source = TomlFileSource::new(&config_path);
883		let config = source.load().unwrap();
884
885		assert_eq!(config.get("debug").unwrap(), &Value::Bool(true));
886		assert_eq!(
887			config.get("secret_key").unwrap(),
888			&Value::String("test-key".to_string())
889		);
890	}
891
892	// `JsonFileSource::new` is deprecated until removal in 0.2.0 (issue #4087);
893	// the test exercises load behavior so we still want to construct one here.
894	#[allow(deprecated)]
895	#[test]
896	fn test_json_source() {
897		let temp_dir = TempDir::new().unwrap();
898		let config_path = temp_dir.path().join("config.json");
899
900		let mut file = File::create(&config_path).unwrap();
901		writeln!(
902			file,
903			r#"{{
904            "debug": false,
905            "secret_key": "json-key"
906        }}"#
907		)
908		.unwrap();
909
910		let source = JsonFileSource::new(&config_path);
911		let config = source.load().unwrap();
912
913		assert_eq!(config.get("debug").unwrap(), &Value::Bool(false));
914		assert_eq!(
915			config.get("secret_key").unwrap(),
916			&Value::String("json-key".to_string())
917		);
918	}
919
920	#[test]
921	fn test_default_source() {
922		let source = DefaultSource::new()
923			.with_value("key1", Value::String("value1".to_string()))
924			.with_value("key2", Value::Bool(true));
925
926		let config = source.load().unwrap();
927
928		assert_eq!(
929			config.get("key1").unwrap(),
930			&Value::String("value1".to_string())
931		);
932		assert_eq!(config.get("key2").unwrap(), &Value::Bool(true));
933	}
934
935	#[test]
936	fn test_source_priority() {
937		assert_eq!(EnvSource::new().priority(), 100);
938		assert_eq!(DotEnvSource::new().priority(), 90);
939		assert_eq!(HighPriorityEnvSource::new().priority(), 60);
940		assert_eq!(TomlFileSource::new("test.toml").priority(), 50);
941		assert_eq!(LowPriorityEnvSource::new().priority(), 40);
942		assert_eq!(DefaultSource::new().priority(), 0);
943	}
944
945	#[test]
946	fn test_high_priority_env_source_wraps_env_source() {
947		// Arrange
948		let source = HighPriorityEnvSource::new();
949
950		// Act
951		let priority = source.priority();
952		let description = source.description();
953
954		// Assert
955		assert_eq!(priority, 60);
956		assert!(description.contains("high priority"));
957	}
958
959	#[test]
960	fn test_high_priority_env_source_with_prefix() {
961		// Arrange
962		let source = HighPriorityEnvSource::new().with_prefix("REINHARDT_TEST_");
963
964		// Act
965		let description = source.description();
966
967		// Assert
968		assert!(description.contains("REINHARDT_TEST_"));
969		assert!(description.contains("high priority"));
970	}
971
972	#[test]
973	fn toml_file_source_without_interpolation_preserves_literal() {
974		// Arrange — issue #4224: explicit opt-out keeps `${...}` verbatim.
975		let temp_dir = TempDir::new().unwrap();
976		let config_path = temp_dir.path().join("config.toml");
977		let mut file = File::create(&config_path).unwrap();
978		writeln!(file, r#"host = "${{LITERAL_VAR}}""#).unwrap();
979
980		// Act
981		let source = TomlFileSource::new(&config_path).without_interpolation();
982		let config = source.load().unwrap();
983
984		// Assert
985		assert_eq!(
986			config.get("host").unwrap(),
987			&Value::String("${LITERAL_VAR}".to_string())
988		);
989	}
990
991	#[test]
992	fn test_high_priority_env_source_overrides_toml() {
993		// Arrange
994		let temp_dir = TempDir::new().unwrap();
995		let config_path = temp_dir.path().join("config.toml");
996		let mut file = File::create(&config_path).unwrap();
997		writeln!(file, r#"port = 1025"#).unwrap();
998
999		let prefix = "HPENV_TEST_3518_";
1000		let env_key = format!("{prefix}PORT");
1001
1002		// SAFETY: Single-threaded test, no concurrent env access.
1003		unsafe { env::set_var(&env_key, "9999") };
1004
1005		// Act
1006		let settings = crate::settings::builder::SettingsBuilder::new()
1007			.add_source(TomlFileSource::new(&config_path))
1008			.add_source(HighPriorityEnvSource::new().with_prefix(prefix))
1009			.build()
1010			.unwrap();
1011
1012		// Assert — HighPriorityEnvSource (60) overrides TOML (50)
1013		let port: i64 = settings.get("port").unwrap();
1014		assert_eq!(port, 9999);
1015
1016		// Cleanup
1017		unsafe { env::remove_var(&env_key) };
1018	}
1019}