Add layered configuration to a Rust CLI¶
By the end you'll have a working command-line binary whose settings come from three places at once — defaults baked into the executable, a YAML file a user can edit, and environment variables for a single run — and you'll have watched each layer override the one beneath it.
Allow about fifteen minutes. Everything happens in a scratch project you can delete afterwards.
Before you start¶
You need a Rust toolchain — a current stable is safest, and rtb-config itself
declares 1.82 as its minimum — plus network access the first time, to fetch crates.
No other setup.
Create the project¶
Add the two dependencies:
cargo add prints the optional features rtb-config offers — hot-reload and
mutable. Leave both off; neither is needed here.
Describe the settings as a struct¶
rtb-config has no keys of its own. The keys are the fields of a struct you declare,
and the compiler checks every use of them.
Put this in src/main.rs:
use rtb_config::Config;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct MyConfig {
greeting: String,
http: Http,
}
#[derive(Debug, Deserialize)]
struct Http {
host: String,
port: u16,
}
http is a nested struct rather than a pair of flat http_host and http_port
fields. That matters later — nesting is what makes a setting reachable from an
environment variable.
Ship the defaults inside the binary¶
Create defaults.yaml next to Cargo.toml:
This file gets compiled into the executable, so it doesn't need to exist on the machine that runs your tool. It's the answer to "what does this setting mean if nobody has said otherwise".
Load the layers and print the result¶
Add a main that registers all three sources:
fn main() -> Result<(), rtb_config::ConfigError> {
let config = Config::<MyConfig>::builder()
.embedded_default(include_str!("../defaults.yaml"))
.user_file("mytool.yaml")
.env_prefixed("MYTOOL_")
.build()?;
let cfg = config.get();
println!("{}, serving on {}:{}", cfg.greeting, cfg.http.host, cfg.http.port);
Ok(())
}
Run it:
mytool.yaml doesn't exist yet, and that's fine — a missing config file contributes
no keys and is not an error. It's what lets a tool register a file path it hopes for
rather than one it demands.
Let a config file override one setting¶
Create mytool.yaml in the project directory with just the one key you want to
change:
The port changed and host didn't. Nested maps merge key by key, so a config file
only has to mention what it changes — it doesn't have to restate the whole http
section.
Override a setting for one run¶
Now set an environment variable, without touching the file:
The environment beat the file, which beat the embedded default. That order is fixed: it doesn't depend on the order you called the builder methods in.
Read the variable name from the inside out. The prefix MYTOOL_ is stripped, the
rest is lower-cased, and each remaining underscore is a level of nesting — so
MYTOOL_HTTP_PORT sets http.port.
That's also the catch worth knowing now rather than later: a field whose own name
contains an underscore can't be set from the environment at all. Had you declared a
flat http_port field, MYTOOL_HTTP_PORT would have been read as the nested key
http.port, matched nothing, and been ignored in silence. Nesting the struct is what
keeps the setting reachable.
Try a single-word key too:
See what a bad value does¶
Configuration fails at load, not at use. Give the port something that isn't a number:
Error: Parse("invalid type: found string \"eighty\", expected u16 for key \"HTTP.PORT\" in `MYTOOL_` environment variable(s)")
The process exits non-zero and never reaches your code. The message names the key and the layer it came from, which is usually enough to find it.
A misspelled key behaves differently, and it's worth seeing once. Add a line to
mytool.yaml:
No error. Keys the struct doesn't have are ignored, so a typo silently does nothing.
Delete that line before moving on. If your tool owns its config file exclusively you
can turn typos into errors by putting #[serde(deny_unknown_fields)] on the struct —
at the cost of rejecting keys a newer version of the tool might add.
Make the error message readable¶
That Error: Parse(...) line is the Debug formatting Rust uses for an error
returned from main. Every rtb-config error also carries a diagnostic code and a
help line, and miette renders them properly:
Change the signature of main:
Leave the body alone; ? converts the error for you. Run the failing case again:
Error: rtb::config::parse
× configuration error: invalid type: found string "eighty", expected u16 for
│ key "HTTP.PORT" in `MYTOOL_` environment variable(s)
help: check your config file and environment variables against the schema
Same failure, considerably more useful to whoever hits it.
What you built¶
Three layers, merged into one typed value, with the compiler checking every field access and bad input rejected at start-up:
| Layer | Set in | Wins over |
|---|---|---|
| Embedded default | defaults.yaml, compiled in |
nothing |
| User file | mytool.yaml on disk |
the embedded default |
| Environment | MYTOOL_* |
both |
Where to go next¶
- Layer configuration sources — a system file plus a per-user override, and what each failure mode does.
- Reload configuration at runtime — picking up a changed file without restarting.
- What rtb-config does not do — read this before you build on it.