# 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 `ConfigBuilder` state 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_strict` denies unknown
**top-level** keys for *any* `DeserializeOwned` type — 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]"` for `Debug` *and* `Display`, 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`/`yaml` features 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)]`**, clippy
`unwrap_used`/`expect_used`/`panic`/`indexing_slicing` denied — the
public surface is fallible and typed.
## Install
```toml
[dependencies]
config-kit = "0.1"
```
## Example
```rust
use config_kit::{ConfigBuilder, ConfigLayer, Sensitive};
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Deserialize)]
struct Service {
port: u16,
debug: bool,
token: Sensitive<String>,
}
// Deployment env wins over the file; in-process overrides win over both.
std::env::set_var("SERVICE_TOKEN", "hunter2");
let mut over: BTreeMap<String, toml::Value> = BTreeMap::new();
over.insert("port".into(), toml::Value::Integer(9090));
let service: Service = ConfigBuilder::new()
.layer(ConfigLayer::file("service.toml")) // base
.layer(ConfigLayer::env_prefix("SERVICE_")) // deployment wins over file
.layer(ConfigLayer::overrides(over)) // in-process wins over both
.load()?;
assert_eq!(service.port, 9090);
assert_eq!(service.token.expose(), "hunter2"); // 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
```rust,ignore
let error = ConfigBuilder::new()
.layer(ConfigLayer::file("service.toml"))
.load_strict::<Service>() // <- typo'd top-level keys rejected
.expect_err("unknown keys");
// 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
```toml
[dependencies]
config-kit = { version = "0.1", features = ["hot-reload"] }
```
```rust,ignore