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) and 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, bearing in mind the caveat about environment prefixes.

Next