use std::marker::PhantomData;
use std::path::PathBuf;
use confique::Config;
use serde::{Deserialize, Serialize};
use crate::error::ClapfigError;
use crate::file;
use crate::flatten;
use crate::ops::{self, ConfigResult};
use crate::overrides;
use crate::persist;
use crate::resolver::Resolver;
use crate::types::{ConfigAction, Layer, SearchMode, SearchPath};
pub struct Clapfig;
impl Clapfig {
pub fn builder<C: Config>() -> ClapfigBuilder<C> {
ClapfigBuilder::new()
}
}
pub(crate) type PostValidateHook<C> = Box<dyn Fn(&C) -> Result<(), String> + Send + Sync>;
pub struct ClapfigBuilder<C: Config> {
app_name: Option<String>,
file_name: Option<String>,
search_paths: Option<Vec<SearchPath>>,
search_mode: SearchMode,
persist_scopes: Vec<(String, SearchPath)>,
env_prefix: Option<String>,
env_enabled: bool,
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>>,
_phantom: PhantomData<C>,
}
impl<C: Config> ClapfigBuilder<C> {
fn new() -> Self {
Self {
app_name: None,
file_name: None,
search_paths: None,
search_mode: SearchMode::default(),
persist_scopes: Vec::new(),
env_prefix: None,
env_enabled: true,
strict: true,
#[cfg(feature = "url")]
url_overrides: Vec::new(),
cli_overrides: Vec::new(),
layer_order: None,
post_validate: None,
_phantom: PhantomData,
}
}
pub fn app_name(mut self, name: &str) -> Self {
self.app_name = Some(name.to_string());
self
}
pub fn file_name(mut self, name: &str) -> Self {
self.file_name = Some(name.to_string());
self
}
pub fn search_paths(mut self, paths: Vec<SearchPath>) -> Self {
self.search_paths = Some(paths);
self
}
pub fn add_search_path(mut self, path: SearchPath) -> Self {
self.search_paths
.get_or_insert_with(|| vec![SearchPath::Platform])
.push(path);
self
}
pub fn search_mode(mut self, mode: SearchMode) -> Self {
self.search_mode = mode;
self
}
pub fn persist_scope(mut self, name: &str, path: SearchPath) -> Self {
self.persist_scopes.push((name.to_string(), path));
self
}
pub fn env_prefix(mut self, prefix: &str) -> Self {
self.env_prefix = Some(prefix.to_string());
self
}
pub fn no_env(mut self) -> Self {
self.env_enabled = false;
self
}
pub fn strict(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
pub fn layer_order(mut self, order: Vec<Layer>) -> Self {
self.layer_order = Some(order);
self
}
pub fn post_validate<F>(mut self, f: F) -> Self
where
F: Fn(&C) -> Result<(), String> + Send + Sync + 'static,
{
self.post_validate = Some(Box::new(f));
self
}
#[cfg(feature = "url")]
pub fn url_query(mut self, query: &str) -> Self {
self.url_overrides
.extend(crate::url::query_to_overrides(query));
self
}
pub fn cli_override<V: Into<toml::Value>>(mut self, key: &str, value: Option<V>) -> Self {
if let Some(v) = value {
self.cli_overrides.push((key.to_string(), v.into()));
}
self
}
pub fn cli_overrides_from<S: Serialize>(mut self, source: &S) -> Self {
let pairs = flatten::flatten(source)
.expect("clapfig: failed to flatten CLI source for auto-matching");
let valid = overrides::valid_keys(&C::META);
for (key, value) in pairs {
if let Some(v) = value
&& valid.contains(&key)
{
self.cli_overrides.push((key, v));
}
}
self
}
fn effective_app_name(&self) -> Result<&str, ClapfigError> {
self.app_name
.as_deref()
.ok_or(ClapfigError::AppNameRequired)
}
fn effective_file_name(&self) -> Result<String, ClapfigError> {
if let Some(name) = &self.file_name {
return Ok(name.clone());
}
let app = self.effective_app_name()?;
Ok(format!("{app}.toml"))
}
fn effective_search_paths(&self) -> Vec<SearchPath> {
let mut paths = if let Some(paths) = &self.search_paths {
paths.clone()
} else {
vec![SearchPath::Platform]
};
for (_, scope_path) in &self.persist_scopes {
if !paths.contains(scope_path) {
paths.push(scope_path.clone());
}
}
paths
}
fn effective_env_prefix(&self) -> Result<Option<String>, ClapfigError> {
if !self.env_enabled {
return Ok(None);
}
if let Some(prefix) = &self.env_prefix {
return Ok(Some(prefix.clone()));
}
let app = self.effective_app_name()?;
Ok(Some(app.to_uppercase()))
}
pub fn build_resolver(self) -> Result<Resolver<C>, ClapfigError> {
let app_name = self.effective_app_name()?.to_string();
let file_name = self.effective_file_name()?;
let search_paths = self.effective_search_paths();
let env_prefix = self.effective_env_prefix()?;
Ok(Resolver::from_builder(
app_name,
file_name,
search_paths,
self.search_mode,
env_prefix,
self.strict,
#[cfg(feature = "url")]
self.url_overrides,
self.cli_overrides,
self.layer_order,
self.post_validate,
))
}
pub fn load(self) -> Result<C, ClapfigError>
where
C::Layer: for<'de> Deserialize<'de>,
{
let start_dir = std::env::current_dir().map_err(|e| ClapfigError::IoError {
path: PathBuf::from("."),
source: e,
})?;
self.build_resolver()?.resolve_at(start_dir)
}
pub fn handle_and_print(self, action: &ConfigAction) -> Result<(), ClapfigError>
where
C: Serialize,
C::Layer: for<'de> Deserialize<'de>,
{
let result = self.handle(action)?;
print!("{result}");
Ok(())
}
pub fn handle_to_string(self, action: &ConfigAction) -> Result<String, ClapfigError>
where
C: Serialize,
C::Layer: for<'de> Deserialize<'de>,
{
self.handle(action).map(|r| r.to_string())
}
fn resolve_scope_persist_path(
&self,
scope: Option<&str>,
) -> Result<std::path::PathBuf, ClapfigError> {
if self.persist_scopes.is_empty() {
return Err(ClapfigError::NoPersistPath);
}
let app_name = self.effective_app_name()?;
let file_name = self.effective_file_name()?;
let (_, search_path) = match scope {
None => &self.persist_scopes[0],
Some(name) => self
.persist_scopes
.iter()
.find(|(n, _)| n == name)
.ok_or_else(|| ClapfigError::UnknownScope {
scope: name.to_string(),
available: self.persist_scopes.iter().map(|(n, _)| n.clone()).collect(),
})?,
};
file::resolve_persist_path(search_path, &file_name, app_name)
}
pub fn handle(self, action: &ConfigAction) -> Result<ConfigResult, ClapfigError>
where
C: Serialize,
C::Layer: for<'de> Deserialize<'de>,
{
match action {
ConfigAction::List { scope } => match scope {
None => {
let config = self.load()?;
ops::list_values(&config)
}
Some(name) => {
let path = self.resolve_scope_persist_path(Some(name))?;
ops::list_scope_file(&path)
}
},
ConfigAction::Gen { output } => {
let template = ops::generate_template::<C>();
match output {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ClapfigError::IoError {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(path, &template).map_err(|e| ClapfigError::IoError {
path: path.clone(),
source: e,
})?;
Ok(ConfigResult::TemplateWritten { path: path.clone() })
}
None => Ok(ConfigResult::Template(template)),
}
}
ConfigAction::Schema { output } => {
let schema = ops::generate_schema_string::<C>();
match output {
Some(path) => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ClapfigError::IoError {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(path, &schema).map_err(|e| ClapfigError::IoError {
path: path.clone(),
source: e,
})?;
Ok(ConfigResult::SchemaWritten { path: path.clone() })
}
None => Ok(ConfigResult::Schema(schema)),
}
}
ConfigAction::Get { key, scope } => match scope {
None => {
let config = self.load()?;
ops::get_value(&config, key)
}
Some(name) => {
let path = self.resolve_scope_persist_path(Some(name))?;
ops::get_scope_value::<C>(&path, key)
}
},
ConfigAction::Set { key, value, scope } => {
let path = self.resolve_scope_persist_path(scope.as_deref())?;
persist::persist_value::<C>(&path, key, value)
}
ConfigAction::Unset { key, scope } => {
let path = self.resolve_scope_persist_path(scope.as_deref())?;
persist::unset_value(&path, key)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::test::{EnumConfig, TestConfig};
use crate::types::Boundary;
use std::fs;
use tempfile::TempDir;
#[test]
fn app_name_sets_defaults() {
let builder = Clapfig::builder::<TestConfig>().app_name("myapp");
assert_eq!(builder.effective_file_name().unwrap(), "myapp.toml");
assert_eq!(
builder.effective_env_prefix().unwrap(),
Some("MYAPP".to_string())
);
assert_eq!(builder.effective_search_paths(), vec![SearchPath::Platform]);
}
#[test]
fn override_file_name() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.file_name("custom.toml");
assert_eq!(builder.effective_file_name().unwrap(), "custom.toml");
}
#[test]
fn override_env_prefix() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.env_prefix("CUSTOM");
assert_eq!(
builder.effective_env_prefix().unwrap(),
Some("CUSTOM".to_string())
);
}
#[test]
fn no_env_disables_prefix() {
let builder = Clapfig::builder::<TestConfig>().app_name("myapp").no_env();
assert_eq!(builder.effective_env_prefix().unwrap(), None);
}
#[test]
fn search_paths_replace() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.search_paths(vec![SearchPath::Cwd]);
assert_eq!(builder.effective_search_paths(), vec![SearchPath::Cwd]);
}
#[test]
fn add_search_path_appends_to_defaults() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.add_search_path(SearchPath::Cwd);
assert_eq!(
builder.effective_search_paths(),
vec![SearchPath::Platform, SearchPath::Cwd]
);
}
#[test]
fn add_search_path_appends_to_existing_list() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.search_paths(vec![SearchPath::Cwd])
.add_search_path(SearchPath::Platform);
assert_eq!(
builder.effective_search_paths(),
vec![SearchPath::Cwd, SearchPath::Platform]
);
}
#[test]
fn search_mode_defaults_to_merge() {
let builder = Clapfig::builder::<TestConfig>().app_name("myapp");
assert_eq!(builder.search_mode, SearchMode::Merge);
}
#[test]
fn search_mode_can_be_set() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.search_mode(SearchMode::FirstMatch);
assert_eq!(builder.search_mode, SearchMode::FirstMatch);
}
#[test]
fn persist_scopes_default_empty() {
let builder = Clapfig::builder::<TestConfig>().app_name("myapp");
assert!(builder.persist_scopes.is_empty());
}
#[test]
fn persist_scope_can_be_added() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.persist_scope("local", SearchPath::Cwd);
assert_eq!(builder.persist_scopes.len(), 1);
assert_eq!(builder.persist_scopes[0].0, "local");
assert_eq!(builder.persist_scopes[0].1, SearchPath::Cwd);
}
#[test]
fn persist_scope_auto_adds_to_search_paths() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.persist_scope("local", SearchPath::Cwd);
let paths = builder.effective_search_paths();
assert_eq!(paths, vec![SearchPath::Platform, SearchPath::Cwd]);
}
#[test]
fn persist_scope_deduplicates_search_paths() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.search_paths(vec![SearchPath::Platform, SearchPath::Cwd])
.persist_scope("local", SearchPath::Cwd);
let paths = builder.effective_search_paths();
assert_eq!(paths, vec![SearchPath::Platform, SearchPath::Cwd]);
}
#[test]
fn cli_override_some_added() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.cli_override("port", Some(3000i64));
assert_eq!(builder.cli_overrides.len(), 1);
assert_eq!(builder.cli_overrides[0].0, "port");
}
#[test]
fn cli_override_none_skipped() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.cli_override::<i64>("port", None);
assert!(builder.cli_overrides.is_empty());
}
#[test]
fn missing_app_name_errors() {
let builder = Clapfig::builder::<TestConfig>();
let result = builder.load();
assert!(matches!(result, Err(ClapfigError::AppNameRequired)));
}
#[test]
fn load_with_file() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").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, 3000);
assert_eq!(config.host, "localhost"); }
#[test]
fn load_with_cli_override() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").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()
.cli_override("port", Some(9999i64))
.load()
.unwrap();
assert_eq!(config.port, 9999);
}
#[test]
fn load_defaults_only() {
let dir = TempDir::new().unwrap();
let config: TestConfig = Clapfig::builder()
.app_name("test")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.load()
.unwrap();
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 8080);
assert!(!config.debug);
}
#[test]
fn post_validate_sees_merged_values() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let seen_port = std::sync::Arc::new(std::sync::Mutex::new(0u16));
let seen_port_clone = seen_port.clone();
let _config: TestConfig = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.post_validate(move |c: &TestConfig| {
*seen_port_clone.lock().unwrap() = c.port;
Ok(())
})
.load()
.unwrap();
assert_eq!(
*seen_port.lock().unwrap(),
3000,
"hook must see post-merge values"
);
}
#[test]
fn post_validate_ok_passes_config_through() {
let dir = TempDir::new().unwrap();
let config: TestConfig = Clapfig::builder()
.app_name("test")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.post_validate(|_: &TestConfig| Ok(()))
.load()
.unwrap();
assert_eq!(config.port, 8080);
}
#[test]
fn post_validate_err_returns_post_validation_failed() {
let dir = TempDir::new().unwrap();
let result: Result<TestConfig, _> = Clapfig::builder()
.app_name("test")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.post_validate(|c: &TestConfig| {
if c.port < 10_000 {
Err(format!("port {} is below 10000", c.port))
} else {
Ok(())
}
})
.load();
match result {
Err(ClapfigError::PostValidationFailed(msg)) => {
assert!(msg.contains("8080"), "expected port in message: {msg}");
assert!(msg.contains("below"), "expected reason in message: {msg}");
}
Err(other) => panic!("expected PostValidationFailed, got {other:?}"),
Ok(_) => panic!("expected PostValidationFailed, got Ok"),
}
}
#[test]
fn post_validate_not_called_when_upstream_fails() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "typo_key = 1\n").unwrap();
let called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let called_clone = called.clone();
let result: Result<TestConfig, _> = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.strict(true)
.post_validate(move |_: &TestConfig| {
called_clone.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(())
})
.load();
assert!(result.is_err(), "strict validation should have failed");
assert!(
!called.load(std::sync::atomic::Ordering::SeqCst),
"hook must not run when upstream resolution fails"
);
}
#[test]
fn post_validate_second_call_replaces_first() {
let dir = TempDir::new().unwrap();
let result: Result<TestConfig, _> = Clapfig::builder()
.app_name("test")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.post_validate(|_: &TestConfig| Err("first".into()))
.post_validate(|_: &TestConfig| Err("second".into()))
.load();
match result {
Err(ClapfigError::PostValidationFailed(msg)) => assert_eq!(msg, "second"),
other => panic!("expected PostValidationFailed('second'), got {other:?}"),
}
}
#[test]
fn strict_rejects_unknown_key() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "typo = 1\n").unwrap();
let result: Result<TestConfig, _> = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.strict(true)
.load();
assert!(result.is_err());
}
#[test]
fn lenient_allows_unknown_key() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "typo = 1\nport = 3000\n").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()
.strict(false)
.load()
.unwrap();
assert_eq!(config.port, 3000);
}
#[test]
fn first_match_uses_highest_priority_file_only() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(
dir1.path().join("test.toml"),
"port = 1000\nhost = \"low\"\n",
)
.unwrap();
fs::write(dir2.path().join("test.toml"), "port = 2000\n").unwrap();
let config: TestConfig = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()), ])
.search_mode(SearchMode::FirstMatch)
.no_env()
.load()
.unwrap();
assert_eq!(config.port, 2000);
assert_eq!(config.host, "localhost"); }
#[test]
fn merge_mode_combines_both_files() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(
dir1.path().join("test.toml"),
"port = 1000\nhost = \"base\"\n",
)
.unwrap();
fs::write(dir2.path().join("test.toml"), "port = 2000\n").unwrap();
let config: TestConfig = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()),
])
.search_mode(SearchMode::Merge)
.no_env()
.load()
.unwrap();
assert_eq!(config.port, 2000);
assert_eq!(config.host, "base");
}
#[test]
fn first_match_falls_back_when_high_priority_missing() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
fs::write(dir1.path().join("test.toml"), "port = 1000\n").unwrap();
let config: TestConfig = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![
SearchPath::Path(dir1.path().to_path_buf()),
SearchPath::Path(dir2.path().to_path_buf()),
])
.search_mode(SearchMode::FirstMatch)
.no_env()
.load()
.unwrap();
assert_eq!(config.port, 1000);
}
#[test]
fn handle_gen() {
let result: ConfigResult = Clapfig::builder::<TestConfig>()
.app_name("test")
.no_env()
.handle(&ConfigAction::Gen { output: None })
.unwrap();
match result {
ConfigResult::Template(t) => {
assert!(t.contains("host"));
assert!(t.contains("port"));
}
other => panic!("Expected Template, got {other:?}"),
}
}
#[test]
fn handle_gen_with_output() {
let dir = TempDir::new().unwrap();
let out_path = dir.path().join("generated.toml");
let result: ConfigResult = Clapfig::builder::<TestConfig>()
.app_name("test")
.no_env()
.handle(&ConfigAction::Gen {
output: Some(out_path.clone()),
})
.unwrap();
assert!(matches!(result, ConfigResult::TemplateWritten { .. }));
let content = fs::read_to_string(&out_path).unwrap();
assert!(content.contains("host"));
assert!(content.contains("port"));
}
#[test]
fn handle_schema() {
let result: ConfigResult = Clapfig::builder::<TestConfig>()
.app_name("test")
.no_env()
.handle(&ConfigAction::Schema { output: None })
.unwrap();
match result {
ConfigResult::Schema(s) => {
let value: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(value["type"], "object");
assert_eq!(value["title"], "TestConfig");
assert!(value["properties"].get("host").is_some());
assert!(value["properties"].get("database").is_some());
}
other => panic!("Expected Schema, got {other:?}"),
}
}
#[test]
fn handle_schema_with_output() {
let dir = TempDir::new().unwrap();
let out_path = dir.path().join("schema.json");
let result: ConfigResult = Clapfig::builder::<TestConfig>()
.app_name("test")
.no_env()
.handle(&ConfigAction::Schema {
output: Some(out_path.clone()),
})
.unwrap();
assert!(matches!(result, ConfigResult::SchemaWritten { .. }));
let content = fs::read_to_string(&out_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(value["title"], "TestConfig");
}
#[test]
fn handle_get_merged() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.handle(&ConfigAction::Get {
key: "port".into(),
scope: None,
})
.unwrap();
match result {
ConfigResult::KeyValue { value, .. } => assert_eq!(value, "3000"),
other => panic!("Expected KeyValue, got {other:?}"),
}
}
#[test]
fn handle_set_requires_persist_scope() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.handle(&ConfigAction::Set {
key: "port".into(),
value: "3000".into(),
scope: None,
});
assert!(matches!(result, Err(ClapfigError::NoPersistPath)));
}
#[test]
fn handle_set_default_scope() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Set {
key: "port".into(),
value: "3000".into(),
scope: None,
})
.unwrap();
assert!(matches!(result, ConfigResult::ValueSet { .. }));
let content = fs::read_to_string(dir.path().join("test.toml")).unwrap();
assert!(content.contains("port = 3000"));
}
#[test]
fn handle_set_named_scope() {
let local_dir = TempDir::new().unwrap();
let global_dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(local_dir.path().to_path_buf()))
.persist_scope("global", SearchPath::Path(global_dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Set {
key: "port".into(),
value: "9999".into(),
scope: Some("global".into()),
})
.unwrap();
assert!(matches!(result, ConfigResult::ValueSet { .. }));
let content = fs::read_to_string(global_dir.path().join("test.toml")).unwrap();
assert!(content.contains("port = 9999"));
assert!(!local_dir.path().join("test.toml").exists());
}
#[test]
fn handle_unset_requires_persist_scope() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.handle(&ConfigAction::Unset {
key: "port".into(),
scope: None,
});
assert!(matches!(result, Err(ClapfigError::NoPersistPath)));
}
#[test]
fn handle_unset_removes_key() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("test.toml"),
"port = 3000\nhost = \"localhost\"\n",
)
.unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Unset {
key: "port".into(),
scope: None,
})
.unwrap();
assert!(matches!(result, ConfigResult::ValueUnset { .. }));
let content = fs::read_to_string(dir.path().join("test.toml")).unwrap();
assert!(!content.contains("port"));
assert!(content.contains("host = \"localhost\""));
}
#[test]
fn handle_set_rejects_ancestors_scope() {
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.persist_scope("bad", SearchPath::Ancestors(Boundary::Root))
.no_env()
.handle(&ConfigAction::Set {
key: "port".into(),
value: "3000".into(),
scope: None,
});
assert!(matches!(
result,
Err(ClapfigError::AncestorsNotAllowedAsPersistPath)
));
}
#[test]
fn handle_unknown_scope_errors() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Set {
key: "port".into(),
value: "3000".into(),
scope: Some("nonexistent".into()),
});
match result {
Err(ClapfigError::UnknownScope { scope, available }) => {
assert_eq!(scope, "nonexistent");
assert_eq!(available, vec!["local"]);
}
other => panic!("Expected UnknownScope, got {other:?}"),
}
}
#[test]
fn handle_list_with_scope() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::List {
scope: Some("local".into()),
})
.unwrap();
match result {
ConfigResult::Listing { entries } => {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0], ("port".into(), "3000".into()));
}
other => panic!("Expected Listing, got {other:?}"),
}
}
#[test]
fn handle_get_with_scope() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Get {
key: "port".into(),
scope: Some("local".into()),
})
.unwrap();
match result {
ConfigResult::KeyValue { value, .. } => assert_eq!(value, "3000"),
other => panic!("Expected KeyValue, got {other:?}"),
}
}
#[test]
fn multiple_scopes_separate_files() {
let local_dir = TempDir::new().unwrap();
let global_dir = TempDir::new().unwrap();
let make_builder = || {
Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(local_dir.path().to_path_buf()))
.persist_scope("global", SearchPath::Path(global_dir.path().to_path_buf()))
.no_env()
};
make_builder()
.handle(&ConfigAction::Set {
key: "port".into(),
value: "3000".into(),
scope: None, })
.unwrap();
make_builder()
.handle(&ConfigAction::Set {
key: "host".into(),
value: "0.0.0.0".into(),
scope: Some("global".into()),
})
.unwrap();
let local_content = fs::read_to_string(local_dir.path().join("test.toml")).unwrap();
assert!(local_content.contains("port = 3000"));
assert!(!local_content.contains("host = \"0.0.0.0\""));
let global_content = fs::read_to_string(global_dir.path().join("test.toml")).unwrap();
assert!(global_content.contains("host = \"0.0.0.0\""));
assert!(!global_content.contains("port = 3000"));
let local_list = make_builder()
.handle(&ConfigAction::List {
scope: Some("local".into()),
})
.unwrap();
match local_list {
ConfigResult::Listing { entries } => {
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, "port");
}
other => panic!("Expected Listing, got {other:?}"),
}
let merged_list = make_builder()
.handle(&ConfigAction::List { scope: None })
.unwrap();
match merged_list {
ConfigResult::Listing { entries } => {
let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"port"));
assert!(keys.contains(&"host"));
}
other => panic!("Expected Listing, got {other:?}"),
}
}
#[test]
fn overrides_from_matches_known_keys() {
#[derive(Serialize)]
struct Args {
host: Option<String>,
port: Option<u16>,
}
let args = Args {
host: Some("1.2.3.4".into()),
port: Some(9999),
};
let builder = Clapfig::builder::<TestConfig>()
.app_name("test")
.cli_overrides_from(&args);
assert_eq!(builder.cli_overrides.len(), 2);
}
#[test]
fn overrides_from_skips_none() {
#[derive(Serialize)]
struct Args {
host: Option<String>,
port: Option<u16>,
}
let args = Args {
host: None,
port: Some(9999),
};
let builder = Clapfig::builder::<TestConfig>()
.app_name("test")
.cli_overrides_from(&args);
assert_eq!(builder.cli_overrides.len(), 1);
assert_eq!(builder.cli_overrides[0].0, "port");
}
#[test]
fn overrides_from_ignores_unknown_keys() {
#[derive(Serialize)]
struct Args {
host: Option<String>,
verbose: bool,
output: Option<String>,
}
let args = Args {
host: Some("x".into()),
verbose: true,
output: Some("f".into()),
};
let builder = Clapfig::builder::<TestConfig>()
.app_name("test")
.cli_overrides_from(&args);
assert_eq!(builder.cli_overrides.len(), 1);
assert_eq!(builder.cli_overrides[0].0, "host");
}
#[test]
fn overrides_from_composes_with_cli_override() {
#[derive(Serialize)]
struct Args {
host: Option<String>,
}
let args = Args {
host: Some("from_struct".into()),
};
let builder = Clapfig::builder::<TestConfig>()
.app_name("test")
.cli_override("port", Some(1234i64))
.cli_overrides_from(&args);
assert_eq!(builder.cli_overrides.len(), 2);
assert_eq!(builder.cli_overrides[0].0, "port");
assert_eq!(builder.cli_overrides[1].0, "host");
}
#[test]
fn overrides_from_hashmap() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert("port".to_string(), 3000i64);
let builder = Clapfig::builder::<TestConfig>()
.app_name("test")
.cli_overrides_from(&map);
assert_eq!(builder.cli_overrides.len(), 1);
assert_eq!(builder.cli_overrides[0].0, "port");
}
#[test]
fn overrides_from_all_none() {
#[derive(Serialize)]
struct Args {
host: Option<String>,
port: Option<u16>,
}
let args = Args {
host: None,
port: None,
};
let builder = Clapfig::builder::<TestConfig>()
.app_name("test")
.cli_overrides_from(&args);
assert!(builder.cli_overrides.is_empty());
}
#[test]
fn overrides_from_end_to_end() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
#[derive(Serialize)]
struct Args {
host: Option<String>,
port: Option<i64>,
verbose: bool,
}
let args = Args {
host: Some("1.2.3.4".into()),
port: None,
verbose: true,
};
let config: TestConfig = Clapfig::builder()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.cli_overrides_from(&args)
.load()
.unwrap();
assert_eq!(config.host, "1.2.3.4"); assert_eq!(config.port, 3000); assert!(!config.debug); }
#[test]
fn handle_list() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.handle(&ConfigAction::List { scope: None })
.unwrap();
match result {
ConfigResult::Listing { entries } => {
let port = entries.iter().find(|(k, _)| k == "port").unwrap();
assert_eq!(port.1, "3000");
let host = entries.iter().find(|(k, _)| k == "host").unwrap();
assert_eq!(host.1, "localhost"); }
other => panic!("Expected Listing, got {other:?}"),
}
}
#[test]
fn handle_set_rejects_invalid_enum_value() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<EnumConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Set {
key: "mode".into(),
value: "garbage".into(),
scope: None,
});
assert!(matches!(result, Err(ClapfigError::InvalidValue { .. })));
assert!(!dir.path().join("test.toml").exists());
}
#[test]
fn handle_set_accepts_valid_enum_value() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<EnumConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Set {
key: "mode".into(),
value: "slow".into(),
scope: None,
});
assert!(matches!(result, Ok(ConfigResult::ValueSet { .. })));
let content = fs::read_to_string(dir.path().join("test.toml")).unwrap();
assert!(content.contains("mode = \"slow\""));
}
#[test]
fn handle_set_rejects_unknown_key() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.file_name("test.toml")
.persist_scope("local", SearchPath::Path(dir.path().to_path_buf()))
.no_env()
.handle(&ConfigAction::Set {
key: "nonexistent".into(),
value: "whatever".into(),
scope: None,
});
assert!(matches!(result, Err(ClapfigError::KeyNotFound(_))));
}
#[test]
fn handle_list_defaults_only() {
let dir = TempDir::new().unwrap();
let result = Clapfig::builder::<TestConfig>()
.app_name("test")
.search_paths(vec![SearchPath::Path(dir.path().to_path_buf())])
.no_env()
.handle(&ConfigAction::List { scope: None })
.unwrap();
match result {
ConfigResult::Listing { entries } => {
assert_eq!(entries.len(), 5);
let db_url = entries.iter().find(|(k, _)| k == "database.url").unwrap();
assert_eq!(db_url.1, "<not set>");
}
other => panic!("Expected Listing, got {other:?}"),
}
}
#[test]
fn layer_order_defaults_to_none() {
let builder = Clapfig::builder::<TestConfig>().app_name("myapp");
assert_eq!(builder.layer_order, None);
}
#[test]
fn layer_order_can_be_set() {
let builder = Clapfig::builder::<TestConfig>()
.app_name("myapp")
.layer_order(vec![Layer::Env, Layer::Files, Layer::Cli]);
assert_eq!(
builder.layer_order,
Some(vec![Layer::Env, Layer::Files, Layer::Cli])
);
}
#[test]
fn layer_order_cli_below_files() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").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()
.cli_override("port", Some(9999))
.layer_order(vec![Layer::Cli, Layer::Files])
.load()
.unwrap();
assert_eq!(config.port, 3000);
}
#[test]
fn layer_order_default_cli_wins() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("test.toml"), "port = 3000\n").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()
.cli_override("port", Some(9999))
.load()
.unwrap();
assert_eq!(config.port, 9999);
}
}