rtb-config v0.1 — Typed, layered configuration¶
Status: IMPLEMENTED — spec, tests, and implementation landed in one
commit; acceptance suite (13 unit + 6 BDD) went green on second run
(first run caught figment's .split("_") requirement for nested env
keys, fixed in-commit before landing).
Target crate: rtb-config
Feeds: rtb-app (App.config), downstream tools.
Parent contract: §4 of the framework spec.
1. Motivation¶
Go Tool Base exposes a Containable.GetString("foo.bar") grab-bag
wrapping Viper — string-keyed dynamic access with no compile-time
checking. Rust-idiomatic config is the opposite: a caller-owned
serde::Deserialize struct that the framework populates by layering
sources.
v0.1 ships the typed layered container and the explicit reload
flow. Hot reload via notify and a reactive watch::Receiver API are
scoped to v0.2 — shipping them now would double the surface area before
we have a CLI wiring to exercise it.
2. Scope boundaries (explicit)¶
In scope for v0.1¶
Config<C = ()>generic container.Cdefaults to()soArc<Config>(rtb-app's current usage) resolves toArc<Config<()>>without an explicit type parameter.ConfigBuilder<C>with three sources — embedded default, user file, env vars — layered in that precedence (last wins)..build()parses all layers into a singleCand wraps it in the Config.Config::get() -> Arc<C>for read access.Config::reload()re-reads the same sources and atomically swaps the stored value viaarc_swap::ArcSwap.ConfigError— typed errors withmiette::Diagnostic.
Deferred to v0.2¶
- Hot reload: wire
notify-debouncer-fullto automatically callreload()on user-file changes. subscribe() -> watch::Receiver<Arc<C>>: once values actually change, the reactive API becomes useful.- TOML and JSON file formats: v0.1 is YAML only. Adding more is a one-line figment call per format.
- Profile selection:
figment::Figment::select(profile). - CLI-flag layer: integrating clap matches as a config source.
- JSON Schema export:
schemars-driven schema generation forconfig schemainrtb-cli.
3. Public API¶
3.1 Crate root¶
3.2 Config<C>¶
pub struct Config<C = ()>
where
C: DeserializeOwned + Send + Sync + 'static,
{
// internal: arc_swap::ArcSwap<C>, retained sources for reload
}
impl<C> Config<C>
where
C: DeserializeOwned + Send + Sync + 'static,
{
pub fn builder() -> ConfigBuilder<C>;
/// Snapshot the currently-stored value. Cheap — no parse.
pub fn get(&self) -> Arc<C>;
/// Re-read every registered layer and atomically swap the stored
/// value. Callers that hold a prior `get()` snapshot retain their
/// old view via Arc reference-counting — no tearing.
pub fn reload(&self) -> Result<(), ConfigError>;
}
impl<C> Default for Config<C>
where
C: DeserializeOwned + Default + Send + Sync + 'static,
{
fn default() -> Self;
}
impl<C> Clone for Config<C>
where
C: DeserializeOwned + Send + Sync + 'static,
{
/// Clones are cheap — the `ArcSwap` is internally `Arc`-backed.
fn clone(&self) -> Self;
}
3.3 ConfigBuilder<C>¶
pub struct ConfigBuilder<C: DeserializeOwned + Send + Sync + 'static> { /* … */ }
impl<C> ConfigBuilder<C> {
#[must_use]
pub fn new() -> Self;
/// YAML string baked into the binary via `include_str!` or a
/// literal. This is the lowest-precedence layer.
#[must_use]
pub fn embedded_default(self, yaml: &'static str) -> Self;
/// YAML file on disk. Missing files are *not* an error — figment
/// treats absent files as an empty source. Present but malformed
/// YAML is an error.
#[must_use]
pub fn user_file(self, path: impl Into<PathBuf>) -> Self;
/// Environment variables with the given prefix. Underscore-to-
/// nesting translation follows figment's `Env::prefixed`
/// semantics (`MYTOOL_HTTP_PORT` → `http.port`).
#[must_use]
pub fn env_prefixed(self, prefix: impl Into<String>) -> Self;
pub fn build(self) -> Result<Config<C>, ConfigError>;
}
Precedence (last wins): embedded default → user file → env vars.
Adding a source with .env_prefixed overrides both earlier layers at
the keys it touches.
3.4 ConfigError¶
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum ConfigError {
/// Figment refused the merged source set (parse, missing required
/// fields, type mismatch).
#[error("configuration error: {0}")]
#[diagnostic(
code(rtb::config::parse),
help("check your config file and environment variables against the schema"),
)]
Parse(String),
/// User config file was present but could not be read.
#[error("could not read config file {path}: {source}")]
#[diagnostic(code(rtb::config::io))]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
4. Acceptance criteria¶
4.1 Unit tests (T#)¶
- T1 —
Config<()>isDefault.Config::<()>::default()compiles and returns aConfigwhoseget()snapshot isArc<()>. - T2 —
Config<T>isSend + Sync + Clonefor anyT: Send + Sync + DeserializeOwned + 'static. - T3 —
Config<T>with default generic elides toConfig<()>.fn _check(_: Config) {}compiles. - T4 — Embedded default populates
C. YAML string parses into a caller-supplied struct;get()returns the parsed values. - T5 — User file overrides embedded default. A partial YAML file overrides only the fields it mentions; other fields keep the embedded value.
- T6 — Env var overrides both earlier layers. Setting
MYTOOL_PORT=9999wins over both. - T7 — Env prefix translation.
MYTOOL_HTTP_PORT=80populateshttp.porton a nested struct. - T8 — Missing required field yields
ConfigError::Parsewith thertb::config::parsecode. - T9 —
reload()picks up new file contents. Write a YAML file, build Config, mutate the file, callreload(), assert new value. - T10 —
get()snapshots don't tear onreload(). A thread holding anArc<C>snapshot keeps its old view after another thread callsreload()and updates. - T11 —
ConfigError::Iocarries the path. A malformed YAML file producesParse(notIo), but an unreadable path — e.g. a directory passed where a file is expected — producesIowith thepathfield populated. - T12 — Missing user file is not an error. Absent user file with valid embedded default builds successfully.
4.2 Gherkin scenarios (S#)¶
Feature file: crates/rtb-config/tests/features/config.feature.
- S1 — Minimal config from embedded default — loads the embedded default YAML and yields the expected typed struct.
- S2 — Layer precedence: env > file > embedded — end-to-end proof of the spec's precedence rule.
- S3 — Missing required field surfaces as a user-friendly diagnostic
— the
ConfigError::Parsevariant'shelpappears in the rendered report. - S4 —
reload()picks up updated file contents — live file edit + reload produces new values viaget(). - S5 —
Config<()>(default generic) — compiles and behaves asConfigwithout angle brackets. - S6 — Nested struct via env prefix —
MYTOOL_HTTP_PORT=8080populateshttp.porton a nested-struct config.
5. Security & operational requirements¶
#![forbid(unsafe_code)].- No public API reads env vars except via the explicit
env_prefixedlayer. The crate does not implicitly inheritRUST_LOG,HOME, etc. reload()is atomic: a concurrentget()returns either the old or the newArc, never a torn value.- No direct file writes.
rtb-configis read-only at v0.1. Mutations (for a futureconfig setsubcommand) belong in a companion crate.
6. Non-goals (explicit)¶
- No custom deserialiser. We rely on
serde::Deserializeentirely. - No string-keyed dynamic access (
get_string("foo.bar")). TheCtype provides compile-time access. - No observer trait. The v0.2
subscribe()API replaces that with atokio::sync::watch::Receiver<Arc<C>>— pull-based, Rust-idiomatic.
7. Rollout plan¶
- Land the spec + tests + implementation in a
feat(config)commit. - Update
rtb-app::app::Appto useConfig(which now resolves via default generic toConfig<()>). No API change visible from App's user — it is a transparent refactor. - Add
rtb-configas a dep inrtb-clionce that crate starts its v0.1 work.
8. Open questions¶
-
O1 — Should
user_fileaccept a list of candidate paths ("first that exists wins") to support XDG-style~/.config/<tool>/config.yamlwith fallback to$CWD/config.yaml? Current design takes one path per call; chaining via the builder already works for the "multi-location" case by calling.user_file(…)twice. Lean: single-path for v0.1, revisit after CLI integration. -
O2 — Should
env_prefixedstrip the prefix with__as the nesting delimiter (MYTOOL__HTTP__PORT→http.port) vs single underscore (MYTOOL_HTTP_PORT)? figment's default is single-underscore; we follow it. Downstream tools that truly need__can provide a customEnvsource. Lean: single underscore, documented. -
O3 — Is
Config<C>Debug? DerivingDebugrequiresC: Debug. Adding the bound forces downstream config structs to derive Debug. Proposed: ship without the blanketDebugimpl; downstream users who want it implementDebugon their own Config wrapper.