Skip to main content

forgeconf/
loader.rs

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
14/// A type that knows how to build its own [`Loader`].
15///
16/// Implemented by `#[derive(Config)]`, which re-exports under the same name:
17/// the derive adds the files declared with `#[config(file = ...)]` to
18/// [`Config::loader`].
19pub trait Config: FromNode {
20    /// A loader preloaded with the files declared on the type.
21    fn loader() -> Loader<Self>;
22}
23
24/// Merges configuration sources and converts the result into `T`.
25///
26/// Sources are merged in ascending priority order; a higher priority value
27/// overrides a lower one, and equal priorities keep insertion order, with the
28/// later source winning.
29///
30/// | Source                              | Priority |
31/// |-------------------------------------|----------|
32/// | declared or added files ([`ConfigFile`]) | 10  |
33/// | [`source`](Loader::source) default  | 100      |
34/// | environment variables, then dotenv  | 200      |
35/// | command line                        | 250      |
36///
37/// A null value never overrides a lower source, arrays replace instead of
38/// appending, and tables merge key by key.
39pub 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    /// A loader with no sources.
52    #[must_use]
53    pub fn new() -> Self {
54        Self {
55            sources: Vec::new(),
56            env_vars: None,
57            // No logging dependency: print like 0.5 did. Replace with
58            // `on_warning`.
59            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    /// Add a configuration file.
69    #[must_use]
70    pub fn file(self, file: ConfigFile) -> Self {
71        self.source(file)
72    }
73
74    /// Add any [`ConfigSource`]; its [`priority`](ConfigSource::priority)
75    /// decides the merge order.
76    #[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    /// Use `vars` instead of the process environment. Meant for tests, which
83    /// then never touch global state.
84    #[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    /// Replace the default warning handler (which prints `warning: ...` to
100    /// stderr). Called once per deprecated key present in the merged tree.
101    #[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    /// Load every source, merge them, report deprecations and convert.
108    ///
109    /// # Errors
110    /// The first source error, or the conversion errors of `T` (all of them
111    /// at once when several fields fail).
112    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        // Stable: equal priorities keep insertion order, later wins.
123        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    /// Read flags from the process arguments ([`std::env::args_os`]).
143    ///
144    /// Flags are derived from `T`: `--port`, `--server.max-conn`, `-p` with
145    /// `short`, `--verbose` / `--no-verbose` for booleans. Repeated list flags
146    /// append, `-h` / `--help` yields a [`Help`](crate::ErrorKind::Help) error
147    /// from [`load`](Loader::load). See [`load_or_exit`](Loader::load_or_exit).
148    #[must_use]
149    pub fn cli(self) -> Self {
150        self.cli_args(std::env::args_os())
151    }
152
153    /// Like [`cli`](Loader::cli) with explicit arguments. `args` is the full
154    /// argv, so the first item is the program name. Meant for tests.
155    #[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    /// [`load`](Loader::load), then exit the process on failure: `--help`
166    /// prints the help text to stdout and exits with status 0, any other
167    /// error prints `error: ...` to stderr and exits with status 2.
168    #[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
190/// Priority of the environment layer, dotenv values included.
191const ENV_PRIORITY: u8 = 200;
192
193impl<T: FromNode> Loader<T> {
194    /// Variables the schema maps to: the process environment (or the
195    /// `env_vars` map), then the dotenv file as a fallback.
196    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    /// Read variables from the dotenv file at `path` when loading.
218    ///
219    /// They fill in what the real environment (or [`env_vars`](Self::env_vars))
220    /// leaves unset and never override it. A missing file is ignored. The file
221    /// is read on every [`load`](Self::load), so reloads see edits.
222    #[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}