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