use std::collections::HashMap;
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use confique::Config;
use serde::Deserialize;
use crate::builder::PostValidateHook;
use crate::error::ClapfigError;
use crate::file;
use crate::resolve::{self, ResolveInput};
use crate::types::{Layer, SearchMode, SearchPath};
#[derive(Clone)]
struct CachedFile {
contents: String,
}
pub struct Resolver<C: Config> {
app_name: String,
file_name: String,
search_paths: Vec<SearchPath>,
search_mode: SearchMode,
env_prefix: Option<String>,
env_vars: Vec<(String, String)>,
strict: bool,
#[cfg(feature = "url")]
url_overrides: Vec<(String, toml::Value)>,
cli_overrides: Vec<(String, toml::Value)>,
layer_order: Option<Vec<Layer>>,
post_validate: Option<Arc<PostValidateHook<C>>>,
file_cache: Mutex<HashMap<PathBuf, CachedFile>>,
_phantom: PhantomData<fn() -> C>,
}
impl<C: Config> Resolver<C> {
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_builder(
app_name: String,
file_name: String,
search_paths: Vec<SearchPath>,
search_mode: SearchMode,
env_prefix: Option<String>,
strict: bool,
#[cfg(feature = "url")] url_overrides: Vec<(String, toml::Value)>,
cli_overrides: Vec<(String, toml::Value)>,
layer_order: Option<Vec<Layer>>,
post_validate: Option<PostValidateHook<C>>,
) -> Self {
let env_vars = if env_prefix.is_some() {
std::env::vars().collect()
} else {
Vec::new()
};
Self {
app_name,
file_name,
search_paths,
search_mode,
env_prefix,
env_vars,
strict,
#[cfg(feature = "url")]
url_overrides,
cli_overrides,
layer_order,
post_validate: post_validate.map(Arc::new),
file_cache: Mutex::new(HashMap::new()),
_phantom: PhantomData,
}
}
pub fn resolve_at(&self, start_dir: impl AsRef<Path>) -> Result<C, ClapfigError>
where
C::Layer: for<'de> Deserialize<'de>,
{
let start_dir = start_dir.as_ref();
let absolute = if start_dir.is_absolute() {
start_dir.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(start_dir),
Err(e) => {
return Err(ClapfigError::IoError {
path: start_dir.to_path_buf(),
source: e,
});
}
}
};
let normalized = std::fs::canonicalize(&absolute).unwrap_or(absolute);
let dirs = file::expand_search_paths(&self.search_paths, &self.app_name, &normalized);
let files = self.load_files_cached(&dirs)?;
let input = ResolveInput {
files,
env_vars: self.env_vars.clone(),
env_prefix: self.env_prefix.clone(),
#[cfg(feature = "url")]
url_overrides: self.url_overrides.clone(),
cli_overrides: self.cli_overrides.clone(),
strict: self.strict,
layer_order: self.layer_order.clone(),
};
let config = resolve::resolve::<C>(input)?;
if let Some(hook) = self.post_validate.as_ref() {
hook(&config).map_err(ClapfigError::PostValidationFailed)?;
}
Ok(config)
}
fn load_files_cached(&self, dirs: &[PathBuf]) -> Result<Vec<(PathBuf, String)>, ClapfigError> {
match self.search_mode {
SearchMode::Merge => {
let mut out = Vec::new();
for dir in dirs {
let path = dir.join(&self.file_name);
if let Some(contents) = self.read_cached(&path)? {
out.push((path, contents));
}
}
Ok(out)
}
SearchMode::FirstMatch => {
for dir in dirs.iter().rev() {
let path = dir.join(&self.file_name);
if let Some(contents) = self.read_cached(&path)? {
return Ok(vec![(path, contents)]);
}
}
Ok(Vec::new())
}
}
}
fn read_cached(&self, path: &Path) -> Result<Option<String>, ClapfigError> {
{
let cache = self.file_cache.lock().expect("file_cache mutex poisoned");
if let Some(cached) = cache.get(path) {
return Ok(Some(cached.contents.clone()));
}
}
match std::fs::read_to_string(path) {
Ok(contents) => {
let mut cache = self.file_cache.lock().expect("file_cache mutex poisoned");
cache.insert(
path.to_path_buf(),
CachedFile {
contents: contents.clone(),
},
);
Ok(Some(contents))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ClapfigError::IoError {
path: path.to_path_buf(),
source: e,
}),
}
}
#[doc(hidden)]
pub fn cache_size(&self) -> usize {
self.file_cache
.lock()
.expect("file_cache mutex poisoned")
.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Clapfig;
use crate::fixtures::test::TestConfig;
use crate::types::{Boundary, SearchMode};
use std::fs;
use tempfile::TempDir;
fn resolver_with_paths(paths: Vec<SearchPath>) -> Resolver<TestConfig> {
Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(paths)
.no_env()
.build_resolver()
.unwrap()
}
#[test]
fn resolve_at_reads_file_under_cwd_start_dir() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Cwd]);
let config = resolver.resolve_at(dir.path()).unwrap();
assert_eq!(config.port, 3000);
}
#[test]
fn resolve_at_different_dirs_produce_different_configs() {
let a = TempDir::new().unwrap();
let b = TempDir::new().unwrap();
fs::write(a.path().join("test.toml"), "port = 1111\n").unwrap();
fs::write(b.path().join("test.toml"), "port = 2222\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Cwd]);
let config_a = resolver.resolve_at(a.path()).unwrap();
let config_b = resolver.resolve_at(b.path()).unwrap();
assert_eq!(config_a.port, 1111);
assert_eq!(config_b.port, 2222);
}
#[test]
fn resolve_at_respects_defaults_when_no_file() {
let dir = TempDir::new().unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Cwd]);
let config = resolver.resolve_at(dir.path()).unwrap();
assert_eq!(config.port, 8080); assert_eq!(config.host, "localhost");
}
#[test]
fn resolve_at_ancestors_walks_up_from_start_dir() {
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("test.toml"), "host = \"rootish\"\n").unwrap();
fs::write(mid.join("test.toml"), "port = 5555\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Ancestors(Boundary::Root)]);
let config = resolver.resolve_at(&deep).unwrap();
assert_eq!(config.host, "rootish");
assert_eq!(config.port, 5555);
}
#[test]
fn resolve_at_ancestors_marker_stops_at_marker() {
let root = TempDir::new().unwrap();
let project = root.path().join("project");
let leaf = project.join("sub").join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::create_dir(project.join(".git")).unwrap();
fs::write(root.path().join("test.toml"), "port = 9999\n").unwrap();
fs::write(project.join("test.toml"), "port = 4444\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Ancestors(Boundary::Marker(".git"))]);
let config = resolver.resolve_at(&leaf).unwrap();
assert_eq!(config.port, 4444, "should find project/test.toml");
}
#[test]
fn resolve_at_ancestors_each_leaf_independent() {
let root = TempDir::new().unwrap();
let a_leaf = root.path().join("a").join("leaf");
let b_leaf = root.path().join("b").join("leaf");
fs::create_dir_all(&a_leaf).unwrap();
fs::create_dir_all(&b_leaf).unwrap();
fs::write(root.path().join("test.toml"), "host = \"shared\"\n").unwrap();
fs::write(root.path().join("a").join("test.toml"), "port = 100\n").unwrap();
fs::write(root.path().join("b").join("test.toml"), "port = 200\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Ancestors(Boundary::Root)]);
let config_a = resolver.resolve_at(&a_leaf).unwrap();
let config_b = resolver.resolve_at(&b_leaf).unwrap();
assert_eq!(config_a.host, "shared");
assert_eq!(config_b.host, "shared");
assert_eq!(config_a.port, 100);
assert_eq!(config_b.port, 200);
}
#[test]
fn cache_populates_on_first_read() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Cwd]);
assert_eq!(resolver.cache_size(), 0);
resolver.resolve_at(dir.path()).unwrap();
assert_eq!(resolver.cache_size(), 1);
}
#[test]
fn cache_hit_on_second_read_of_same_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.toml");
fs::write(&path, "port = 3000\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Cwd]);
let config1 = resolver.resolve_at(dir.path()).unwrap();
assert_eq!(config1.port, 3000);
assert_eq!(resolver.cache_size(), 1);
fs::write(&path, "port = 9999\n").unwrap();
let config2 = resolver.resolve_at(dir.path()).unwrap();
assert_eq!(
config2.port, 3000,
"cache should mask on-disk changes — build a new Resolver for freshness"
);
assert_eq!(resolver.cache_size(), 1, "no new cache entry");
}
#[test]
fn cache_shared_ancestor_across_leaves_reads_once() {
let root = TempDir::new().unwrap();
let a_leaf = root.path().join("a");
let b_leaf = root.path().join("b");
fs::create_dir_all(&a_leaf).unwrap();
fs::create_dir_all(&b_leaf).unwrap();
fs::write(root.path().join("test.toml"), "port = 7777\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Ancestors(Boundary::Root)]);
let _ = resolver.resolve_at(&a_leaf).unwrap();
let cache_after_a = resolver.cache_size();
let _ = resolver.resolve_at(&b_leaf).unwrap();
let cache_after_b = resolver.cache_size();
assert!(cache_after_a >= 1);
assert_eq!(
cache_after_b, cache_after_a,
"shared ancestor file should be deduplicated in cache"
);
}
#[test]
fn post_validate_fires_on_every_resolve_at() {
let dir_a = TempDir::new().unwrap();
let dir_b = TempDir::new().unwrap();
fs::write(dir_a.path().join("test.toml"), "port = 3000\n").unwrap();
fs::write(dir_b.path().join("test.toml"), "port = 4000\n").unwrap();
let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let call_count_clone = call_count.clone();
let resolver = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Cwd])
.no_env()
.post_validate(move |_: &TestConfig| {
call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
.build_resolver()
.unwrap();
resolver.resolve_at(dir_a.path()).unwrap();
resolver.resolve_at(dir_b.path()).unwrap();
resolver.resolve_at(dir_a.path()).unwrap();
assert_eq!(
call_count.load(std::sync::atomic::Ordering::SeqCst),
3,
"hook must run once per resolve_at call"
);
}
#[test]
fn post_validate_rejection_propagates_from_resolve_at() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 80\n").unwrap();
let resolver = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Cwd])
.no_env()
.post_validate(|c: &TestConfig| {
if c.port < 1024 {
Err(format!("port {} is privileged", c.port))
} else {
Ok(())
}
})
.build_resolver()
.unwrap();
let result = resolver.resolve_at(dir.path());
match result {
Err(ClapfigError::PostValidationFailed(msg)) => {
assert!(msg.contains("80"), "expected port in message: {msg}");
}
other => panic!("expected PostValidationFailed, got {other:?}"),
}
}
#[test]
fn resolve_at_merge_layers_multiple_ancestors() {
let root = TempDir::new().unwrap();
let mid = root.path().join("mid");
let leaf = mid.join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::write(root.path().join("test.toml"), "host = \"root\"\n").unwrap();
fs::write(mid.join("test.toml"), "port = 1111\n").unwrap();
fs::write(
leaf.join("test.toml"),
"host = \"leaf\"\n[database]\npool_size = 99\n",
)
.unwrap();
let resolver = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Ancestors(Boundary::Root)])
.search_mode(SearchMode::Merge)
.no_env()
.build_resolver()
.unwrap();
let config = resolver.resolve_at(&leaf).unwrap();
assert_eq!(config.host, "leaf", "leaf (deepest) wins for host");
assert_eq!(config.port, 1111, "mid contributes port");
assert_eq!(
config.database.pool_size, 99,
"leaf contributes nested pool_size"
);
}
#[test]
fn resolve_at_first_match_picks_nearest_ancestor() {
let root = TempDir::new().unwrap();
let mid = root.path().join("mid");
let leaf = mid.join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::write(root.path().join("test.toml"), "port = 1\n").unwrap();
fs::write(mid.join("test.toml"), "port = 2\n").unwrap();
let resolver = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Ancestors(Boundary::Root)])
.search_mode(SearchMode::FirstMatch)
.no_env()
.build_resolver()
.unwrap();
let config = resolver.resolve_at(&leaf).unwrap();
assert_eq!(config.port, 2, "nearest ancestor with a file wins");
}
#[test]
fn build_resolver_requires_app_name() {
let result = Clapfig::builder::<TestConfig>().build_resolver();
assert!(matches!(result, Err(ClapfigError::AppNameRequired)));
}
#[test]
fn resolve_at_normalizes_relative_start_dir_via_cwd() {
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("sub");
fs::create_dir(&sub).unwrap();
fs::write(sub.join("test.toml"), "port = 3030\n").unwrap();
let resolver = resolver_with_paths(vec![SearchPath::Cwd]);
let c1 = resolver.resolve_at(&sub).unwrap();
assert_eq!(c1.port, 3030);
let size_after_plain = resolver.cache_size();
let canon = std::fs::canonicalize(&sub).unwrap();
let c2 = resolver.resolve_at(&canon).unwrap();
assert_eq!(c2.port, 3030);
let size_after_canon = resolver.cache_size();
assert_eq!(
size_after_plain, size_after_canon,
"normalization should collapse equivalent spellings to one cache entry"
);
}
#[test]
fn resolve_at_relative_start_dir_does_not_panic() {
let resolver = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Ancestors(Boundary::Root)])
.no_env()
.build_resolver()
.unwrap();
let rel = resolver.resolve_at(".").unwrap();
let abs = resolver
.resolve_at(std::env::current_dir().unwrap())
.unwrap();
assert_eq!(rel.port, abs.port);
assert_eq!(rel.host, abs.host);
}
#[test]
fn env_vars_captured_at_build_resolver_time() {
const KEY: &str = "CLAPFIG_RESOLVER_CAPTURE_TEST_F7A2E1__PORT";
unsafe {
std::env::set_var(KEY, "1111");
}
let dir = TempDir::new().unwrap();
let resolver = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.env_prefix("CLAPFIG_RESOLVER_CAPTURE_TEST_F7A2E1")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.build_resolver()
.unwrap();
unsafe {
std::env::set_var(KEY, "2222");
}
let config = resolver.resolve_at(dir.path()).unwrap();
assert_eq!(
config.port, 1111,
"resolver must use the env snapshot captured at build_resolver time"
);
unsafe {
std::env::remove_var(KEY);
}
}
#[test]
fn load_still_produces_same_config_as_before() {
let dir = TempDir::new().unwrap();
let config: TestConfig = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.load()
.unwrap();
assert_eq!(config.port, 8080);
assert_eq!(config.host, "localhost");
assert_eq!(config.database.pool_size, 5);
}
}