Skip to content

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

http:
  port: 9090

layered over a default of

http:
  host: localhost
  port: 8080

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