config-kit
Layered typed configuration for Rust — the shared config pattern of the WyattAu estate: file → env → overrides, deep-merged into serde structs, with top-level unknown-key denial, redacted secrets, and opt-in hot reload.
- Typed, layered, deterministic: a
ConfigBuilderstate machine that makes zero-layer loads a compile error; layers merge key-by-key in the order added, so later layers win on collisions without clobbering siblings. - Strict mode without derives:
load_strictdenies unknown top-level keys for anyDeserializeOwnedtype — no#[serde(deny_unknown_fields)]per-struct ceremony — and reports the offending keys in a typed error. - Secrets redacted by construction:
Sensitive<T>renders"[REDACTED]"forDebugandDisplay, with bound-free impls so derived container formatting cannot leak; reads require an explicit, grep-friendly.expose(). - TOML core; JSON/YAML optional: format picked by file extension,
TOML by default,
json/yamlfeatures add the rest. - Hot reload (opt-in):
notify-backed, 300 ms debounced, typed callbacks; reload failures are recorded, never panics. #![forbid(unsafe_code)],#![deny(missing_docs)], clippyunwrap_used/expect_used/panic/indexing_slicingdenied — the public surface is fallible and typed.
Install
[]
= "0.1"
Example
use ;
use Deserialize;
use BTreeMap;
// Deployment env wins over the file; in-process overrides win over both.
set_var;
let mut over: = new;
over.insert;
let service: Service = new
.layer // base
.layer // deployment wins over file
.layer // in-process wins over both
.load?;
assert_eq!;
assert_eq!; // the only way in
Precedence: overrides > env > file. Tables deep-merge, so an override
of db.port leaves db.url from the file intact.
Strict loading
let error = new
.layer
. // <- typo'd top-level keys rejected
.expect_err;
// ConfigError::UnknownFields { keys: ["tokne"] }
The precise contract: detection is container-agnostic (it works for any
DeserializeOwned type by observing which top-level keys serde actually
consumed), it covers keys from all layers — file, env, and overrides —
and it is top level only: unknown keys nested inside tables are
serde's business, and #[serde(flatten)] fields are exempt (flattening
swallows the evidence before the check can see it).
Hot reload
[]
= { = "0.1", = ["hot-reload"] }
let watcher = ?;
// Reload failures (deleted file, malformed edit) are recorded, not fatal:
if let Some = watcher.last_error
watcher.stop;
Performance
Measured with criterion on the committed bench suite (cargo bench); see
benches/loader_bench.rs (3 layers × 20 keys each: file + env + overrides,
full merge-and-deserialize pipeline; Linux x86_64, rustc stable):
| Benchmark | Measured | Cost model |
|---|---|---|
layer_merge_3x20 |
~16 µs | 3 layer parses + deep merge + one serde pass |
env_parse_20 |
~11 µs | 20 vars_os entries, inference + table build + serde |
Loading is a cold-path operation: one file read + parse per file layer, one
vars_os sweep per env layer, and a single serde pass over the merged
table. Sensitive<T> adds zero runtime cost to non-formatting paths —
redaction is a constant string write. Publish your measured numbers in
your README per the estate standard.
Feature flags
| Feature | Default | Description |
|---|---|---|
json |
no | .json config files via serde_json |
yaml |
no | .yaml/.yml config files via serde_yaml_ng |
serde |
no | opt-in Serialize for Sensitive<T> (deserialization always available) |
hot-reload |
no | watch(path, handler) + ConfigWatcher via notify |
Stability
config-kit is 1.0. Breaking changes require a minor bump and a #[deprecated]
window of at least one minor release; MSRV bumps are minor releases, never patch
releases. Layer precedence (file, then env, then overrides) is guaranteed. New
built-in sources may arrive in minors behind features; reload debounce timing is
best-effort and may be tuned.
License
Licensed under either of Apache-2.0 or MIT at your option.