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