use crate::config::store::{ConfigStore, SectionKind};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub(super) const TOML_DEFAULT_FILE_NAME: &str = "app_config.toml";
pub(super) const JSON_DEFAULT_FILE_NAME: &str = "app_config.json";
const DEFAULT_RELOAD_INTERVAL: Duration = Duration::from_secs(5);
type RegisterFn = Box<dyn FnOnce(&mut ConfigStore, &Value) -> Result<(), String> + Send>;
pub struct ConfigBuilder {
pub(crate) file_path: Option<PathBuf>,
pub(crate) reload_interval: Option<Duration>,
bindings: Vec<RegisterFn>,
}
impl std::fmt::Debug for ConfigBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigBuilder")
.field("file_path", &self.file_path)
.field("binding_count", &self.bindings.len())
.field("reload_interval", &self.reload_interval)
.finish()
}
}
impl Default for ConfigBuilder {
#[inline]
fn default() -> Self {
Self {
file_path: get_default_file().map(|p| p.to_path_buf()),
bindings: Vec::new(),
reload_interval: None,
}
}
}
impl ConfigBuilder {
pub fn new() -> Self {
Self {
file_path: None,
bindings: Vec::new(),
reload_interval: None,
}
}
pub fn from_file(path: impl AsRef<Path>) -> Self {
Self::new().with_file(path)
}
pub fn with_file(mut self, path: impl AsRef<Path>) -> Self {
self.file_path = Some(path.as_ref().into());
self
}
pub fn bind_section<T>(mut self, key: impl Into<String>) -> Self
where
T: DeserializeOwned + Send + Sync + 'static,
{
let key = key.into();
self.bindings.push(Box::new(move |store, value| {
store.register::<T>(&key, SectionKind::Required, value)
}));
self
}
pub fn bind_section_optional<T>(mut self, key: impl Into<String>) -> Self
where
T: DeserializeOwned + Send + Sync + 'static,
{
let key = key.into();
self.bindings.push(Box::new(move |store, value| {
store.register::<T>(&key, SectionKind::Optional, value)
}));
self
}
pub fn reload_on_change(mut self) -> Self {
self.reload_interval = Some(DEFAULT_RELOAD_INTERVAL);
self
}
pub fn load_file(&self) -> Result<Value, String> {
let path = self.file_path.as_deref().ok_or_else(|| {
"config: no file path configured; call from_file() before reload_on_change()".to_owned()
})?;
parse_config_file(path)
}
pub fn build_from_value(self, value: &Value) -> Result<ConfigStore, String> {
let mut store = ConfigStore::new();
for register in self.bindings {
register(&mut store, value)?;
}
store.reload = self
.reload_interval
.map(|i| (i, self.file_path.clone().unwrap_or_default()));
Ok(store)
}
}
pub(crate) fn parse_config_file(path: &Path) -> Result<Value, String> {
let contents = std::fs::read_to_string(path)
.map_err(|e| format!("config: cannot read file '{}': {e}", path.display()))?;
if path.extension().is_some_and(|ext| ext == "toml") {
let table: toml::Value = contents
.parse()
.map_err(|e| format!("config: TOML parse error in '{}': {e}", path.display()))?;
serde_json::to_value(table)
.map_err(|e| format!("config: TOML → JSON conversion error: {e}"))
} else if path.extension().is_some_and(|ext| ext == "json") {
serde_json::from_str(&contents)
.map_err(|e| format!("config: JSON parse error in '{}': {e}", path.display()))
} else {
Err(format!(
"config: unsupported file format for '{}' (use .toml or .json)",
path.display()
))
}
}
#[inline]
pub(super) fn get_default_file() -> Option<&'static Path> {
[TOML_DEFAULT_FILE_NAME, JSON_DEFAULT_FILE_NAME]
.into_iter()
.map(Path::new)
.find(|p| p.exists())
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use std::io::Write;
#[derive(Debug, Deserialize, PartialEq)]
struct Db {
url: String,
}
fn write_toml(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::with_suffix(".toml").unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn it_creates_builder_from_file() {
let file = write_toml("[db]\nurl = \"postgres://localhost/test\"");
let builder =
ConfigBuilder::from_file(file.path().to_str().unwrap()).bind_section::<Db>("db");
let json = builder.load_file().unwrap();
let store = builder.build_from_value(&json).unwrap();
let arc = store.get::<Db>().unwrap();
assert_eq!(arc.url, "postgres://localhost/test");
}
#[test]
fn builder_from_toml_required_section() {
let file = write_toml("[db]\nurl = \"postgres://localhost/test\"");
let builder = ConfigBuilder::new()
.with_file(file.path().to_str().unwrap())
.bind_section::<Db>("db");
let json = builder.load_file().unwrap();
let store = builder.build_from_value(&json).unwrap();
let arc = store.get::<Db>().unwrap();
assert_eq!(arc.url, "postgres://localhost/test");
}
#[test]
fn builder_from_json_required_section() {
let mut f = tempfile::NamedTempFile::with_suffix(".json").unwrap();
f.write_all(br#"{"db": {"url": "mysql://localhost/test"}}"#)
.unwrap();
let builder = ConfigBuilder::new()
.with_file(f.path().to_str().unwrap())
.bind_section::<Db>("db");
let json = builder.load_file().unwrap();
let store = builder.build_from_value(&json).unwrap();
assert_eq!(store.get::<Db>().unwrap().url, "mysql://localhost/test");
}
#[test]
fn builder_optional_section_missing_is_ok() {
let file = write_toml("");
let builder = ConfigBuilder::new()
.with_file(file.path().to_str().unwrap())
.bind_section_optional::<Db>("db");
let json = builder.load_file().unwrap();
let store = builder.build_from_value(&json).unwrap();
assert!(store.get::<Db>().is_none());
}
#[test]
fn builder_required_section_missing_errors() {
let file = write_toml("");
let builder = ConfigBuilder::new()
.with_file(file.path().to_str().unwrap())
.bind_section::<Db>("db");
let json = builder.load_file().unwrap();
let result = builder.build_from_value(&json);
assert!(result.is_err());
}
#[test]
fn reload_on_change_sets_interval() {
let file = write_toml("");
let builder = ConfigBuilder::new()
.with_file(file.path().to_str().unwrap())
.reload_on_change();
let json = builder.load_file().unwrap();
let store = builder.build_from_value(&json).unwrap();
assert!(store.reload.is_some());
}
#[test]
fn reload_without_file_is_error() {
let builder = ConfigBuilder::new().reload_on_change();
assert!(builder.load_file().is_err());
}
#[test]
fn debug_impl_is_non_empty() {
let builder = ConfigBuilder::new();
assert!(!format!("{builder:?}").is_empty());
}
#[test]
fn default_impl_matches_new() {
let builder = ConfigBuilder::default();
assert!(builder.file_path.is_none());
assert!(builder.reload_interval.is_none());
}
#[test]
fn unsupported_file_format_returns_err() {
let mut f = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
f.write_all(b"key: val").unwrap();
let result = parse_config_file(f.path());
assert!(result.is_err());
assert!(result.unwrap_err().contains("unsupported file format"));
}
}