Skip to content

Configuration

RTB's configuration system is built on figment with one strong opinion: configuration is typed. The caller defines a serde::Deserialize struct, the framework populates it by layering sources, and access is via struct field, not string key.

Layers, in precedence order

From lowest to highest priority — later layers override matching keys from earlier ones:

  1. Embedded defaults — a YAML string baked into the binary with include_str! and added via .embedded_default(yaml).
  2. User files — paths on disk added via .user_file(path). Missing files are silently ignored (contribute no keys); present but malformed YAML is an error. Directory paths — where a regular file was expected — return ConfigError::Io.
  3. Env vars — added via .env_prefixed(prefix). Underscores nest: MYTOOL_HTTP_PORT=80 populates http.port on the config struct, with the MYTOOL_ prefix stripped.

Building a Config<C>

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

[derive(Default, Deserialize)]

struct MyConfig { host: String, port: u16, http: HttpSection, }

[derive(Default, Deserialize)]

struct HttpSection { max_body_bytes: u64, }

let cfg: Config = Config::builder() .embedded_default(include_str!("defaults.yaml")) .user_file("/etc/mytool/config.yaml") .env_prefixed("MYTOOL_") .build()?; ```

cfg.get() returns Arc<MyConfig> — a snapshot of the current value. Clone the Arc, keep it across awaits, pass it to tasks; it costs a refcount bump.

Atomic reload

cfg.reload() re-reads every source and swaps the stored value via arc_swap::ArcSwap. Callers that held an Arc<MyConfig> snapshot keep their old view until they ask for a new one — no tearing.

Hot reload on file change

Automatic reload has shipped. It is opt-in behind rtb-config's hot-reload Cargo feature, and it is deliberately opt-in rather than default: a watcher costs a background thread and OS handles, which is the wrong trade for a short-lived CLI invocation and the right one for a long-running service.

Config::watch_files() starts a debounced watcher over every path registered with user_file, calls reload() when one changes, and wakes every Config::subscribe() receiver on a successful reload. It returns a WatchHandle; dropping the handle stops the watcher, so keep it alive for as long as you want reloading.

Two things to know before relying on it. The debounce window is 250 ms, chosen so that editors which save by rename-and-replace produce one reload rather than several. And calling watch_files() with no user_file paths registered is an error — ConfigError::Watch("no user files registered") — not a silently idle watcher.

Why no get_string("foo.bar")?

Go Tool Base ships a Viper-backed Containable interface with GetString("foo.bar") accessors. RTB deliberately does not. Rust gives us compile-time checking for free:

  • cfg.get().http.port fails at compile time if port isn't a u16.
  • A renamed field surfaces as a build error in every call site, not a runtime None or a panic.
  • Refactors are safe because cargo check catches every referent.

String-keyed access is strictly worse in Rust than a struct-field chain, so we don't provide it.

Generic parameter default

Config<C = ()> — when a downstream crate (notably rtb-app's App) holds an Arc<Config> without a type parameter, C defaults to (). Tool authors that need typed config use Config<MyConfig> explicitly.

When the framework's App eventually becomes App<C> (post-0.1), the ergonomics of the generic will carry through — App<MyConfig> holds Arc<Config<MyConfig>>.

What can go wrong, and what you get back

ConfigError is #[non_exhaustive], so match with a fallback arm. The variants you will actually meet:

  • ConfigError::Parse(String) — figment rejected the merged sources. Missing required field, type mismatch, malformed YAML. The string message names the offending field or file.
  • ConfigError::Io { path, source } — the user_file(path) existed but wasn't a regular file, a directory for example. A merely missing file is not an error.
  • ConfigError::Watch(String) — the file watcher could not start: no user-file paths registered, an OS handle limit, or a failure from the notify backend. Only constructable under the hot-reload feature, but the variant is always present so your match does not need cfg-gating.
  • ConfigError::Write(String) — serialisation or filesystem failure writing the merged value back to disk. Same cfg story, under the mutable feature.

All derive miette::Diagnostic under the rtb::config::* namespace, so they render with a code and a help line rather than as a bare string.