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