aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-01 06:07:12 +0100
committertv <tv@krebsco.de>2026-11-01 06:07:12 +0100
commit68b86927bea773ac0201f5c0618204af20660c47 (patch)
treeffeea47f242bde59cc74dce3d18d88f084e5e77c
parentdf74bf7026569514210982a2c7c3a4c36f469a32 (diff)
config: includes load in file order
-rw-r--r--src/config/file.rs94
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")
+ );
}
}