1use std::collections::BTreeMap;
2use std::ffi::OsString;
3use std::marker::PhantomData;
4#[cfg(feature = "dotenv")]
5use std::path::{Path, PathBuf};
6
7use crate::__private::collect_deprecations;
8use crate::merge::merge;
9#[cfg(feature = "cli")]
10use crate::source::cli::cli_node;
11use crate::source::env::env_node;
12use crate::{ConfigError, ConfigFile, ConfigNode, ConfigSource, FromNode, Table, Warning};
13
14pub trait Config: FromNode {
20 fn loader() -> Loader<Self>;
22}
23
24pub struct Loader<T> {
40 pub(crate) sources: Vec<Box<dyn ConfigSource>>,
41 env_vars: Option<BTreeMap<String, String>>,
42 on_warning: Box<dyn Fn(&Warning) + Send + Sync>,
43 marker: PhantomData<fn() -> T>,
44 #[cfg(feature = "dotenv")]
45 dotenv: Option<PathBuf>,
46 #[cfg(feature = "cli")]
47 cli_args: Option<Vec<OsString>>,
48}
49
50impl<T: FromNode> Loader<T> {
51 #[must_use]
53 pub fn new() -> Self {
54 Self {
55 sources: Vec::new(),
56 env_vars: None,
57 on_warning: Box::new(|warning| eprintln!("warning: {warning}")),
60 marker: PhantomData,
61 #[cfg(feature = "dotenv")]
62 dotenv: None,
63 #[cfg(feature = "cli")]
64 cli_args: None,
65 }
66 }
67
68 #[must_use]
70 pub fn file(self, file: ConfigFile) -> Self {
71 self.source(file)
72 }
73
74 #[must_use]
77 pub fn source(mut self, source: impl ConfigSource + 'static) -> Self {
78 self.sources.push(Box::new(source));
79 self
80 }
81
82 #[must_use]
85 pub fn env_vars<I, K, V>(mut self, vars: I) -> Self
86 where
87 I: IntoIterator<Item = (K, V)>,
88 K: Into<String>,
89 V: Into<String>,
90 {
91 self.env_vars = Some(
92 vars.into_iter()
93 .map(|(key, value)| (key.into(), value.into()))
94 .collect(),
95 );
96 self
97 }
98
99 #[must_use]
102 pub fn on_warning(mut self, handler: impl Fn(&Warning) + Send + Sync + 'static) -> Self {
103 self.on_warning = Box::new(handler);
104 self
105 }
106
107 pub fn load(&self) -> Result<T, ConfigError> {
113 let mut layers = Vec::with_capacity(self.sources.len());
114 for source in &self.sources {
115 layers.push((source.priority(), source.load()?));
116 }
117 layers.push((ENV_PRIORITY, self.env_layer()?));
118 #[cfg(feature = "cli")]
119 if let Some(args) = &self.cli_args {
120 layers.push((250, cli_node(T::shape(), args)?));
121 }
122 layers.sort_by_key(|(priority, _)| *priority);
124
125 let mut merged = ConfigNode::table(Table::new());
126 for (_, node) in layers {
127 merge(&mut merged, node);
128 }
129
130 let mut warnings = Vec::new();
131 collect_deprecations(&merged, T::shape(), &mut warnings);
132 for warning in &warnings {
133 (self.on_warning)(warning);
134 }
135
136 T::from_node_owned(merged)
137 }
138}
139
140#[cfg(feature = "cli")]
141impl<T: FromNode> Loader<T> {
142 #[must_use]
149 pub fn cli(self) -> Self {
150 self.cli_args(std::env::args_os())
151 }
152
153 #[must_use]
156 pub fn cli_args<I>(mut self, args: I) -> Self
157 where
158 I: IntoIterator,
159 I::Item: Into<OsString>,
160 {
161 self.cli_args = Some(args.into_iter().map(Into::into).collect());
162 self
163 }
164
165 #[must_use]
169 pub fn load_or_exit(&self) -> T {
170 match self.load() {
171 Ok(value) => value,
172 Err(err) if err.is_help() => {
173 println!("{err}");
174 std::process::exit(0);
175 },
176 Err(err) => {
177 eprintln!("error: {err}");
178 std::process::exit(2);
179 },
180 }
181 }
182}
183
184impl<T: FromNode> Default for Loader<T> {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190const ENV_PRIORITY: u8 = 200;
192
193impl<T: FromNode> Loader<T> {
194 fn env_layer(&self) -> Result<ConfigNode, ConfigError> {
197 #[cfg(feature = "dotenv")]
198 let fallback = match &self.dotenv {
199 Some(path) => crate::source::dotenv::load(path)?,
200 None => BTreeMap::new(),
201 };
202 #[cfg(not(feature = "dotenv"))]
203 let fallback = BTreeMap::<String, String>::new();
204
205 env_node(T::shape(), &|name| {
206 let primary = match &self.env_vars {
207 Some(vars) => vars.get(name).cloned().map(Ok),
208 None => std::env::var_os(name).map(OsString::into_string),
209 };
210 primary.or_else(|| fallback.get(name).cloned().map(Ok))
211 })
212 }
213}
214
215#[cfg(feature = "dotenv")]
216impl<T: FromNode> Loader<T> {
217 #[must_use]
223 pub fn dotenv(mut self, path: impl Into<PathBuf>) -> Self {
224 self.dotenv = Some(path.into());
225 self
226 }
227
228 #[cfg_attr(not(feature = "watch"), allow(dead_code, reason = "read by Loader::watch"))]
229 pub(crate) fn dotenv_path(&self) -> Option<&Path> {
230 self.dotenv.as_deref()
231 }
232}
233
234#[cfg(test)]
235mod test {
236 #![allow(clippy::unwrap_used, reason = "tests assert on known-good fixtures")]
237
238 use std::sync::{Arc, Mutex};
239
240 use super::*;
241 use crate::{__private as private, FieldInfo, Origin, Shape, StructInfo};
242
243 struct Static {
244 priority: u8,
245 node: ConfigNode,
246 }
247
248 impl Static {
249 fn new(priority: u8, key: &str, value: &str) -> Self {
250 let mut table = Table::new();
251 table.insert(key.into(), ConfigNode::scalar(value));
252 Self { priority, node: ConfigNode::table(table) }
253 }
254 }
255
256 impl ConfigSource for Static {
257 fn load(&self) -> Result<ConfigNode, ConfigError> {
258 Ok(self.node.clone())
259 }
260
261 fn priority(&self) -> u8 {
262 self.priority
263 }
264 }
265
266 struct Failing;
267
268 impl ConfigSource for Failing {
269 fn load(&self) -> Result<ConfigNode, ConfigError> {
270 Err(ConfigError::invalid("boom"))
271 }
272 }
273
274 #[derive(Debug, PartialEq)]
275 struct Probe {
276 name: String,
277 legacy: Option<String>,
278 }
279
280 static PROBE: StructInfo = StructInfo::new(
281 "Probe",
282 &[
283 FieldInfo::new("name", <String as FromNode>::shape),
284 FieldInfo::new("legacy", <Option<String> as FromNode>::shape)
285 .aliases(&["old"])
286 .deprecated("use name"),
287 ],
288 );
289
290 impl FromNode for Probe {
291 fn from_node(node: &ConfigNode) -> Result<Self, ConfigError> {
292 let table = private::table(node, "Probe")?;
293 let mut errors = private::Collector::new();
294 let name = errors.take(private::field(node, table, &["name"], false, None));
295 let legacy = errors.take(private::field(node, table, &["legacy", "old"], false, None));
296 if let (Some(name), Some(legacy)) = (name, legacy) {
297 errors.finish(Self { name, legacy })
298 } else {
299 Err(errors.into_error())
300 }
301 }
302
303 fn shape() -> Shape {
304 Shape::Struct(&PROBE)
305 }
306 }
307
308 fn name_of(loader: &Loader<ConfigNode>) -> String {
309 let node = loader.load().unwrap();
310 node.get("k")
311 .and_then(ConfigNode::as_str)
312 .unwrap()
313 .to_owned()
314 }
315
316 #[test]
317 fn higher_priority_wins_regardless_of_insertion_order() {
318 let loader = Loader::<ConfigNode>::new()
319 .source(Static::new(200, "k", "high"))
320 .source(Static::new(5, "k", "low"));
321 assert_eq!(name_of(&loader), "high");
322 }
323
324 #[test]
325 fn equal_priority_later_insertion_wins() {
326 let loader = Loader::<ConfigNode>::default()
327 .source(Static::new(100, "k", "first"))
328 .source(Static::new(100, "k", "second"));
329 assert_eq!(name_of(&loader), "second");
330 }
331
332 #[test]
333 fn default_source_priority_is_above_files() {
334 let loader = Loader::<ConfigNode>::new()
335 .source(Static::new(100, "k", "source"))
336 .file(ConfigFile::new("/definitely/not/here.toml").optional());
337 assert_eq!(name_of(&loader), "source");
338 }
339
340 #[test]
341 fn null_source_keeps_lower_values() {
342 let loader = Loader::<ConfigNode>::new()
343 .source(Static::new(5, "k", "base"))
344 .source(Static { priority: 200, node: ConfigNode::null() });
345 assert_eq!(name_of(&loader), "base");
346 }
347
348 #[test]
349 fn source_error_aborts_load() {
350 let err = Loader::<ConfigNode>::new()
351 .source(Static::new(5, "k", "base"))
352 .source(Failing)
353 .load()
354 .unwrap_err();
355 assert_eq!(err.to_string(), "boom");
356 }
357
358 #[test]
359 fn empty_loader_yields_empty_table() {
360 let node = Loader::<ConfigNode>::new().load().unwrap();
361 assert_eq!(node, ConfigNode::table(Table::new()));
362 }
363
364 #[test]
365 fn conversion_errors_are_returned() {
366 let err = Loader::<Probe>::new().load().unwrap_err();
367 assert_eq!(err.to_string(), "name: missing value");
368 }
369
370 #[test]
371 fn warning_handler_runs_once_per_deprecated_key() {
372 let seen = Arc::new(Mutex::new(Vec::new()));
373 let sink = Arc::clone(&seen);
374 let mut table = Table::new();
375 table.insert("name".into(), ConfigNode::scalar("x"));
376 table.insert(
377 "old".into(),
378 ConfigNode::scalar("y").with_origin(Origin::new("app.toml", 2, 7)),
379 );
380 let probe = Loader::<Probe>::new()
381 .source(Static {
382 priority: 100,
383 node: ConfigNode::table(table),
384 })
385 .on_warning(move |warning| {
386 if let Ok(mut seen) = sink.lock() {
387 seen.push(warning.to_string());
388 }
389 })
390 .load()
391 .unwrap();
392
393 assert_eq!(
394 probe,
395 Probe {
396 name: "x".into(),
397 legacy: Some("y".into())
398 }
399 );
400 let seen = seen.lock().unwrap();
401 assert_eq!(*seen, ["old: use name (app.toml:2:7)"]);
402 }
403
404 #[test]
405 fn no_warning_for_absent_deprecated_key() {
406 let seen = Arc::new(Mutex::new(0usize));
407 let sink = Arc::clone(&seen);
408 Loader::<Probe>::new()
409 .source(Static::new(100, "name", "x"))
410 .on_warning(move |_| {
411 if let Ok(mut count) = sink.lock() {
412 *count += 1;
413 }
414 })
415 .load()
416 .unwrap();
417 assert_eq!(*seen.lock().unwrap(), 0);
418 }
419
420 #[test]
421 fn env_vars_accepts_pairs() {
422 let loader = Loader::<ConfigNode>::new().env_vars([("A", "1"), ("B", "2")]);
423 assert_eq!(loader.env_vars.as_ref().map(BTreeMap::len), Some(2));
424 }
425}