Skip to main content

dynamic_config/
discovery.rs

1//! Finding configuration files instead of being told where they are.
2//!
3//! A hardcoded `files = ["config.toml"]` resolves against the working
4//! directory, which is fine for a repository checkout and wrong for everything
5//! else: a package puts its configuration in `/etc`, a user overrides it in
6//! `~/.config`, and a developer overrides *that* in the current directory.
7//!
8//! ```text
9//! name  = "config"
10//! paths = ["/etc/myapp", "~/.config/myapp", "."]
11//!         →  /etc/myapp/config.toml
12//!            ~/.config/myapp/config.json
13//!            ./config.yaml
14//! ```
15//!
16//! Every directory that has a match contributes one, merged in the order the
17//! paths are listed — so the layering is the search order, and the last
18//! directory wins. This is where it differs from Go's Viper, which stops at the
19//! first hit: the whole reason to list `/etc` *and* `~` is to layer them.
20//!
21//! Within one directory the extensions are tried in a fixed order and the first
22//! hit is taken, so a stray `config.json` next to a `config.toml` is resolved
23//! the same way every run rather than by directory-listing order.
24
25#[cfg(feature = "watch")]
26use std::path::Path;
27use std::path::PathBuf;
28
29use crate::source::Format;
30
31/// Extensions tried in each directory, in order. Formats whose feature is off
32/// are skipped, so a build without `yaml` never picks up a `config.yaml` it
33/// could not parse.
34const EXTENSIONS: &[(&str, Format)] = &[
35    #[cfg(feature = "toml")]
36    ("toml", Format::Toml),
37    #[cfg(feature = "json")]
38    ("json", Format::Json),
39    #[cfg(feature = "yaml")]
40    ("yaml", Format::Yaml),
41    #[cfg(feature = "yaml")]
42    ("yml", Format::Yaml),
43];
44
45/// Where to look for configuration, and under what name.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Search<'a> {
48    /// The file name without an extension, e.g. `"config"`.
49    pub name: &'a str,
50    /// Directories to search, in increasing order of precedence.
51    pub paths: &'a [&'a str],
52}
53
54impl<'a> Search<'a> {
55    /// Looks for `{name}.{ext}` in each of `paths`.
56    #[must_use]
57    pub const fn new(name: &'a str, paths: &'a [&'a str]) -> Self {
58        Self { name, paths }
59    }
60
61    /// The files that exist right now, in merge order.
62    ///
63    /// Resolution happens per load rather than once, so a file that appears
64    /// later — a mounted secret, a freshly written override — is picked up by
65    /// the next reload instead of requiring a restart.
66    pub(crate) fn resolve(&self) -> Vec<(PathBuf, Format)> {
67        let mut found = Vec::new();
68
69        for directory in self.paths {
70            let Some(directory) = expand_home(directory) else {
71                continue;
72            };
73
74            for (extension, format) in EXTENSIONS {
75                let candidate = directory.join(format!("{}.{extension}", self.name));
76
77                if candidate.is_file() {
78                    found.push((candidate, *format));
79                    break;
80                }
81            }
82        }
83
84        found
85    }
86}
87
88/// Expands a leading `~` using `HOME`, or `USERPROFILE` on Windows.
89///
90/// A `~` that cannot be expanded drops the directory rather than searching a
91/// literal `./~`, which would be a confusing place to find configuration.
92fn expand_home(path: &str) -> Option<PathBuf> {
93    let Some(rest) = path.strip_prefix('~') else {
94        return Some(PathBuf::from(path));
95    };
96
97    let home = std::env::var_os("HOME")
98        .or_else(|| std::env::var_os("USERPROFILE"))
99        .map(PathBuf::from)?;
100
101    let rest = rest.trim_start_matches(['/', '\\']);
102
103    Some(if rest.is_empty() {
104        home
105    } else {
106        home.join(rest)
107    })
108}
109
110/// Whether `path` looks like a file this build could parse.
111///
112/// Used by the watcher, which has to decide what to watch before any file
113/// exists.
114#[cfg(feature = "watch")]
115pub(crate) fn is_candidate(path: &Path, name: &str) -> bool {
116    let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
117        return false;
118    };
119
120    if stem != name {
121        return false;
122    }
123
124    path.extension()
125        .and_then(|extension| extension.to_str())
126        .and_then(Format::from_extension)
127        .is_some()
128}
129
130/// Directories a watcher should listen to for this search.
131///
132/// Every listed directory, existing or not — a config file that appears later
133/// is exactly the event worth catching, and watching a directory that is absent
134/// simply fails and is reported.
135#[cfg(feature = "watch")]
136pub(crate) fn search_directories(search: &Search<'_>) -> Vec<PathBuf> {
137    search
138        .paths
139        .iter()
140        .filter_map(|path| expand_home(path))
141        .collect()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use std::fs;
148
149    fn scratch(name: &str) -> PathBuf {
150        let directory = std::env::temp_dir().join(format!("dynamic-config-discovery-{name}"));
151
152        let _ = fs::remove_dir_all(&directory);
153        fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
154
155        directory
156    }
157
158    #[test]
159    fn a_directory_with_no_match_contributes_nothing() {
160        let directory = scratch("empty");
161        let path = directory.to_string_lossy().into_owned();
162
163        assert!(Search::new("config", &[&path]).resolve().is_empty());
164    }
165
166    #[cfg(feature = "json")]
167    #[test]
168    fn a_matching_file_is_found_with_its_format() {
169        let directory = scratch("one");
170        fs::write(directory.join("config.json"), "{}").unwrap();
171
172        let path = directory.to_string_lossy().into_owned();
173        let found = Search::new("config", &[&path]).resolve();
174
175        assert_eq!(found.len(), 1);
176        assert_eq!(found[0].1, Format::Json);
177    }
178
179    #[cfg(all(feature = "json", feature = "toml"))]
180    #[test]
181    fn one_directory_contributes_at_most_one_file() {
182        let directory = scratch("ambiguous");
183        fs::write(directory.join("config.json"), "{}").unwrap();
184        fs::write(directory.join("config.toml"), "").unwrap();
185
186        let path = directory.to_string_lossy().into_owned();
187        let found = Search::new("config", &[&path]).resolve();
188
189        assert_eq!(
190            found.len(),
191            1,
192            "extension order decides, not the filesystem"
193        );
194        assert_eq!(
195            found[0].1,
196            Format::Toml,
197            "`toml` is tried before `json`, so the result is stable"
198        );
199    }
200
201    #[cfg(feature = "json")]
202    #[test]
203    fn directories_are_searched_in_the_order_given() {
204        let first = scratch("first");
205        let second = scratch("second");
206        fs::write(first.join("config.json"), "{}").unwrap();
207        fs::write(second.join("config.json"), "{}").unwrap();
208
209        let first_path = first.to_string_lossy().into_owned();
210        let second_path = second.to_string_lossy().into_owned();
211        let found = Search::new("config", &[&first_path, &second_path]).resolve();
212
213        assert_eq!(found.len(), 2, "each directory contributes its own layer");
214        assert_eq!(found[0].0.parent().unwrap(), first);
215        assert_eq!(found[1].0.parent().unwrap(), second);
216    }
217
218    #[test]
219    fn a_leading_tilde_expands_to_the_home_directory() {
220        let home = std::env::var_os("HOME")
221            .or_else(|| std::env::var_os("USERPROFILE"))
222            .map(PathBuf::from);
223
224        let Some(home) = home else {
225            return;
226        };
227
228        assert_eq!(expand_home("~"), Some(home.clone()));
229        assert_eq!(expand_home("~/.config/app"), Some(home.join(".config/app")));
230    }
231
232    #[test]
233    fn a_path_without_a_tilde_is_left_alone() {
234        assert_eq!(expand_home("/etc/app"), Some(PathBuf::from("/etc/app")));
235        assert_eq!(expand_home("."), Some(PathBuf::from(".")));
236    }
237
238    #[cfg(feature = "watch")]
239    #[test]
240    fn the_watcher_recognises_a_candidate_by_stem_and_extension() {
241        assert!(is_candidate(Path::new("/etc/app/config.json"), "config"));
242        assert!(is_candidate(Path::new("config.yml"), "config"));
243        assert!(!is_candidate(Path::new("/etc/app/other.json"), "config"));
244        assert!(!is_candidate(Path::new("/etc/app/config.ini"), "config"));
245        assert!(!is_candidate(Path::new("/etc/app/config"), "config"));
246    }
247}