Skip to content

Layer configuration sources

Register the sources your tool should read, in one builder chain, and let the merge produce a single typed value.

Register defaults, a file and an environment prefix

use rtb_config::Config;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct MyConfig {
    host: String,
    port: u16,
}

let cfg = Config::<MyConfig>::builder()
    .embedded_default(include_str!("../defaults.yaml"))
    .user_file("/etc/mytool/config.yaml")
    .env_prefixed("MYTOOL_")
    .build()?;

let current = cfg.get();
println!("{}:{}", current.host, current.port);

Nothing is read until build(). The builder methods only record what to read.

The order of the calls does not set precedence — embedded defaults are always overridden by files, and files by the environment, whichever order you registered them in. How layering and precedence work covers why.

Ship the defaults as a file in the repository

embedded_default takes a &'static str, so the practical way to supply real defaults is include_str!:

.embedded_default(include_str!("../defaults.yaml"))

Keeping them in defaults.yaml rather than a string literal means they are syntax-highlighted, reviewable, and can be shipped as an example config for users to copy. They are compiled in, so the file does not have to exist at runtime.

Support a system file and a per-user override

Register both. Later registrations of the same kind win, and a file that does not exist contributes nothing and is not an error — so you can register both unconditionally:

let mut builder = Config::<MyConfig>::builder()
    .embedded_default(include_str!("../defaults.yaml"))
    .user_file("/etc/mytool/config.yaml");

// wherever your tool decides the per-user path lives
if let Some(path) = user_config_path() {
    builder = builder.user_file(path);
}

let cfg = builder.env_prefixed("MYTOOL_").build()?;

The per-user file overrides the system file key by key: setting only port in the user file leaves host as the system file left it.

Override one section without repeating the rest

Nested maps merge, so a file only needs the keys it changes:

# defaults.yaml
http:
  host: localhost
  port: 8080
  timeout: 30
# /etc/mytool/config.yaml
http:
  port: 9090

The result has host: localhost, port: 9090, timeout: 30.

Lists are the exception — a list in a later layer replaces the whole list rather than adding to it. There is no way to append one element to a default list.

Check which value actually won

There is no provenance API, so the way to check a merge is to print the merged result. Derive Debug on your config struct and print the snapshot:

println!("{:#?}", cfg.get());

Printing the Config itself instead shows only the sources — file paths, environment prefixes and the number of embedded layers — never the values:

Config { files: ["/etc/mytool/config.yaml"], env_prefixes: ["MYTOOL_"], embedded_layers: 1, .. }

That is useful for "is it even reading the file I think it is", and it is deliberate that the values are absent: a Config in a debug log cannot leak a token held in a config field. Your own struct's Debug has no such protection, so do not print the snapshot if it holds secrets.

Handle a missing file, a bad file and a bad value

Situation What happens
A registered file does not exist Skipped silently, no error
A registered path is a directory ConfigError::Io, carrying the path
A file exists but cannot be read ConfigError::Parse, wrapping the OS error
A file is not valid YAML ConfigError::Parse
A value does not fit the field's type ConfigError::Parse, naming the key and layer
A key your struct does not have Ignored, silently

The last row is the one that costs people time. A misspelled key is not an error; it simply has no effect. If your tool owns its config file, add #[serde(deny_unknown_fields)] to your struct — with the caveat about environment prefixes.

Next