rtb-config 0.6.4

Layered, typed configuration backed by figment. Part of the phpboyscout Rust toolkit.
Documentation
---
title: rtb-config
description: Typed, layered configuration via figment — embedded defaults, user files, and env vars merged into your Deserialize struct with atomic reload.
---

# `rtb-config`

Typed, layered configuration backed by [figment]. `Config<C>` is a
generic container over *your* `serde::Deserialize` struct — populated by
layering sources through figment and snapshot-swapped atomically via
[arc-swap].

Part of the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk); extracted from —
and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base). It carries no
dependency back on the framework, so it works in any Rust project.

```toml
[dependencies]
rtb-config = "0.6"
```

## Where to start

| You want to | Go to |
|---|---|
| Learn the crate by building something | [Add layered configuration to a Rust CLI]tutorials/add-layered-configuration.md |
| Do one specific task | [How-to guides]how-to/index.md |
| Look up a method, an error or a feature | [Reference]reference/index.md |
| Understand why it works this way | [Explanation]explanation/index.md |
| Know what it *won't* do | [What rtb-config does not do]explanation/limitations.md |

## Typed, not dynamic

Many config layers expose dynamic accessors — `GetString("foo.bar")`
style. `rtb-config` deliberately rejects that pattern. Rust gives us
compile-time checking for free: declare a struct, derive `Deserialize`,
and let `cargo check` catch every mistyped field across every call site.
Hierarchical access uses nested structs; there is no `Sub()` or
`get_string()` API.

```rust
use rtb_config::Config;
use serde::Deserialize;

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

let cfg = Config::<MyConfig>::builder()
    .embedded_default("host: localhost\nport: 8080\n")
    .env_prefixed("MYTOOL_")
    .build()?;

let current = cfg.get();
assert_eq!(current.host, "localhost");
```

The reasoning, and what the choice costs, is in
[why there is no `get_string("foo.bar")`](explanation/typed-not-dynamic.md).

## Layered precedence

Sources merge last-wins:

**embedded default → user file → env vars (`<PREFIX>_*`)**

So `MYTOOL_PORT=9999` beats `port: 9090` in the user file, which beats
`port: 8080` in the embedded default. That order is fixed — it does not depend on the
order the sources were registered in. Underscore is the key separator for environment
variables, so `MYTOOL_HTTP_PORT=8080` populates a nested `http.port` field; for the
same reason a *flat* field named `http_port` cannot be set from the environment at
all.

Missing files are **not** an error — an absent file contributes no keys, while a path
that exists but is not a regular file surfaces as `ConfigError::Io`. Keys your struct
does not have are ignored silently.

More: [how layering and precedence work](explanation/layering-and-precedence.md) and
[layer configuration sources](how-to/layer-configuration-sources.md).

## Atomic reload and subscriptions

`Config::reload()` re-reads every source and atomically swaps the stored
value via `arc_swap::ArcSwap`. Readers holding a pre-reload `Arc<C>`
snapshot keep their consistent view until they ask for a new one —
snapshots never tear, and the read path takes no locks. A reload that fails to parse
leaves the stored value untouched and wakes nobody.
`Config::subscribe()` returns a `tokio::sync::watch::Receiver` that
wakes on every successful reload.

More: [why configuration is a snapshot](explanation/snapshots-and-reload.md) and
[reload configuration at runtime](how-to/reload-configuration-at-runtime.md).

## Features

| Feature | Adds | Cost |
|---|---|---|
| `hot-reload` | `Config::watch_files()` — a debounced background watcher (notify) that calls `reload()` on change and hands back a `WatchHandle` to stop it. | `notify`, `notify-debouncer-full` |
| `mutable` | `Config::schema()` (JSON Schema for `C` via schemars) and `Config::write()` (write the current value to YAML / TOML / JSON, chosen by extension). | `schemars`, `serde_json`, `serde_yaml`, `toml` |

Both are default-off so tools that don't need them don't pay the
dependency weight. See [Cargo features](reference/cargo-features.md).

Before relying on the watcher, read
[which file changes it actually sees](explanation/limitations.md#which-file-changes-does-the-watcher-actually-see)
— a save that replaces the file rather than writing into it stops it.

Full API reference: [reference/api.md](reference/api.md), with generated rustdoc on
[docs.rs/rtb-config](https://docs.rs/rtb-config).

## Roadmap

A v2 **Store architecture** is approved and will land in this repository. It adds the
two things this crate cannot do today: **value provenance** — asking which layer
supplied a given field — and **structure-preserving writes**, so editing a setting
keeps the comments and ordering of the file a person wrote. Neither exists in any
released version; the figment-based v1 documented here is the supported surface until
they do.

[figment]: https://crates.io/crates/figment
[arc-swap]: https://crates.io/crates/arc-swap

## Further reading

The blog carries a curated route through this subject: **[Rust, and what survived the port](https://phpboyscout.uk/topics/rust/)** collects
everything written about it, ordered so you can start at the beginning rather
than newest-first.

!!! tip "Ask phpbotscout"

    ![phpbotscout](https://phpboyscout.uk/images/projects/logo-phpbotscout.png){ width="84" align=left style="border-radius:10px;margin-right:1rem" }

    He answers questions about the projects over on the Discord, citing the docs
    where they already cover it, and offering to raise an issue where they don't.
    Bring a bug, an idea, or a questionable engineering decision.

    [Join the Discord](https://discord.gg/mQzGbmGyzZ){ .md-button .md-button--primary }