diff options
| author | tv <tv@krebsco.de> | 2026-11-01 06:07:12 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-01 06:07:12 +0100 |
| commit | a14792d985fa0bbcf2f916929e3804ed661bd757 (patch) | |
| tree | 0463a882207926738f2ffc5b8506d4f8c9e93272 /src/config | |
| parent | ac81f5a68bf3259d1e0c49c93b2f84635b873824 (diff) | |
config: includes load in file order
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/file.rs | 94 |
1 files changed, 83 insertions, 11 deletions
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<PathBuf>) -> Result<Layer> { .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<JsonMap<String, JsonValue>> { } } +/// The files `include` names, in its order, and whether each is required: a path, or +/// `{ path = "…", optional = true }`. +fn take_includes( + root: &mut JsonMap<String, JsonValue>, + path: &Path, +) -> Result<Vec<(String, bool)>> { + 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<String, JsonValue>, 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") + ); } } |
