Skip to content

API reference

Everything rtb-config exposes, with the behaviour each item actually has. Verified against the crate source at v0.6.3.

Does rtb-config have its own configuration keys?

No. rtb-config has no keys, no flags and no environment variables of its own. The keys are the field names of your serde::Deserialize struct, and the environment variables are whatever prefix you register with ConfigBuilder::env_prefixed.

The knobs the crate itself owns are its two Cargo features — see Cargo features.

What the crate exports

Item Kind Feature Purpose
Config<C = ()> struct Holds the parsed value and the sources it came from
ConfigBuilder<C> struct Registers sources, then parses them
ConfigError enum Every failure the crate can return
WatchHandle struct hot-reload Owns a running file watcher; drop it to stop

The modules rtb_config::config, rtb_config::error and (under hot-reload) rtb_config::watch are public, but everything worth naming is re-exported at the crate root:

use rtb_config::{Config, ConfigBuilder, ConfigError};
#[cfg(feature = "hot-reload")]
use rtb_config::WatchHandle;

Which trait bounds does my config struct need?

Config<C> requires C: DeserializeOwned + Send + Sync + 'static everywhere. Two methods ask for more, and they only exist when the bound is met:

Method Extra bounds Feature
Config::write serde::Serialize, schemars::JsonSchema mutable
Config::schema serde::Serialize, schemars::JsonSchema mutable
Config::default Default

If Config::<MyConfig>::write does not exist for your type, the missing piece is almost always #[derive(Serialize, JsonSchema)] rather than the feature flag.

C defaults to (), so Config with no angle brackets means Config<()>. That exists so downstream code holding an Arc<Config> does not have to carry a type parameter it never uses.

Config::builder — start registering sources

pub fn builder() -> ConfigBuilder<C>

Equivalent to ConfigBuilder::<C>::new(). Nothing is read from disk or the environment until build is called.

Config::with_value — wrap a value with no sources behind it

pub fn with_value(value: C) -> Self

Stores value directly. The resulting Config has an empty source list, which has one consequence worth knowing before you use it in a test:

calling reload() on it re-parses nothing, so the stored value is replaced by whatever an empty source set deserialises into. For a struct whose fields all carry #[serde(default)] that is C::default() — the value you passed in is gone. For a struct with a required field it is ConfigError::Parse.

Use builder().embedded_default(...) instead when a test needs a value that survives a reload.

Config::get — take a snapshot of the current value

pub fn get(&self) -> Arc<C>

Returns a snapshot. Cheap: an atomic load and an Arc clone, no parsing, no locks.

A snapshot is immutable and complete. Hold one across a reload() and you keep seeing the pre-reload value for as long as you hold it; the next get() returns the post-reload value. There is no state in which half the fields are old and half are new.

Config::reload — re-read every source

pub fn reload(&self) -> Result<(), ConfigError>

Re-reads every registered source — embedded strings, files, and environment variables — merges them again, and swaps the result in atomically. Environment variables are read at reload time, not cached from build(), so a variable set after startup takes effect on the next reload.

On success, every subscribe receiver is woken.

On failure the stored value is left untouched and no subscriber is woken. A config file that has been saved mid-edit and does not parse cannot take a running process down to defaults.

Config::subscribe — be woken when the value changes

pub fn subscribe(&self) -> tokio::sync::watch::Receiver<Arc<C>>

The receiver holds the current value immediately — rx.borrow() is valid before any reload has happened — and rx.changed().await resolves after each successful reload.

Subscribing late is safe. A receiver created after several reloads sees the newest value, not the value the process started with, and dropping every receiver does not break subsequent reloads.

Config::watch_files — reload automatically when a file changes

#[cfg(feature = "hot-reload")]
pub fn watch_files(&self) -> Result<WatchHandle, ConfigError>

Starts one background thread (named rtb-config-watcher) watching every path registered with user_file, non-recursively. File-system events are debounced for 250 ms — a fixed constant, not configurable — and each batch calls reload(). A failing reload is swallowed so a bad save does not kill the watcher.

Returns ConfigError::Watch when:

  • no user_file path was registered, or
  • a registered path does not exist yet — the underlying watcher refuses to watch a path that is not there, so create the file before calling this, or
  • the OS refuses another watch (handle limits, unsupported filesystem).

The handle is #[must_use]: dropping it immediately stops the watcher. Keep it alive for as long as you want reloads.

Read the limitations before relying on this in production — a save that replaces the file rather than writing into it stops the watcher permanently.

Config::schema — emit a JSON Schema for the config struct

#[cfg(feature = "mutable")]
pub fn schema() -> serde_json::Value

An associated function — there is no &self, so it can be called before any config is loaded. Generates the schema from C's JsonSchema derive at call time; there is no caching, and it is cheap enough for CLI-startup use.

The returned object carries the usual top-level keys — $schema, title, type and properties. If serialisation somehow fails, the function returns serde_json::Value::Null rather than panicking.

Config::write — write the current value to a file

#[cfg(feature = "mutable")]
pub fn write(&self, path: &Path) -> Result<(), ConfigError>

Serialises the merged, current value — the same thing get() returns — and writes it to path. Missing parent directories are created.

Format is chosen by extension:

Extension Format written
.yml, .yaml YAML
.toml TOML
.json JSON
anything else, or none YAML

Two things to know before you wire this to a config set command:

  • What is written is the merged result, not just the file layer. Any environment override active at the time is baked into the file as a literal value, and so are the embedded defaults. Comments and key ordering in an existing file are not preserved — the file is replaced.
  • Only the YAML output can be read back by ConfigBuilder::user_file, which parses every file as YAML. JSON survives the round trip because YAML is a superset of it; TOML does not. See limitations.

The write is not atomic. If a torn file would matter, write to a temporary path and rename it yourself.

Errors are ConfigError::Write for both serialisation and filesystem failures, including failing to create the parent directory.

Config trait implementations

Impl Behaviour
Clone Cheap. Clones share the same stored value, sources and subscriber channel — a reload through one handle is visible through all of them.
Default Requires C: Default. Stores C::default() with no sources, so it behaves like with_value for reload purposes.
Debug Prints the source inventory only — file paths, env prefixes and the number of embedded layers. The stored value is deliberately never rendered, so a Debug log of a Config cannot leak a secret held in a config field.

Example of the Debug output:

Config { files: ["/etc/mytool/config.yaml"], env_prefixes: ["MYTOOL_"], embedded_layers: 1, .. }

ConfigBuilder::embedded_default — bake defaults into the binary

pub fn embedded_default(mut self, yaml: &'static str) -> Self

Takes a &'static str of YAML, which in practice means a literal or an include_str!. Call it more than once and the layers merge in call order, the later call winning on any key both define.

ConfigBuilder::user_file — read a YAML file from disk

pub fn user_file(mut self, path: impl Into<PathBuf>) -> Self

Registers a file path. Paths are read in registration order and later files win on shared keys.

Situation Result
File does not exist Not an error — contributes no keys
File exists and parses Merged over the layers before it
File exists, is not valid YAML ConfigError::Parse
Path exists but is a directory ConfigError::Io, carrying the path
File exists but is unreadable (permissions) ConfigError::Parsenot Io

The file is always parsed as YAML regardless of its extension.

ConfigBuilder::env_prefixed — read prefixed environment variables

pub fn env_prefixed(mut self, prefix: impl Into<String>) -> Self

Registers an environment-variable layer. Matching is case-insensitive on the variable name, the prefix is stripped, the remainder is lower-cased, and each remaining underscore becomes a level of nesting:

Variable Prefix Populates
MYTOOL_PORT=8080 MYTOOL_ port
MYTOOL_HTTP_PORT=8080 MYTOOL_ http.port
mytool_port=8080 MYTOOL_ port

Values are parsed, not taken as raw strings: true becomes a boolean, 8080 a number, and [alpha,beta] a two-element list. A value that cannot be parsed into the field's type fails with ConfigError::Parse, naming both the key and the prefix.

Two things bite here, both silent:

  • Include the trailing underscore in the prefix. env_prefixed("MYTOOL") with MYTOOL_PORT set leaves a stray leading underscore on the key and the variable is ignored — no error, no warning.
  • A field name containing an underscore cannot be set from the environment at all, because that underscore is read as nesting. See limitations.

Register several prefixes if you want to accept more than one; later registrations win.

ConfigBuilder::build — parse every source and construct the Config

pub fn build(self) -> Result<Config<C>, ConfigError>

Reads and merges every registered source, deserialises the result into C, and returns the Config. This is the point at which files are opened and environment variables are read — the builder methods only record intent.

A builder with no sources at all is valid. It deserialises an empty document, which succeeds for a struct whose fields all have defaults and fails with ConfigError::Parse for one that has a required field.

In what order are sources merged?

Embedded defaults, then files, then environment variables — always, regardless of the order you called the builder methods in. Registering .env_prefixed() before .embedded_default() does not make the embedded layer win; the environment still overrides it.

Within a single kind, registration order applies and the last one wins.

Merging is per key and goes into nested maps: a file that sets only http.port leaves http.host from the layer beneath it intact. Sequences are not merged — a list in a later layer replaces the whole list, it does not append to it.

What happens to a key my struct does not have?

It is ignored, silently and successfully. port: 8080 next to a misspelled tiemout: 30 deserialises fine and the typo does nothing. rtb-config has no strict mode of its own that turns unknown keys into errors.

#[serde(deny_unknown_fields)] on your struct does turn them into a ConfigError::Parse, and it works through every layer — but it is blunter than it looks once an environment layer is registered. Every variable sharing the prefix is a candidate key, so an unrelated MYTOOL_SOMETHING_ELSE in the environment fails the build with unknown field: found \something``. Reserve the prefix for this tool before turning strict mode on.

Next