Skip to content

Merge layered configuration

You want a config file whose defaults ship inside the binary, where a user's override file states only the keys it changes.

Load the merged document

Register the layers lowest priority first, then read with load_merged_yaml:

use rtb_assets::Assets;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Config {
    name: String,
    palette: Palette,
    #[serde(default)]
    plugins: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct Palette {
    primary: String,
    accent: String,
}

let assets = Assets::builder()
    .embedded::<Defaults>("defaults")
    .directory(user_config_dir, "user")
    .build();

let config: Config = assets.load_merged_yaml("config.yaml")?;

load_merged_json is the same call for JSON input. Both read the file from every layer that has it, unlike open, which stops at the highest.

If the user's file mentions only palette.accent, everything else comes from the embedded default. That is the whole feature.

Design the schema so the merge behaves

Four rules, each of which exists because of a real RFC 7396 behaviour described in Merge semantics.

Make optional fields Option<T> or #[serde(default)]. A merged document is assembled from several files and no single one of them is guaranteed to be complete. Required fields must be present in the lowest layer, which is the one you ship.

Never ask a user to write null. In a merge patch, null deletes the key instead of setting it. If a user needs to switch something off, give them a value that says so — mode: off, timeout: 0 — not an absence.

Prefer maps over arrays for anything a user might extend. An array in a higher layer replaces the array below it entirely, so a user who adds one plugin loses every default plugin and never picks up new ones you add in later releases. Keyed maps merge:

// Extensible: a user's `plugins.lint.enabled: false` leaves the rest alone.
#[derive(Deserialize)]
struct Config {
    #[serde(default)]
    plugins: std::collections::BTreeMap<String, Plugin>,
}

Keep an array only where wholesale replacement is what you want — an ordered search path, say, where merging two orders is meaningless.

Ship the override template as {}, not as an empty file. An empty YAML document parses to null and wipes everything below it. An empty map is a valid no-op patch.

Give the user a starting file

If your tool writes a starter override, write a commented map rather than a bare comment block:

{}
# Uncomment and edit to override the shipped defaults.
# palette:
#   accent: "#E8912A"

The {} on the first line is what keeps the file harmless while it is still all comments.

Handle the failure cases

use rtb_assets::AssetError;

match assets.load_merged_yaml::<Config>("config.yaml") {
    Ok(config) => config,
    // Nothing anywhere — usually means the embedded default is missing
    // from the folder, since that layer should always have it.
    Err(AssetError::NotFound(path)) => {
        return Err(miette::miette!("no config asset at {path}"));
    }
    // Either a layer's file is malformed, or the merged result does not
    // fit Config. The error text distinguishes them: a per-layer failure
    // names the layer in its path.
    Err(e) => return Err(miette::Report::new(e)),
}

A malformed override does not silently fall back to the defaults. That is intentional: a user whose file has a typo should be told, not quietly ignored. Print the error rather than swallowing it.

What this does not give you

  • No provenance. The merged Config cannot tell you which file a particular value came from.
  • No reload. The document is read once, when you call it. Reading it again re-reads the files; nothing watches them.
  • No formats beyond YAML and JSON. TOML config can be carried as an asset and read with open_text, but it shadows rather than merges.