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];
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Search<'a> {
49 pub name: &'a str,
51 pub paths: &'a [&'a str],
53}
54
55impl<'a> Search<'a> {
56 #[must_use]
58 pub const fn new(name: &'a str, paths: &'a [&'a str]) -> Self {
59 Self { name, paths }
60 }
61
62 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
89fn 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#[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#[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}