use std::path::PathBuf;
use crate::error::ClapfigError;
use crate::types::{Boundary, SearchMode, SearchPath};
pub fn resolve_search_path(sp: &SearchPath, app_name: &str) -> Option<PathBuf> {
match sp {
SearchPath::Platform => {
let proj = directories::ProjectDirs::from("", "", app_name)?;
Some(proj.config_dir().to_path_buf())
}
SearchPath::Home(subdir) => {
let user = directories::UserDirs::new()?;
Some(user.home_dir().join(subdir))
}
SearchPath::Cwd => std::env::current_dir().ok(),
SearchPath::Path(p) => Some(p.clone()),
SearchPath::Ancestors(_) => {
panic!("resolve_search_path called with Ancestors — use expand_ancestors instead")
}
}
}
pub fn expand_ancestors(boundary: &Boundary) -> Vec<PathBuf> {
let Ok(cwd) = std::env::current_dir() else {
return vec![];
};
expand_ancestors_from(cwd, boundary)
}
pub fn expand_ancestors_from(start: PathBuf, boundary: &Boundary) -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut current = start.as_path();
loop {
dirs.push(current.to_path_buf());
if let Boundary::Marker(name) = boundary
&& current.join(name).exists()
{
break;
}
match current.parent() {
Some(parent) => current = parent,
None => break, }
}
dirs.reverse();
dirs
}
pub fn expand_search_paths(search_paths: &[SearchPath], app_name: &str) -> Vec<PathBuf> {
expand_search_paths_from(search_paths, app_name, None)
}
pub fn expand_search_paths_from(
search_paths: &[SearchPath],
app_name: &str,
ancestors_start: Option<&std::path::Path>,
) -> Vec<PathBuf> {
let mut dirs = Vec::new();
for sp in search_paths {
match sp {
SearchPath::Ancestors(boundary) => {
let expanded = match ancestors_start {
Some(start) => expand_ancestors_from(start.to_path_buf(), boundary),
None => expand_ancestors(boundary),
};
dirs.extend(expanded);
}
other => {
if let Some(dir) = resolve_search_path(other, app_name) {
dirs.push(dir);
}
}
}
}
dirs
}
pub fn load_config_files(
search_paths: &[SearchPath],
file_name: &str,
app_name: &str,
mode: SearchMode,
) -> Result<Vec<(PathBuf, String)>, ClapfigError> {
let dirs = expand_search_paths(search_paths, app_name);
match mode {
SearchMode::Merge => load_all(&dirs, file_name),
SearchMode::FirstMatch => load_first_match(&dirs, file_name),
}
}
fn load_all(dirs: &[PathBuf], file_name: &str) -> Result<Vec<(PathBuf, String)>, ClapfigError> {
let mut results = Vec::new();
for dir in dirs {
let file_path = dir.join(file_name);
match std::fs::read_to_string(&file_path) {
Ok(content) => results.push((file_path, content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(ClapfigError::IoError {
path: file_path,
source: e,
});
}
}
}
Ok(results)
}
fn load_first_match(
dirs: &[PathBuf],
file_name: &str,
) -> Result<Vec<(PathBuf, String)>, ClapfigError> {
for dir in dirs.iter().rev() {
let file_path = dir.join(file_name);
match std::fs::read_to_string(&file_path) {
Ok(content) => return Ok(vec![(file_path, content)]),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(ClapfigError::IoError {
path: file_path,
source: e,
});
}
}
}
Ok(vec![])
}
pub fn resolve_persist_path(
persist: &SearchPath,
file_name: &str,
app_name: &str,
) -> Result<PathBuf, ClapfigError> {
match persist {
SearchPath::Ancestors(_) => Err(ClapfigError::AncestorsNotAllowedAsPersistPath),
other => resolve_search_path(other, app_name)
.map(|dir| dir.join(file_name))
.ok_or(ClapfigError::NoPersistPath),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn resolve_explicit_path() {
let p = PathBuf::from("/tmp/myapp");
let resolved = resolve_search_path(&SearchPath::Path(p.clone()), "ignored");
assert_eq!(resolved, Some(p));
}
#[test]
fn load_no_files_exist() {
let dir = TempDir::new().unwrap();
let paths = vec![SearchPath::Path(dir.path().to_path_buf())];
let files =
load_config_files(&paths, "nonexistent.toml", "test", SearchMode::Merge).unwrap();
assert!(files.is_empty());
}
#[test]
fn load_one_file_exists() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("app.toml"), "port = 3000\n").unwrap();
let paths = vec![SearchPath::Path(dir.path().to_path_buf())];
let files = load_config_files(&paths, "app.toml", "test", SearchMode::Merge).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].1, "port = 3000\n");
}
#[test]
fn load_multiple_files() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(dir1.path().join("app.toml"), "host = \"a\"\n").unwrap();
fs::write(dir2.path().join("app.toml"), "port = 1000\n").unwrap();
let paths = vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()),
];
let files = load_config_files(&paths, "app.toml", "test", SearchMode::Merge).unwrap();
assert_eq!(files.len(), 2);
assert!(files[0].1.contains("host"));
assert!(files[1].1.contains("port"));
}
#[test]
fn missing_file_silently_skipped() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(dir2.path().join("app.toml"), "port = 1\n").unwrap();
let paths = vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()),
];
let files = load_config_files(&paths, "app.toml", "test", SearchMode::Merge).unwrap();
assert_eq!(files.len(), 1);
}
#[cfg(unix)]
#[test]
fn unreadable_file_returns_io_error() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("app.toml");
fs::write(&file_path, "port = 1\n").unwrap();
fs::set_permissions(&file_path, fs::Permissions::from_mode(0o000)).unwrap();
let paths = vec![SearchPath::Path(dir.path().to_path_buf())];
let result = load_config_files(&paths, "app.toml", "test", SearchMode::Merge);
assert!(result.is_err());
fs::set_permissions(&file_path, fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn first_match_returns_highest_priority() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(dir1.path().join("app.toml"), "host = \"low\"\n").unwrap();
fs::write(dir2.path().join("app.toml"), "host = \"high\"\n").unwrap();
let paths = vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()), ];
let files = load_config_files(&paths, "app.toml", "test", SearchMode::FirstMatch).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].1.contains("high"));
}
#[test]
fn first_match_falls_back_to_lower_priority() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(dir1.path().join("app.toml"), "host = \"fallback\"\n").unwrap();
let paths = vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()),
];
let files = load_config_files(&paths, "app.toml", "test", SearchMode::FirstMatch).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].1.contains("fallback"));
}
#[test]
fn first_match_returns_empty_when_no_files() {
let dir = TempDir::new().unwrap();
let paths = vec![SearchPath::Path(dir.path().to_path_buf())];
let files =
load_config_files(&paths, "nonexistent.toml", "test", SearchMode::FirstMatch).unwrap();
assert!(files.is_empty());
}
#[test]
fn expand_ancestors_root_includes_cwd() {
let dirs = expand_ancestors(&Boundary::Root);
assert!(!dirs.is_empty());
let cwd = std::env::current_dir().unwrap();
assert_eq!(dirs.last().unwrap(), &cwd);
}
#[test]
fn expand_ancestors_root_is_shallowest_first() {
let dirs = expand_ancestors(&Boundary::Root);
assert!(dirs.len() >= 2);
for pair in dirs.windows(2) {
assert!(
pair[1].starts_with(&pair[0]),
"{:?} should start with {:?}",
pair[1],
pair[0]
);
}
}
#[test]
fn expand_ancestors_marker_stops_at_marker() {
let dir = TempDir::new().unwrap();
let deep = dir.path().join("a").join("b").join("c");
fs::create_dir_all(&deep).unwrap();
fs::create_dir(dir.path().join("a").join(".git")).unwrap();
let dirs = expand_ancestors_from(deep.clone(), &Boundary::Marker(".git"));
assert!(dirs.contains(&dir.path().join("a")));
assert!(dirs.contains(&dir.path().join("a").join("b")));
assert!(dirs.contains(&dir.path().join("a").join("b").join("c")));
assert!(!dirs.contains(&dir.path().to_path_buf()));
}
#[test]
fn expand_ancestors_marker_missing_walks_to_root() {
let dir = TempDir::new().unwrap();
let deep = dir.path().join("x").join("y");
fs::create_dir_all(&deep).unwrap();
let dirs = expand_ancestors_from(deep.clone(), &Boundary::Marker(".nonexistent"));
assert!(dirs.contains(&dir.path().to_path_buf()));
assert!(dirs.contains(&deep));
}
#[test]
fn expand_search_paths_mixes_single_and_ancestors() {
let dir = TempDir::new().unwrap();
let deep = dir.path().join("a").join("b");
fs::create_dir_all(&deep).unwrap();
fs::create_dir(dir.path().join("a").join(".marker")).unwrap();
let explicit = TempDir::new().unwrap();
let paths = vec![
SearchPath::Path(explicit.path().to_path_buf()),
SearchPath::Ancestors(Boundary::Marker(".marker")),
];
let dirs = expand_search_paths_from(&paths, "test", Some(&deep));
assert_eq!(dirs[0], explicit.path().to_path_buf());
assert!(dirs.contains(&dir.path().join("a")));
assert!(dirs.contains(&dir.path().join("a").join("b")));
let pos_a = dirs
.iter()
.position(|d| d == &dir.path().join("a"))
.unwrap();
let pos_ab = dirs
.iter()
.position(|d| d == &dir.path().join("a").join("b"))
.unwrap();
assert!(pos_ab > pos_a);
}
#[test]
fn persist_path_explicit() {
let p = PathBuf::from("/tmp/configs");
let result = resolve_persist_path(&SearchPath::Path(p.clone()), "app.toml", "test");
assert_eq!(result.unwrap(), p.join("app.toml"));
}
#[test]
fn persist_path_rejects_ancestors() {
let result =
resolve_persist_path(&SearchPath::Ancestors(Boundary::Root), "app.toml", "test");
assert!(matches!(
result,
Err(ClapfigError::AncestorsNotAllowedAsPersistPath)
));
}
#[test]
fn ancestors_first_match_finds_nearest() {
let root = TempDir::new().unwrap();
let mid = root.path().join("mid");
let deep = mid.join("deep");
fs::create_dir_all(&deep).unwrap();
fs::write(mid.join("app.toml"), "host = \"mid\"\n").unwrap();
fs::write(root.path().join("app.toml"), "host = \"root\"\n").unwrap();
let dirs = vec![root.path().to_path_buf(), mid.clone(), deep.clone()];
let files = load_first_match(&dirs, "app.toml").unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].1.contains("mid"));
}
#[test]
fn ancestors_merge_layers_all() {
let root = TempDir::new().unwrap();
let mid = root.path().join("mid");
let deep = mid.join("deep");
fs::create_dir_all(&deep).unwrap();
fs::write(root.path().join("app.toml"), "host = \"root\"\n").unwrap();
fs::write(mid.join("app.toml"), "port = 9000\n").unwrap();
let dirs = vec![root.path().to_path_buf(), mid.clone(), deep.clone()];
let files = load_all(&dirs, "app.toml").unwrap();
assert_eq!(files.len(), 2);
assert!(files[0].1.contains("root")); assert!(files[1].1.contains("9000")); }
}