From 6b36e91036824e38a1f558579823ea648e188ac8 Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 1 Nov 2026 06:07:12 +0100 Subject: config: includes load in file order --- src/config/file.rs | 94 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 11 deletions(-) (limited to 'src/config/file.rs') diff --git a/src/config/file.rs b/src/config/file.rs index fbbd89a..bd6a3d5 100644 --- a/src/config/file.rs +++ b/src/config/file.rs @@ -2,9 +2,10 @@ //! configuration, with the same keys: the long option names. Nested tables join their //! keys with `-`, so `[mycelium] key-file = …` is `mycelium-key-file`. //! -//! `include = [paths]` and `include-optional = [paths]` load other files first -//! (relative to this one); this file's own values win over theirs, and later includes -//! over earlier ones. A missing optional include is skipped; a cycle is an error. +//! `include = [paths]` loads other files first (relative to this one), in the order +//! listed; an entry `{ path = "…", optional = true }` is skipped where missing. This +//! file's own values win over theirs, and later includes over earlier ones. A cycle is +//! an error. //! //! `unset = [options]` sets options back to their defaults, overriding includes and //! earlier files; in JSON, `null` does the same for one option. @@ -19,6 +20,7 @@ use super::import; use super::{Entry, Layer, Overlay, Provenance, UNSET, overlay, spec, unset_entry}; const INCLUDE: &str = "include"; +/// Gone: optional includes are entries of `include`, so the order is the list's. const INCLUDE_OPTIONAL: &str = "include-optional"; /// The settings `path` makes, its includes resolved. @@ -39,17 +41,18 @@ fn load_inner(path: &Path, loading: &mut Vec) -> Result { .unwrap_or_else(|| PathBuf::from("/")); let mut root = parse(&canonical)?; - let includes = take_paths(&mut root, INCLUDE, &canonical)?; - let optional = take_paths(&mut root, INCLUDE_OPTIONAL, &canonical)?; + if root.contains_key(INCLUDE_OPTIONAL) { + bail!( + "{}: include-optional is gone; list the file in include as {{ path = \"…\", optional = true }}, where it goes in the order", + canonical.display() + ); + } + let includes = take_includes(&mut root, &canonical)?; let unsets = take_paths(&mut root, UNSET, &canonical)?; loading.push(canonical.clone()); let mut layer = Layer::new(); - for (include, required) in includes - .into_iter() - .map(|p| (p, true)) - .chain(optional.into_iter().map(|p| (p, false))) - { + for (include, required) in includes { let include = dir.join(include); if !required && !include.exists() { debug!(file = %include.display(), "Optional configuration file is missing; skipped"); @@ -117,6 +120,50 @@ fn parse(path: &Path) -> Result> { } } +/// The files `include` names, in its order, and whether each is required: a path, or +/// `{ path = "…", optional = true }`. +fn take_includes( + root: &mut JsonMap, + path: &Path, +) -> Result> { + let items = match root.remove(INCLUDE) { + None => return Ok(Vec::new()), + Some(JsonValue::Array(items)) => items, + Some(single) => vec![single], + }; + items + .into_iter() + .map(|item| match item { + JsonValue::String(file) => Ok((file, true)), + JsonValue::Object(mut entry) => { + let file = match entry.remove("path") { + Some(JsonValue::String(file)) => file, + _ => bail!("{}: an include entry needs a path", path.display()), + }; + let optional = match entry.remove("optional") { + None => false, + Some(JsonValue::Bool(optional)) => optional, + Some(other) => bail!( + "{}: include's optional is true or false, not {other}", + path.display() + ), + }; + if let Some(key) = entry.keys().next() { + bail!( + "{}: unknown key {key:?} in an include entry", + path.display() + ); + } + Ok((file, !optional)) + } + other => Err(anyhow!( + "{}: include entries are paths or {{ path, optional }}, not {other}", + path.display() + )), + }) + .collect() +} + fn take_paths( root: &mut JsonMap, key: &str, @@ -195,7 +242,7 @@ mod tests { let main = write( &dir, "main.toml", - "include = [\"public.toml\", \"secrets.json\"]\ninclude-optional = [\"missing.toml\"]\nalias = \"main\"\n", + "include = [\"public.toml\", \"secrets.json\", { path = \"missing.toml\", optional = true }]\nalias = \"main\"\n", ); let layer = load(&main).unwrap(); assert_eq!(layer["neighbours"].value, json!(8)); @@ -248,5 +295,30 @@ mod tests { assert!(load(&missing).is_err()); let format = write(&dir, "config.yaml", "alias: x\n"); assert!(load(&format).is_err()); + let gone = write(&dir, "gone.toml", "include-optional = [\"x.toml\"]\n"); + assert!(load(&gone).is_err()); + } + + /// Includes are loaded in the order listed, optional ones too: the later wins. + #[test] + fn includes_in_order() { + let dir = scratch_dir("config-include-order"); + write(&dir, "local.toml", "alias = \"local\"\n"); + write(&dir, "secrets.toml", "alias = \"secrets\"\n"); + let local_last = write( + &dir, + "a.toml", + "include = [\"secrets.toml\", { path = \"local.toml\", optional = true }]\n", + ); + assert_eq!(load(&local_last).unwrap()["alias"].value, json!("local")); + let secrets_last = write( + &dir, + "b.toml", + "include = [{ path = \"local.toml\", optional = true }, \"secrets.toml\"]\n", + ); + assert_eq!( + load(&secrets_last).unwrap()["alias"].value, + json!("secrets") + ); } } -- cgit v1.3.1