Skip to content

Ship defaults a user can override

By the end of this you'll have a binary that carries its own default config and a banner file inside the executable, and picks up a user's overrides from a directory beside it. You'll see both ways a layer can win: replacing a whole file, and replacing one key in a config while the rest keeps tracking the shipped defaults.

Allow about fifteen minutes. Everything happens in one throwaway project and nothing is installed system-wide.

Before you start

You need Rust 1.82 or newer (rustc --version) and a terminal. No network access beyond cargo fetching crates the first time, which pulls about a dozen small dependencies.

Create the project

cargo new greeter
cd greeter

Add three dependencies to Cargo.toml:

[dependencies]
rtb-assets = "0.6"
rust-embed = "8"
serde = { version = "1", features = ["derive"] }

rust-embed is what actually compiles files into the binary. rtb-assets layers it with anything else you want to read from.

Write the defaults you want to ship

Assets that get embedded live in a folder inside the crate. Create it and put a config file in:

mkdir assets
assets/config.yaml
greeting: "Hello"
punctuation: "!"
palette:
  primary: "petrol"
  accent: "amber"

The folder has to exist when you compile — rust-embed reads it at build time and a missing folder is a compile error, not a runtime one.

Build the overlay and read from it

Replace src/main.rs with this:

src/main.rs
use rtb_assets::Assets;
use serde::Deserialize;

#[derive(rust_embed::RustEmbed)]
#[folder = "assets/"]
struct Defaults;

#[derive(Debug, Deserialize)]
struct Config {
    greeting: String,
    punctuation: String,
    palette: Palette,
}

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

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let assets = Assets::builder()
        .embedded::<Defaults>("defaults")
        .directory("overrides", "user")
        .build();

    println!("{assets:?}");
    println!("assets: {:?}", assets.list_dir(""));

    let config: Config = assets.load_merged_yaml("config.yaml")?;
    println!("{} world{}", config.greeting, config.punctuation);
    println!("palette: {} / {}", config.palette.primary, config.palette.accent);
    Ok(())
}

Two layers, registered lowest priority first: the embedded defaults, then a directory called overrides that doesn't exist yet. A missing directory is fine — that layer simply contributes nothing until the user creates it.

Note that "overrides" is a relative path, so it resolves against wherever the binary is run from. That's convenient for a tutorial and wrong for a real tool, which should point at the user's config directory instead. Run everything below from the project root.

Run it:

cargo run
Assets { layers: ["defaults", "user"] }
assets: ["config.yaml"]
Hello world!
palette: petrol / amber

The first line is the layer stack in priority order, lowest first — worth remembering, because it's the fastest way to debug a lookup later on. The second is every file across every layer.

Override one key and watch the rest hold

Now be the user. Create the override directory and change exactly one thing:

mkdir overrides
overrides/config.yaml
palette:
  accent: "pink"
cargo run
Assets { layers: ["defaults", "user"] }
assets: ["config.yaml"]
Hello world!
palette: petrol / pink

The accent changed. greeting, punctuation and palette.primary came from the embedded file, which the override never mentioned. That's load_merged_yaml doing a deep merge across both layers rather than picking a winner.

This is the behaviour worth having: the user's file states a difference. When you ship a new default next release, they get it, because they never copied the parts they didn't want to change.

One thing to know before you leave that file empty. An empty YAML file — or one containing only comments — parses as null, and a null override wipes everything underneath it. Try it if you like: blank overrides/config.yaml and run again.

Error: Parse { path: "config.yaml", format: "YAML", message: "invalid type: null, expected struct Config" }

If you ship a starter override file, put {} on the first line so it's a valid empty patch. Then restore the two lines above before carrying on.

Add a file that gets replaced whole

Config merges. Most other things shouldn't. Add a banner to the embedded assets:

echo "== greeter ==" > assets/banner.txt

And print it — add this line just above the let config line in main.rs:

    println!("banner: {}", assets.open_text("banner.txt")?);
cargo run
Assets { layers: ["defaults", "user"] }
assets: ["banner.txt", "config.yaml"]
banner: == greeter ==

Hello world!
palette: petrol / pink

Now override it from disk:

echo "== my greeter ==" > overrides/banner.txt
cargo run
banner: == my greeter ==

open_text doesn't merge. It walks the stack from the top and takes the first layer that has the file, whole. There's no sensible way to combine two banners, or two PNGs, so it doesn't try.

Notice the listing still shows banner.txt once. list_dir unions every layer and deduplicates, so a file present twice appears once — and it won't tell you which layer supplied it.

What you've built

  • Defaults compiled into the binary, so the executable stands alone.
  • A disk layer that overrides them, and that costs nothing when the user hasn't created it.
  • Two override behaviours from one stack: whole-file for blobs, key by key for config.

Where the shipped assets actually live

They're inside the executable, not in assets/ at runtime. This crate turns on rust-embed's debug-embed feature, so that holds in debug builds too — editing assets/config.yaml does nothing until you rebuild. If you want live editing while developing, add a DirectorySource pointing at assets/ above the embedded layer; the disk copy will win.