use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::error::Error;
use crate::source::{self, Format};
#[derive(Debug, Clone, Default)]
pub struct ConfigBuilder {
layers: Vec<Layer>,
}
#[derive(Debug, Clone)]
enum Layer {
Defaults(Value),
File { path: PathBuf, required: bool },
}
impl ConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn defaults<D: Serialize>(mut self, defaults: D) -> Result<Self, Error> {
let value = serde_json::to_value(defaults).map_err(Error::Defaults)?;
self.layers.push(Layer::Defaults(value));
Ok(self)
}
#[must_use]
pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
self.layers.push(Layer::File {
path: path.into(),
required: true,
});
self
}
#[must_use]
pub fn file_optional(mut self, path: impl Into<PathBuf>) -> Self {
self.layers.push(Layer::File {
path: path.into(),
required: false,
});
self
}
pub fn build<T: DeserializeOwned>(self) -> Result<T, Error> {
let merged = self.build_value()?;
serde_json::from_value(merged).map_err(Error::Deserialize)
}
pub(crate) fn build_value(&self) -> Result<Value, Error> {
let mut merged = Value::Object(serde_json::Map::new());
for layer in &self.layers {
match layer {
Layer::Defaults(value) => source::merge(&mut merged, value.clone()),
Layer::File { path, required } => {
let format = Format::from_path(path)?;
let Some(text) = read_file(path, *required)? else {
continue;
};
let value = format.parse(&text, Some(path))?;
source::merge(&mut merged, value);
}
}
}
Ok(merged)
}
#[cfg(feature = "watch")]
pub(crate) fn file_paths(&self) -> Vec<(PathBuf, bool)> {
self.layers
.iter()
.filter_map(|layer| match layer {
Layer::File { path, required } => Some((path.clone(), *required)),
Layer::Defaults(_) => None,
})
.collect()
}
}
fn read_file(path: &Path, required: bool) -> Result<Option<String>, Error> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
if required {
Err(Error::NotFound {
path: path.to_path_buf(),
})
} else {
Ok(None)
}
}
Err(err) => Err(Error::Io {
path: path.to_path_buf(),
source: err,
}),
}
}
#[cfg(test)]
mod tests {
use serde::Deserialize;
use serde_json::json;
use super::*;
#[derive(Debug, PartialEq, Deserialize)]
struct Sample {
name: String,
port: u16,
#[serde(default)]
debug: bool,
}
#[test]
fn defaults_then_file_override() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.json");
std::fs::write(&path, r#"{ "port": 9090 }"#).unwrap();
let config: Sample = ConfigBuilder::new()
.defaults(json!({ "name": "demo", "port": 1, "debug": true }))
.unwrap()
.file(&path)
.build()
.unwrap();
assert_eq!(
config,
Sample {
name: "demo".into(),
port: 9090,
debug: true
}
);
}
#[test]
fn missing_required_file_is_an_error() {
let err = ConfigBuilder::new()
.file("definitely/missing.json")
.build::<Sample>()
.unwrap_err();
assert!(matches!(err, Error::NotFound { .. }), "{err}");
assert!(err.to_string().contains("missing.json"), "{err}");
}
#[test]
fn missing_optional_file_is_skipped() {
let config: Sample = ConfigBuilder::new()
.defaults(json!({ "name": "demo", "port": 1 }))
.unwrap()
.file_optional("definitely/missing.json")
.build()
.unwrap();
assert_eq!(config.port, 1);
}
#[test]
fn unknown_extension_is_reported_even_for_optional_file() {
let err = ConfigBuilder::new()
.file_optional("missing.conf")
.build::<serde_json::Value>()
.unwrap_err();
assert!(matches!(err, Error::UnknownExtension { .. }), "{err}");
}
#[test]
fn type_mismatch_is_a_deserialize_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("app.json");
std::fs::write(&path, r#"{ "name": "demo", "port": "not-a-number" }"#).unwrap();
let err = ConfigBuilder::new()
.file(&path)
.build::<Sample>()
.unwrap_err();
assert!(matches!(err, Error::Deserialize(_)), "{err}");
}
}