dynamic_config/
discovery.rs1#[cfg(feature = "watch")]
27use std::path::Path;
28use std::path::PathBuf;
29
30use crate::source::Format;
31
32const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Search<'a> {
53 pub name: &'a str,
55 pub paths: &'a [&'a str],
57}
58
59impl<'a> Search<'a> {
60 #[must_use]
62 pub const fn new(name: &'a str, paths: &'a [&'a str]) -> Self {
63 Self { name, paths }
64 }
65
66 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
93fn 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#[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#[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}