config_kit/lib.rs
1//! Layered typed configuration for Rust.
2//!
3//! `config-kit` centralizes the estate's shared configuration pattern:
4//! `serde`-typed settings assembled from layered sources — a base file,
5//! then environment variables, then in-process overrides — with
6//! unknown-key denial at the top level, redacted secrets, and optional
7//! hot reload.
8//!
9//! # Design
10//!
11//! - **Typed and layered.** You describe the target type; layers produce
12//! values that deep-merge key-by-key in the order added. Driven in the
13//! documented order, precedence is **overrides > env > file**: later
14//! layers win on colliding keys, and table contents merge so a late
15//! layer can override one nested field without clobbering siblings.
16//! - **Strict by choice.** [`ConfigBuilder::load`] ignores unknown keys;
17//! [`ConfigBuilder::load_strict`] denies unconsumed **top-level** keys
18//! with a typed error listing them — container-agnostically, for any
19//! `DeserializeOwned` type, without derive support (see
20//! [`load_strict`](ConfigBuilder::load_strict) for the exact contract).
21//! - **Secrets are redacted by construction.** [`Sensitive<T>`] renders
22//! `"[REDACTED]"` for `Debug` and `Display` (bound-free impls, so
23//! derived container `Debug`s cannot leak either) and requires an
24//! explicit, grep-friendly `.expose()` to read.
25//! - **Formats.** TOML is core. JSON (`json` feature) and YAML (`yaml`
26//! feature) are optional; format is picked by file extension, defaulting
27//! to TOML.
28//! - **Hot reload, opt-in.** The `hot-reload` feature adds
29//! [`watch`]: a 300 ms-debounced, `notify`-backed watcher that feeds
30//! freshly parsed values to your callback and records reload failures
31//! instead of panicking.
32//! - **Tier-A hygiene.** `#![forbid(unsafe_code)]`, `#![deny(missing_docs)]`,
33//! clippy `unwrap_used`/`expect_used`/`panic`/`indexing_slicing` denied —
34//! the public surface is fallible and typed, never panicking.
35//!
36//! # Example
37//!
38//! File layer omitted here so the example runs stand-alone; combine all
39//! three layers for the documented precedence:
40//!
41//! ```
42//! use config_kit::{ConfigBuilder, ConfigLayer, Sensitive};
43//! use serde::Deserialize;
44//! use std::collections::BTreeMap;
45//!
46//! #[derive(Deserialize)]
47//! struct Service {
48//! port: u16,
49//! debug: bool,
50//! token: Sensitive<String>,
51//! }
52//!
53//! std::env::set_var("CKIT_EXAMPLE_SERVICE_TOKEN", "hunter2");
54//! std::env::set_var("CKIT_EXAMPLE_SERVICE_DEBUG", "true");
55//! let mut over: BTreeMap<String, toml::Value> = BTreeMap::new();
56//! over.insert("port".into(), toml::Value::Integer(9090));
57//!
58//! let service: Service = ConfigBuilder::new()
59//! .layer(ConfigLayer::env_prefix("CKIT_EXAMPLE_SERVICE_"))
60//! .layer(ConfigLayer::overrides(over))
61//! .load()?;
62//!
63//! assert_eq!(service.port, 9090); // overrides beat env
64//! assert!(service.debug);
65//! assert_eq!(service.token.expose(), "hunter2"); // the only way in
66//! assert_eq!(format!("{:?}", service.token), "[REDACTED]");
67//! # Ok::<(), config_kit::ConfigError>(())
68//! ```
69//!
70//! # Strict loading
71//!
72//! [`ConfigBuilder::load_strict`] rejects typo'd keys at the top level:
73//!
74//! ```no_run
75//! use config_kit::{ConfigBuilder, ConfigLayer};
76//! use serde::Deserialize;
77//! use tempfile::TempDir;
78//!
79//! #[derive(Deserialize, Debug)]
80//! struct Db {
81//! url: String,
82//! }
83//! #[derive(Deserialize, Debug)]
84//! struct Settings {
85//! db: Db,
86//! }
87//!
88//! let dir = TempDir::new().expect("tempdir");
89//! let path = dir.path().join("settings.toml");
90//! std::fs::write(
91//! &path,
92//! "db = { url = \"postgres://localhost\" }\ntypo_key = 1\n",
93//! )
94//! .expect("write");
95//!
96//! let error = ConfigBuilder::new()
97//! .layer(ConfigLayer::file(&path))
98//! .load_strict::<Settings>()
99//! .expect_err("typo_key is not a field");
100//! assert!(error.to_string().contains("typo_key"), "{error}");
101//! ```
102//!
103//! # Environment typing
104//!
105//! Env values are inferred: `true`/`false` → bool, integer → `i64`, float
106//! → `f64`, else verbatim string; keys are prefix-stripped and lowercased
107//! (`APP_PORT` → `port`). See [`ConfigLayer::env_prefix`].
108//!
109//! # Hot reload
110//!
111//! With the `hot-reload` feature, `watch` spawns a 300 ms-debounced,
112//! `notify`-backed watcher that parses the file on settle and hands the
113//! fresh value to your callback; reload failures are recorded (never
114//! panic) and retrievable via `ConfigWatcher::last_error`, and
115//! `ConfigWatcher::stop` joins the worker. See the `watch` function's
116//! documentation for a runnable example.
117
118#![forbid(unsafe_code)]
119#![deny(missing_docs)]
120
121mod builder;
122mod env;
123mod error;
124mod layer;
125mod sensitive;
126#[cfg(feature = "hot-reload")]
127mod watcher;
128
129pub use builder::{ConfigBuilder, Empty, Ready};
130pub use error::ConfigError;
131pub use layer::ConfigLayer;
132pub use sensitive::Sensitive;
133#[cfg(feature = "hot-reload")]
134pub use watcher::{watch, ConfigWatcher};
135
136#[cfg(test)]
137mod tests {
138 #[test]
139 fn reexports_are_wired() {
140 let builder: crate::ConfigBuilder<crate::Empty> = crate::ConfigBuilder::new();
141 let ready = builder.layer(crate::ConfigLayer::env_prefix("CKIT_WIRING_"));
142 let _ = std::mem::size_of::<crate::Ready>();
143 drop(ready);
144 }
145}