rtb-config 0.6.5

Layered, typed configuration backed by figment. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Add layered configuration to a Rust CLI
description: Build a small CLI from scratch that reads defaults compiled into the binary, lets a config file override them, and lets an environment variable override both.
---

# 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 and, the first time, network access to fetch crates. A
current stable is safest; `rtb-config` itself declares 1.89 as its minimum.
No other setup.

## Create the project

```bash
cargo new mytool
cd mytool
```

Add the two dependencies:

```bash
cargo add rtb-config
cargo add serde --features derive
```

`cargo add` prints the two 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`:

```rust
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, because nesting is what makes a setting reachable from an
environment variable.

## Ship the defaults inside the binary

Create `defaults.yaml` next to `Cargo.toml`:

```yaml
greeting: Hello
http:
  host: localhost
  port: 8080
```

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:

```rust
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:

```bash
cargo run
```

```text
Hello, serving on localhost:8080
```

`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:

```yaml
http:
  port: 9090
```

```bash
cargo run
```

```text
Hello, serving on localhost:9090
```

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:

```bash
MYTOOL_HTTP_PORT=3000 cargo run
```

```text
Hello, serving on localhost:3000
```

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:

```bash
MYTOOL_GREETING="Good morning" cargo run
```

```text
Good morning, serving on localhost:9090
```

## See what a bad value does

Configuration fails at load, not at use. Give the port something that isn't a number:

```bash
MYTOOL_HTTP_PORT=eighty cargo run
```

```text
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`:

```yaml
http:
  port: 9090
prot: 1234
```

```bash
cargo run
```

```text
Hello, serving on localhost:9090
```

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:

```bash
cargo add miette --features fancy
```

Change the signature of `main`:

```rust
fn main() -> miette::Result<()> {
```

Leave the body alone; `?` converts the error for you. Run the failing case again:

```bash
MYTOOL_HTTP_PORT=eighty cargo run
```

```text
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]../how-to/layer-configuration-sources.md: a system
  file plus a per-user override, and what each failure mode does.
- [Reload configuration at runtime]../how-to/reload-configuration-at-runtime.md:
  picking up a changed file without restarting.
- [What rtb-config does not do]../explanation/limitations.md: read this before you
  build on it.