How layering and precedence work¶
A tool needs a value for every setting whether or not the person running it has ever opened a config file. It also needs an operator to be able to change one setting for one run without editing anything. Layering is how both are true at once: several partial sources, merged into one complete value.
rtb-config has three kinds of layer.
The three layers, and what each is for¶
Embedded defaults are YAML compiled into the binary, usually an include_str! of
a file in the repository. They are the answer to "what does this setting mean if
nobody has said otherwise", and they ship with the code that reads them, so a new
setting and its default land in the same commit. Because they are &'static str,
they cannot come from disk.
User files are YAML on the local filesystem — the config a person edits. A
missing file contributes nothing and is not an error, which is what lets a tool
register /etc/mytool/config.yaml and ~/.config/mytool.yaml unconditionally and
let whichever exist take part.
Environment variables are the per-run override. They are read fresh on every
parse, so exporting one and calling reload() changes the running process without
touching a file. This is also the layer that container platforms and CI systems reach
for, which is why it sits on top.
Why the order is fixed rather than the order you register¶
Sources merge embedded defaults, then files, then environment variables,
regardless of the order the builder methods were called in. Registering
.env_prefixed() before .embedded_default() does not make the defaults win.
That is a deliberate flattening of a decision nobody benefits from making twice. The
alternative — precedence following call order — means the meaning of a config system
depends on the order of a fluent chain in main.rs, which is exactly the kind of
detail that gets reordered in a refactor and changes behaviour silently. Fixing the
order makes "the environment always wins" a property of the crate rather than of one
call site.
Within a single kind, order does matter and the last registration wins. Two
user_file calls layer the second over the first, which is how a system-wide file
plus a per-user override is expressed.
What the merge does to nested maps and lists¶
Merging is per key and goes all the way down through maps. A file containing only
layered over a default of
produces host: localhost and port: 9090. You override the key you care about, not
the section it lives in.
Lists do not work that way. A sequence in a higher layer replaces the whole sequence beneath it; there is no appending, and no way to add one element to a default list. If a setting needs to be extended rather than replaced, model it as a map keyed by name rather than as a list.
Why an unknown key does nothing instead of failing¶
A key that no field on your struct matches is dropped during deserialisation. That is
serde's default behaviour and rtb-config does not change it.
It is a real trade-off rather than an oversight. Ignoring unknown keys is what lets a
config file written for a newer version of a tool still load on an older one, and it
is what lets one file serve several tools. The cost is that a typo — tiemout for
timeout — is indistinguishable from a key meant for someone else, so it loads
cleanly and does nothing.
If your tool owns its config file exclusively, #[serde(deny_unknown_fields)] buys
the other trade: typos become loud, and forward compatibility goes away.
Why environment variables use underscores for nesting¶
A config struct is a tree; environment variables are a flat namespace of names that
can only hold [A-Z0-9_] in practice. Something has to encode depth, and the
underscore is the conventional choice — MYTOOL_HTTP_PORT reaching http.port is
what an operator expects.
The cost of that convention is precise and worth stating plainly: an underscore can
no longer appear inside a key name. A field called max_retries is unreachable from
the environment, because the name it produces is max.retries. That is not a bug in
the encoding, it is the encoding — but it does mean the shape of your config struct
decides what an operator can override, which is a design consideration when you name
a field. The limitations page
has the workarounds.
Where flags fit¶
They do not, inside this crate. Layering stops at the environment, and a
command-line flag that should beat everything is resolved by the tool after get().
That boundary is intentional in the wider toolkit: rtb-config answers "what is the
configured value", and the CLI layer above it decides whether an argument for this
one invocation should be used instead. Fusing the two — a flag that is also a config
key, bound automatically — is a different design, and one the Go toolkit takes; this
one keeps a transient argument and a persistent setting as separate things.
Next¶
- Layer configuration sources — the recipe
- API reference — the exact rules
- What rtb-config does not do