1use std::collections::BTreeMap;
4use std::marker::PhantomData;
5use std::path::Path;
6use std::path::PathBuf;
7
8use serde::de::DeserializeOwned;
9
10use crate::error::ConfigError;
11use crate::layer::ConfigLayer;
12
13#[derive(Debug, Clone, Copy)]
19pub struct Empty;
20
21#[derive(Debug, Clone, Copy)]
24pub struct Ready;
25
26#[derive(Debug, Clone)]
76pub struct ConfigBuilder<S> {
77 layers: Vec<ConfigLayer>,
78 _state: PhantomData<S>,
79}
80
81impl Default for ConfigBuilder<Empty> {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl ConfigBuilder<Empty> {
88 #[must_use]
91 pub fn new() -> Self {
92 Self {
93 layers: Vec::new(),
94 _state: PhantomData,
95 }
96 }
97}
98
99impl<S> ConfigBuilder<S> {
100 #[must_use]
105 pub fn layer(mut self, layer: ConfigLayer) -> ConfigBuilder<Ready> {
106 self.layers.push(layer);
107 ConfigBuilder {
108 layers: self.layers,
109 _state: PhantomData,
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
118enum Origin {
119 File(PathBuf),
121 Env { var: String },
124 Overrides,
126}
127
128impl Origin {
129 fn error_path(&self) -> PathBuf {
132 match self {
133 Origin::File(path) => path.clone(),
134 Origin::Env { var } => PathBuf::from(format!("<env:{var}>")),
135 Origin::Overrides => PathBuf::from("<overrides>"),
136 }
137 }
138}
139
140struct Merged {
143 value: toml::Value,
144 provenance: BTreeMap<String, Origin>,
148 last_file: Option<PathBuf>,
149}
150
151impl ConfigBuilder<Ready> {
152 pub fn load<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
172 let Merged {
173 value,
174 provenance,
175 last_file,
176 } = self.merged()?;
177 match serde_path_to_error::deserialize(value) {
178 Ok(value) => Ok(value),
179 Err(source) => {
180 let path = attribute_error_path(&provenance, &last_file, source.path());
181 Err(ConfigError::Parse {
182 path,
183 source: Box::new(source),
184 })
185 }
186 }
187 }
188
189 pub fn load_strict<T: DeserializeOwned>(self) -> Result<T, ConfigError> {
223 let Merged {
224 value,
225 provenance,
226 last_file,
227 } = self.merged()?;
228 let mut unknown: Vec<String> = Vec::new();
229 let mut track = serde_path_to_error::Track::new();
230 let parsed = serde_ignored::deserialize(
231 serde_path_to_error::Deserializer::new(value, &mut track),
232 |path| {
233 if let serde_ignored::Path::Map { parent, key } = path {
234 if matches!(*parent, serde_ignored::Path::Root) {
235 unknown.push(key);
236 }
237 }
238 },
239 );
240 let parsed = match parsed {
241 Ok(parsed) => parsed,
242 Err(source) => {
243 let path = attribute_error_path(&provenance, &last_file, &track.path());
244 return Err(ConfigError::Parse {
245 path,
246 source: Box::new(source),
247 });
248 }
249 };
250 if unknown.is_empty() {
251 return Ok(parsed);
252 }
253 unknown.sort_unstable();
254 unknown.dedup();
255 Err(ConfigError::UnknownFields { keys: unknown })
256 }
257
258 fn merged(self) -> Result<Merged, ConfigError> {
264 let mut acc = toml::Value::Table(toml::Table::new());
265 let mut provenance = BTreeMap::new();
266 let mut last_file: Option<PathBuf> = None;
267 for layer in self.layers {
268 match layer {
269 ConfigLayer::File(path) => {
270 let value = read_layer_value(&path)?;
271 last_file = Some(path.clone());
272 let origin = Origin::File(path);
273 let mut from_root = Vec::new();
274 merge_tracked(&mut acc, value, &origin, &mut from_root, &mut provenance);
275 }
276 ConfigLayer::EnvPrefix(prefix) => {
277 let (table, sources) = crate::env::collect(&prefix)?;
278 for (key, value) in table {
279 let var = sources.get(&key).cloned().unwrap_or_default();
280 let origin = Origin::Env { var };
281 let mut wrapper = toml::Table::new();
282 wrapper.insert(key, value);
283 let mut from_root = Vec::new();
284 merge_tracked(
285 &mut acc,
286 toml::Value::Table(wrapper),
287 &origin,
288 &mut from_root,
289 &mut provenance,
290 );
291 }
292 }
293 ConfigLayer::Overrides(overrides) => {
294 let mut table = toml::Table::new();
295 for (key, value) in overrides {
296 table.insert(key, value);
297 }
298 let origin = Origin::Overrides;
299 let mut from_root = Vec::new();
300 merge_tracked(
301 &mut acc,
302 toml::Value::Table(table),
303 &origin,
304 &mut from_root,
305 &mut provenance,
306 );
307 }
308 }
309 }
310 Ok(Merged {
311 value: acc,
312 provenance,
313 last_file,
314 })
315 }
316}
317
318fn attribute_error_path(
324 provenance: &BTreeMap<String, Origin>,
325 last_file: &Option<PathBuf>,
326 failing: &serde_path_to_error::Path,
327) -> PathBuf {
328 let segments: Vec<String> = failing
329 .iter()
330 .map(|segment| match segment {
331 serde_path_to_error::Segment::Map { key } => key.clone(),
332 serde_path_to_error::Segment::Enum { variant } => variant.clone(),
333 serde_path_to_error::Segment::Seq { index } => index.to_string(),
334 serde_path_to_error::Segment::Unknown => String::from("?"),
335 })
336 .collect();
337 for end in (1..=segments.len()).rev() {
338 let Some(prefix) = segments.get(..end) else {
339 continue;
340 };
341 if let Some(origin) = provenance.get(&prefix.join(".")) {
342 return origin.error_path();
343 }
344 }
345 last_file.clone().unwrap_or_default()
346}
347
348pub(crate) fn read_layer_value(path: &Path) -> Result<toml::Value, ConfigError> {
353 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
354 path: path.to_path_buf(),
355 source,
356 })?;
357 parse_by_extension(path, &text)
358}
359
360fn parse_by_extension(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
363 let ext = path
364 .extension()
365 .and_then(|ext| ext.to_str())
366 .unwrap_or("toml");
367 match ext.to_ascii_lowercase().as_str() {
368 "json" => parse_json(path, text),
369 "yaml" | "yml" => parse_yaml(path, text),
370 _ => parse_toml(path, text),
371 }
372}
373
374fn parse_error(
376 path: &Path,
377 source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
378) -> ConfigError {
379 ConfigError::Parse {
380 path: path.to_path_buf(),
381 source: source.into(),
382 }
383}
384
385fn parse_toml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
386 toml::from_str(text).map_err(|source| parse_error(path, source))
387}
388
389#[cfg(feature = "json")]
390fn parse_json(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
391 serde_json::from_str(text).map_err(|source| parse_error(path, source))
392}
393
394#[cfg(not(feature = "json"))]
395fn parse_json(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
396 Err(parse_error(
397 path,
398 "`json` config files require the `json` feature",
399 ))
400}
401
402#[cfg(feature = "yaml")]
403fn parse_yaml(path: &Path, text: &str) -> Result<toml::Value, ConfigError> {
404 serde_yaml_ng::from_str(text).map_err(|source| parse_error(path, source))
405}
406
407#[cfg(not(feature = "yaml"))]
408fn parse_yaml(path: &Path, _text: &str) -> Result<toml::Value, ConfigError> {
409 Err(parse_error(
410 path,
411 "`yaml` config files require the `yaml` feature",
412 ))
413}
414
415fn merge_tracked(
423 base: &mut toml::Value,
424 over: toml::Value,
425 origin: &Origin,
426 from_root: &mut Vec<String>,
427 provenance: &mut BTreeMap<String, Origin>,
428) {
429 match (base, over) {
430 (toml::Value::Table(base_table), toml::Value::Table(over_table)) => {
431 for (key, value) in over_table {
432 match base_table.get_mut(&key) {
433 Some(slot) => {
434 from_root.push(key);
435 merge_tracked(slot, value, origin, from_root, provenance);
436 from_root.pop();
437 }
438 None => {
439 from_root.push(key.clone());
440 record_provenance(from_root, origin, provenance);
441 base_table.insert(key, value);
442 from_root.pop();
443 }
444 }
445 }
446 }
447 (base, over) => {
448 if !from_root.is_empty() {
449 record_provenance(from_root, origin, provenance);
450 }
451 *base = over;
452 }
453 }
454}
455
456fn record_provenance(path: &[String], origin: &Origin, provenance: &mut BTreeMap<String, Origin>) {
459 provenance.insert(path.join("."), origin.clone());
460}
461
462#[cfg(test)]
463mod tests {
464 #![allow(clippy::unwrap_used, clippy::expect_used)]
465 use super::*;
466
467 #[test]
468 fn merge_tables_deep_and_replace_scalars() {
469 let mut base: toml::Value =
470 toml::from_str("flag = [1, 2]\n\n[db]\nurl = \"u\"\nport = 1\n").expect("base");
471 let over: toml::Value = toml::from_str("flag = 3\n\n[db]\nport = 2\n").expect("over");
472 let origin = Origin::File(PathBuf::from("over.toml"));
473 let mut provenance = BTreeMap::new();
474 merge_tracked(&mut base, over, &origin, &mut Vec::new(), &mut provenance);
475 let table = base.as_table().expect("table");
476 let db = table.get("db").and_then(toml::Value::as_table).expect("db");
477 assert_eq!(db.get("url"), Some(&toml::Value::String("u".into())));
478 assert_eq!(db.get("port"), Some(&toml::Value::Integer(2)));
479 assert_eq!(table.get("flag"), Some(&toml::Value::Integer(3)));
480 assert!(matches!(provenance.get("flag"), Some(Origin::File(_))));
484 assert!(matches!(provenance.get("db.port"), Some(Origin::File(_))));
485 assert!(
486 !provenance.contains_key("db.url"),
487 "unwritten values have no provenance"
488 );
489 assert!(
490 !provenance.contains_key("db"),
491 "table recursions don't record the parent"
492 );
493 }
494
495 #[test]
496 fn parse_by_extension_detects_formats() {
497 let toml_val = parse_by_extension(Path::new("a.toml"), "x = 1").expect("toml");
498 assert_eq!(toml_val.get("x"), Some(&toml::Value::Integer(1)));
499 let no_ext = parse_by_extension(Path::new("defaults"), "x = 1").expect("toml default");
500 assert_eq!(no_ext.get("x"), Some(&toml::Value::Integer(1)));
501 let upper = parse_by_extension(Path::new("b.TOML"), "x = 2").expect("case-insensitive");
502 assert_eq!(upper.get("x"), Some(&toml::Value::Integer(2)));
503 }
504
505 #[cfg(feature = "json")]
506 #[test]
507 fn parse_by_extension_json() {
508 let val = parse_by_extension(Path::new("a.json"), "{\"x\": 1}").expect("json");
509 assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
510 }
511
512 #[cfg(not(feature = "json"))]
513 #[test]
514 fn json_without_feature_is_parse_error() {
515 let err = parse_by_extension(Path::new("a.json"), "{}").expect_err("feature gate");
516 assert!(err.to_string().contains("`json` feature"));
517 }
518
519 #[cfg(feature = "yaml")]
520 #[test]
521 fn parse_by_extension_yaml() {
522 let val = parse_by_extension(Path::new("a.yaml"), "x: 1").expect("yaml");
523 assert_eq!(val.get("x"), Some(&toml::Value::Integer(1)));
524 let val = parse_by_extension(Path::new("a.yml"), "x: 2").expect("yml");
525 assert_eq!(val.get("x"), Some(&toml::Value::Integer(2)));
526 }
527
528 #[cfg(not(feature = "yaml"))]
529 #[test]
530 fn yaml_without_feature_is_parse_error() {
531 let err = parse_by_extension(Path::new("a.yaml"), "x: 1").expect_err("feature gate");
532 assert!(err.to_string().contains("`yaml` feature"));
533 }
534}