#[cfg(feature = "watch")]
use std::path::Path;
use std::path::PathBuf;
use crate::source::Format;
const EXTENSIONS: &[(&str, Format)] = &[
#[cfg(feature = "toml")]
("toml", Format::Toml),
#[cfg(feature = "json")]
("json", Format::Json),
#[cfg(feature = "yaml")]
("yaml", Format::Yaml),
#[cfg(feature = "yaml")]
("yml", Format::Yaml),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Search<'a> {
pub name: &'a str,
pub paths: &'a [&'a str],
}
impl<'a> Search<'a> {
#[must_use]
pub const fn new(name: &'a str, paths: &'a [&'a str]) -> Self {
Self { name, paths }
}
pub(crate) fn resolve(&self) -> Vec<(PathBuf, Format)> {
let mut found = Vec::new();
for directory in self.paths {
let Some(directory) = expand_home(directory) else {
continue;
};
for (extension, format) in EXTENSIONS {
let candidate = directory.join(format!("{}.{extension}", self.name));
if candidate.is_file() {
found.push((candidate, *format));
break;
}
}
}
found
}
}
fn expand_home(path: &str) -> Option<PathBuf> {
let Some(rest) = path.strip_prefix('~') else {
return Some(PathBuf::from(path));
};
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)?;
let rest = rest.trim_start_matches(['/', '\\']);
Some(if rest.is_empty() {
home
} else {
home.join(rest)
})
}
#[cfg(feature = "watch")]
pub(crate) fn is_candidate(path: &Path, name: &str) -> bool {
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return false;
};
if stem != name {
return false;
}
path.extension()
.and_then(|extension| extension.to_str())
.and_then(Format::from_extension)
.is_some()
}
#[cfg(feature = "watch")]
pub(crate) fn search_directories(search: &Search<'_>) -> Vec<PathBuf> {
search
.paths
.iter()
.filter_map(|path| expand_home(path))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn scratch(name: &str) -> PathBuf {
let directory = std::env::temp_dir().join(format!("dynamic-config-discovery-{name}"));
let _ = fs::remove_dir_all(&directory);
fs::create_dir_all(&directory).expect("the scratch directory should be creatable");
directory
}
#[test]
fn a_directory_with_no_match_contributes_nothing() {
let directory = scratch("empty");
let path = directory.to_string_lossy().into_owned();
assert!(Search::new("config", &[&path]).resolve().is_empty());
}
#[cfg(feature = "json")]
#[test]
fn a_matching_file_is_found_with_its_format() {
let directory = scratch("one");
fs::write(directory.join("config.json"), "{}").unwrap();
let path = directory.to_string_lossy().into_owned();
let found = Search::new("config", &[&path]).resolve();
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, Format::Json);
}
#[cfg(all(feature = "json", feature = "toml"))]
#[test]
fn one_directory_contributes_at_most_one_file() {
let directory = scratch("ambiguous");
fs::write(directory.join("config.json"), "{}").unwrap();
fs::write(directory.join("config.toml"), "").unwrap();
let path = directory.to_string_lossy().into_owned();
let found = Search::new("config", &[&path]).resolve();
assert_eq!(
found.len(),
1,
"extension order decides, not the filesystem"
);
assert_eq!(
found[0].1,
Format::Toml,
"`toml` is tried before `json`, so the result is stable"
);
}
#[cfg(feature = "json")]
#[test]
fn directories_are_searched_in_the_order_given() {
let first = scratch("first");
let second = scratch("second");
fs::write(first.join("config.json"), "{}").unwrap();
fs::write(second.join("config.json"), "{}").unwrap();
let first_path = first.to_string_lossy().into_owned();
let second_path = second.to_string_lossy().into_owned();
let found = Search::new("config", &[&first_path, &second_path]).resolve();
assert_eq!(found.len(), 2, "each directory contributes its own layer");
assert_eq!(found[0].0.parent().unwrap(), first);
assert_eq!(found[1].0.parent().unwrap(), second);
}
#[test]
fn a_leading_tilde_expands_to_the_home_directory() {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from);
let Some(home) = home else {
return;
};
assert_eq!(expand_home("~"), Some(home.clone()));
assert_eq!(expand_home("~/.config/app"), Some(home.join(".config/app")));
}
#[test]
fn a_path_without_a_tilde_is_left_alone() {
assert_eq!(expand_home("/etc/app"), Some(PathBuf::from("/etc/app")));
assert_eq!(expand_home("."), Some(PathBuf::from(".")));
}
#[cfg(feature = "watch")]
#[test]
fn the_watcher_recognises_a_candidate_by_stem_and_extension() {
assert!(is_candidate(Path::new("/etc/app/config.json"), "config"));
assert!(is_candidate(Path::new("config.yml"), "config"));
assert!(!is_candidate(Path::new("/etc/app/other.json"), "config"));
assert!(!is_candidate(Path::new("/etc/app/config.ini"), "config"));
assert!(!is_candidate(Path::new("/etc/app/config"), "config"));
}
}