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¶
Add three dependencies to Cargo.toml:
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:
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:
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:
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:
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:
And print it — add this line just above the let config line in
main.rs:
Assets { layers: ["defaults", "user"] }
assets: ["banner.txt", "config.yaml"]
banner: == greeter ==
Hello world!
palette: petrol / pink
Now override it from disk:
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.
What to read next¶
- Merge layered configuration
— how to shape a config schema so the merge does what users expect.
Arrays and
nullboth have sharp edges worth knowing before you design one. - How layer precedence works — why byte reads and merged reads answer the same stack differently.
- Diagnose a failed asset lookup — for the first time a read returns nothing and you can't see why.