1use std::marker::PhantomData;
4use std::path::Path;
5use std::path::PathBuf;
6
7use serde::de::DeserializeOwned;
8
9use crate::error::ConfigError;
10use crate::layer::ConfigLayer;
11
12#[derive(Debug, Clone, Copy)]
18pub struct Empty;
19
20#[derive(Debug, Clone, Copy)]
23pub struct Ready;
24
25#[derive(Debug, Clone)]
75pub struct ConfigBuilder<S> {
76 layers: Vec<ConfigLayer>,
77 _state: PhantomData<S>,
78}
79
80impl Default for ConfigBuilder<Empty> {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl ConfigBuilder<Empty> {
87 #[must_use]
90 pub fn new() -> Self {
91 Self {
92 layers: Vec::new(),
93 _state: PhantomData,
94 }
95 }
96}
97
98impl<S> ConfigBuilder<S> {
99 #[must_use]
104 pub fn layer(mut self, layer: ConfigLayer) -> ConfigBuilder<Ready> {
105 self.layers.push(layer);
106 ConfigBuilder {
107 layers: self.layers,
108 _state: PhantomData,
109 }
110 }
111}
112
113struct Merged {
115 value: toml::Value,
116 last_file: Option<PathBuf>,
117}
118
119impl Merged {
120 fn error_path(&self) -> PathBuf {
123 self.last_file.clone().unwrap_or_default()
124 }
125}
126
127impl ConfigBuilder<Ready> {
128 pub fn load<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
142 let merged = self.merged()?;
143 let path = merged.error_path();
144 T::deserialize(merged.value).map_err(|source| ConfigError::Parse {
145 path,
146 source: Box::new(source),
147 })
148 }
149
150 pub fn load_strict<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
180 let merged = self.merged()?;
181 let path = merged.error_path();
182 let mut unknown: Vec<String> = Vec::new();
183 let parsed = serde_ignored::deserialize(merged.value, |path| {
184 if let serde_ignored::Path::Map { parent, key } = path {
185 if matches!(*parent, serde_ignored::Path::Root) {
186 unknown.push(key);
187 }
188 }
189 })
190 .map_err(|source| ConfigError::Parse {
191 path,
192 source: Box::new(source),
193 })?;
194 if unknown.is_empty() {
195 return Ok(parsed);
196 }
197 unknown.sort_unstable();
198 unknown.dedup();
199 Err(ConfigError::UnknownFields { keys: unknown })
200 }
201
202 fn merged(self) -> Result<Merged, ConfigError> {
204 let mut acc = toml::Value::Table(toml::Table::new());
205 let mut last_file: Option<PathBuf> = None;
206 for layer in self.layers {
207 match layer {
208 ConfigLayer::File(path) => {
209 let value = read_layer_value(&path)?;
210 last_file = Some(path);
211 merge(&mut acc, value);
212 }
213 ConfigLayer::EnvPrefix(prefix) => {
214 merge(&mut acc, toml::Value::Table(crate::env::collect(&prefix)?));
215 }
216 ConfigLayer::Overrides(overrides) => {
217 let mut table = toml::Table::new();
218 for (key, value) in overrides {
219 table.insert(key, value);
220 }
221 merge(&mut acc, toml::Value::Table(table));
222 }
223 }
224 }
225 Ok(Merged {
226 value: acc,
227 last_file,
228 })
229 }
230}
231
232pub(crate) fn read_layer_value(path: &Path) -> Result<toml::Value, ConfigError> {
237 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
238 path: path.to_path_buf(),
239 source,
240 })?;
241 parse_by_extension(path, &text)
242}
243
244fn parse_by_extension(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
247 let ext = path
248 .extension()
249 .and_then(|ext| ext.to_str())
250 .unwrap_or("toml");
251 match ext.to_ascii_lowercase().as_str() {
252 "json" => parse_json(path, text),
253 "yaml" | "yml" => parse_yaml(path, text),
254 _ => parse_toml(path, text),
255 }
256}
257
258fn parse_error(
260 path: &Path,
261 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
262) -> ConfigError {
263 ConfigError::Parse {
264 path: path.to_path_buf(),
265 source: source.into(),
266 }
267}
268
269fn parse_toml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
270 toml::from_str(text).map_err(|source| parse_error(path, source))
271}
272
273#[cfg(feature = "json")]
274fn parse_json(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
275 serde_json::from_str(text).map_err(|source| parse_error(path, source))
276}
277
278#[cfg(not(feature = "json"))]
279fn parse_json(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
280 Err(parse_error(
281 path,
282 "`json` config files require the `json` feature",
283 ))
284}
285
286#[cfg(feature = "yaml")]
287fn parse_yaml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
288 serde_yaml_ng::from_str(text).map_err(|source| parse_error(path, source))
289}
290
291#[cfg(not(feature = "yaml"))]
292fn parse_yaml(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
293 Err(parse_error(
294 path,
295 "`yaml` config files require the `yaml` feature",
296 ))
297}
298
299fn merge(base: &mut toml::Value, over: toml::Value) {
302 match (base, over) {
303 (toml::Value::Table(base_table), toml::Value::Table(over_table)) => {
304 for (key, value) in over_table {
305 match base_table.get_mut(&key) {
306 Some(slot) => merge(slot, value),
307 None => {
308 base_table.insert(key, value);
309 }
310 }
311 }
312 }
313 (base, over) => *base = over,
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 #![allow(clippy::unwrap_used, clippy::expect_used)]
320 use super::*;
321
322 #[test]
323 fn merge_tables_deep_and_replace_scalars() {
324 let mut base: toml::Value =
325 toml::from_str("flag = [1, 2]\n\n[db]\nurl = \"u\"\nport = 1\n").expect("base");
326 let over: toml::Value = toml::from_str("flag = 3\n\n[db]\nport = 2\n").expect("over");
327 merge(&mut base, over);
328 let table = base.as_table().expect("table");
329 let db = table.get("db").and_then(toml::Value::as_table).expect("db");
330 assert_eq!(db.get("url"), Some(&toml::Value::String("u".into())));
331 assert_eq!(db.get("port"), Some(&toml::Value::Integer(2)));
332 assert_eq!(table.get("flag"), Some(&toml::Value::Integer(3)));
333 }
334
335 #[test]
336 fn parse_by_extension_detects_formats() {
337 let toml_val = parse_by_extension(Path::new("a.toml"), "x = 1").expect("toml");
338 assert_eq!(toml_val.get("x"), Some(&toml::Value::Integer(1)));
339 let no_ext = parse_by_extension(Path::new("defaults"), "x = 1").expect("toml default");
340 assert_eq!(no_ext.get("x"), Some(&toml::Value::Integer(1)));
341 let upper = parse_by_extension(Path::new("b.TOML"), "x = 2").expect("case-insensitive");
342 assert_eq!(upper.get("x"), Some(&toml::Value::Integer(2)));
343 }
344
345 #[cfg(feature = "json")]
346 #[test]
347 fn parse_by_extension_json() {
348 let val = parse_by_extension(Path::new("a.json"), "{\"x\": 1}").expect("json");
349 assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
350 }
351
352 #[cfg(not(feature = "json"))]
353 #[test]
354 fn json_without_feature_is_parse_error() {
355 let err = parse_by_extension(Path::new("a.json"), "{}").expect_err("feature gate");
356 assert!(err.to_string().contains("`json` feature"));
357 }
358
359 #[cfg(feature = "yaml")]
360 #[test]
361 fn parse_by_extension_yaml() {
362 let val = parse_by_extension(Path::new("a.yaml"), "x: 1").expect("yaml");
363 assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
364 let val = parse_by_extension(Path::new("a.yml"), "x: 2").expect("yml");
365 assert_eq!(val.get("x"), Some(&toml::Value::Integer(2)));
366 }
367
368 #[cfg(not(feature = "yaml"))]
369 #[test]
370 fn yaml_without_feature_is_parse_error() {
371 let err = parse_by_extension(Path::new("a.yaml"), "x: 1").expect_err("feature gate");
372 assert!(err.to_string().contains("`yaml` feature"));
373 }
374}