dynamic_config/lib.rs
1//! Hot-reloadable, lock-free application configuration, built on
2//! [figment](https://docs.rs/figment).
3//!
4//! Annotate a struct, call `init()` once, and read it from anywhere:
5//!
6//! ```
7//! # #[cfg(feature = "json")] {
8//! use dynamic_config::dynamic_config;
9//! use serde::Deserialize;
10//!
11//! #[dynamic_config(files = ["config.json"], key = "server", env = "APP_")]
12//! #[derive(Debug, Deserialize)]
13//! struct ServerConfig {
14//! #[serde(default = "default_host")]
15//! host: String,
16//! #[serde(default = "default_port")]
17//! port: u16,
18//! }
19//!
20//! # fn default_host() -> String { "0.0.0.0".into() }
21//! # fn default_port() -> u16 { 8080 }
22//! // `config.json` does not exist here, so every field falls back to its
23//! // default — a missing file is skipped, not an error.
24//! ServerConfig::init().expect("defaults cover every field");
25//!
26//! let config = ServerConfig::current();
27//! println!("{}:{}", config.host, config.port);
28//! # }
29//! ```
30//!
31//! This page is the API reference. The guide — profiles, discovery, hot
32//! reload, remote stores, encryption, testing — is
33//! [**the book**](https://ctolon.github.io/dynamic-config/).
34//!
35//! # What the attribute generates
36//!
37//! The everyday core:
38//!
39//! | Method | Description |
40//! |---|---|
41//! | `load() -> Result<Self, Error>` | Read the sources and deserialize. Does not touch the snapshot. |
42//! | `init() -> Result<(), Error>` | `load()` plus install as the initial snapshot. Call once at startup. |
43//! | `replace(Self)` | Atomically swap in a new snapshot. |
44//! | `current() -> Arc<Self>` | The current snapshot. Panics before `init()`. |
45//! | `try_current() -> Option<Arc<Self>>` | The current snapshot, or `None` before `init()`. |
46//! | `start_watch() -> io::Result<WatchHandle>` | With `watch`: reload on file changes until the handle is dropped. A second watch while one runs is `AlreadyExists`. |
47//! | `on_reload(f)` | Run a callback on every later reload, for the life of the process. |
48//! | `on_reload_scoped(f) -> HookGuard` | The same, until the guard is dropped. |
49//! | `set_default(path, value)` | A fallback used only when nothing else supplies the key. |
50//! | `set_override(path, value)` | A value that wins over every file and variable. |
51//! | `clear_defaults()` / `clear_overrides()` | Drop them again. |
52//! | `load_async()` / `init_async()` | With `async`: the same, off the async executor. |
53//! | `changes()` | With `async`: a handle woken by every later reload. |
54//!
55//! The rest of the surface — introspection (`snapshot`, `source_of`, `is_set`,
56//! `check`), persistence (`save`, `save_new`, `save_encrypted`), remote stores
57//! (`set_remote`, `refresh_remote`, `apply_remote`), aliases, environment
58//! bindings, flags, `bind_clap`, `schema` — is in [the book's attribute
59//! reference](https://ctolon.github.io/dynamic-config/attribute-reference.html).
60//!
61//! # Precedence
62//!
63//! ```text
64//! set_default < discovered < config.toml < secrets.json < remote < APP_DB_* < bind_env < set_flag < set_override
65//! (runtime) (search path) (first) (last file) (etcd…) (environment) (by name) (CLI) (runtime)
66//! ```
67//!
68//! Files merge left to right and tables merge key by key, so a small
69//! `secrets.json` can override two fields of a large `config.toml` without
70//! restating it.
71//!
72//! The two runtime layers bracket the rest. Defaults cover a fallback the
73//! program can compute but a file need not state; overrides are what make a
74//! test or a `--set key=value` flag authoritative without touching disk. Both
75//! take effect on the next `load()`.
76//!
77//! # Reading configuration is lock-free
78//!
79//! `current()` hands out an `Arc` cloned from an `ArcSwap`, so a reload never
80//! blocks a request handler. A reader that already holds an `Arc` keeps its own
81//! generation — call `current()` once per request and reuse it, or a reload
82//! landing mid-request will show you two different configurations.
83//!
84//! # Reloading cannot take the process down
85//!
86//! A reload re-runs `load()`. If the new configuration is invalid, or a file is
87//! caught half-written, the error is reported and the previous snapshot stays
88//! in place. A bad edit degrades to "no change".
89//!
90//! # Environment variables
91//!
92//! `env = "APP_"` with `key = "db"` reads `APP_DB_*`. A single underscore is
93//! part of a field name; a doubled one introduces nesting:
94//!
95//! | Variable | Sets |
96//! |---|---|
97//! | `APP_DB_HOST` | `host` |
98//! | `APP_DB_MAX_SIZE` | `max_size` |
99//! | `APP_DB_POOL__MAX_SIZE` | `pool.max_size` |
100//!
101//! Values are interpreted by figment, which reads them loosely: `8080` reaches
102//! a `u16`, `true` reaches a `bool`, and `[a, b, c]` reaches a `Vec<String>`.
103//! A value that cannot become the field's type is an error naming the field.
104//!
105//! # Units
106//!
107//! `timeout = 30` is ambiguous and `max_body = 67108864` is unreadable, so both
108//! are usually written with a unit — which no stock `Deserialize` accepts:
109//!
110//! ```
111//! use std::time::Duration;
112//! use serde::Deserialize;
113//!
114//! #[derive(Deserialize)]
115//! struct Limits {
116//! #[serde(with = "dynamic_config::duration")]
117//! timeout: Duration, // "30s", "1h30m", "500ms", or a number of seconds
118//! #[serde(with = "dynamic_config::bytes")]
119//! max_body: u64, // "64MiB", "1GB", or a number of bytes
120//! }
121//! ```
122//!
123//! # Async
124//!
125//! With the `async` feature and the `async` argument, configuration loads
126//! without blocking the executor, and tasks can await reloads instead of
127//! polling. No runtime is named anywhere: `changes()` is a `Future`, so any
128//! executor drives it.
129//!
130//! ```ignore
131//! #[dynamic_config(files = ["config.json"], key = "db", watch, async)]
132//! #[derive(Debug, Deserialize)]
133//! struct DbConfig { pool_size: u32 }
134//!
135//! DbConfig::init_async().await?;
136//! // Keep the handle: dropping it stops the watch.
137//! let _watch = DbConfig::start_watch()?;
138//!
139//! let mut reloads = DbConfig::changes();
140//!
141//! spawn(async move {
142//! loop {
143//! let config = reloads.changed().await;
144//! pool.resize(config.pool_size);
145//! }
146//! });
147//! ```
148//!
149//! The watcher itself stays on a plain thread. `notify`'s channel is
150//! synchronous, and keeping it off the runtime means file watching works
151//! whether or not a runtime is running.
152//!
153//! # Features
154//!
155//! | Feature | Default | Effect |
156//! |---|---|---|
157//! | `json` | yes | `.json` sources |
158//! | `toml` | no | `.toml` sources |
159//! | `yaml` | no | `.yaml` / `.yml` sources |
160//! | `watch` | no | `start_watch()` and the file watcher |
161//! | `async` | no | `load_async`, `init_async`, `changes` — no runtime dependency |
162//! | `tokio` | no | `async`, plus tokio's blocking pool instead of a thread per load |
163//! | `clap` | no | `bind_clap`: named `clap` arguments as the flags layer |
164//! | `schema` | no | `schema()`: a JSON Schema for the resolved configuration |
165//! | `decrypt` | no | the [`Decryptor`]/[`Encryptor`] traits and `.age`-suffix handling |
166//! | `age` | no | `decrypt`, plus the `age` module's implementation of it |
167//! | `figment` | no | foreign figment providers as sources, via `Source::provider` |
168//! | `dotenv` | no | `env_files = [".env"]`: `.env` files as the environment layer |
169//! | `tracing` | no | Watcher diagnostics via `tracing` instead of stderr |
170//! | `full` | no | all of the above |
171//!
172//! Using a format, `watch` or `async` whose feature is disabled is a compile
173//! error naming the feature to add.
174//!
175//! # Without the macro
176//!
177//! [`load`], [`ConfigCell`] and [`LoadSpec`] are the whole engine and are
178//! usable on their own:
179//!
180//! ```
181//! # #[cfg(feature = "json")] {
182//! use dynamic_config::{load, Format, LoadSpec, Source};
183//! use serde::Deserialize;
184//!
185//! #[derive(Deserialize)]
186//! struct Db { host: String }
187//!
188//! let sources = [Source::inline(r#"{"db": {"host": "localhost"}}"#, Format::Json)];
189//! let db: Db = load(&LoadSpec::new("db", &sources))
190//! .expect("the inline document is well formed");
191//!
192//! assert_eq!(db.host, "localhost");
193//! # }
194//! ```
195
196#![forbid(unsafe_code)]
197#![deny(missing_docs)]
198// A getter whose result is discarded is a mistake in a library like this one —
199// `is_set`, `contains`, `document`, `describe` all answer a question and change
200// nothing. Warned about rather than left to review, and CI denies warnings.
201#![warn(clippy::must_use_candidate)]
202#![cfg_attr(docsrs, feature(doc_cfg))]
203
204#[cfg(feature = "age")]
205#[cfg_attr(docsrs, doc(cfg(feature = "age")))]
206pub mod age;
207mod aliases;
208#[cfg(feature = "async")]
209mod asynchronous;
210mod bindings;
211mod cache;
212mod cell;
213mod check;
214#[cfg(feature = "decrypt")]
215mod decrypt;
216mod discovery;
217#[cfg(feature = "dotenv")]
218mod dotenv;
219mod error;
220mod group;
221mod layer;
222mod loader;
223mod log;
224mod redirects;
225mod registry;
226mod remote;
227#[cfg(feature = "schema")]
228#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
229pub mod schema;
230mod snapshot;
231mod source;
232mod units;
233mod write;
234
235#[cfg(feature = "watch")]
236#[cfg_attr(docsrs, doc(cfg(feature = "watch")))]
237pub mod watch;
238
239#[cfg(feature = "async")]
240#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
241pub use asynchronous::{set_blocking_executor, BlockingExecutor, Changes};
242/// figment itself, re-exported.
243///
244/// So that writing a [`Source::provider`] needs no direct dependency, and no
245/// second version of figment in the graph. This is the one place figment
246/// appears in this crate's API, which is why it is behind a feature.
247#[cfg(feature = "figment")]
248#[cfg_attr(docsrs, doc(cfg(feature = "figment")))]
249pub use figment;
250
251pub use aliases::Aliases;
252pub use bindings::EnvBindings;
253pub use cache::{CacheMode, Recovery};
254pub use cell::{ConfigCell, HookGuard};
255pub use check::{check, Report, Resolved, UnknownKey};
256#[cfg(feature = "decrypt")]
257#[cfg_attr(docsrs, doc(cfg(feature = "decrypt")))]
258pub use decrypt::{has_decryptor, set_decryptor, Decryptor, Encryptor};
259pub use discovery::Search;
260pub use error::{Error, ErrorKind, Origin};
261pub use group::{Commit, ReloadGroup, Reloadable};
262pub use layer::Layer;
263pub use registry::Registry;
264#[cfg(feature = "async")]
265#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
266pub use remote::AsyncRemoteSource;
267pub use remote::{Fetched, Remote, RemoteSource, RemoteWatch, Watching};
268pub use snapshot::{Change, ChangeKind, Snapshot};
269pub use source::{Format, LoadSpec, Source, DEFAULT_NEST};
270pub use units::{bytes, duration};
271#[cfg(feature = "decrypt")]
272#[cfg_attr(docsrs, doc(cfg(feature = "decrypt")))]
273pub use write::save_encrypted;
274pub use write::{save, save_new};
275
276/// Turns a struct into a hot-reloadable configuration snapshot.
277///
278/// See the [crate documentation](crate) for the full guide.
279///
280/// # Arguments
281///
282/// | Argument | Form | Requires | Default |
283/// |---|---|---|---|
284/// | `files` | `files = ["a.toml"]` | one of `files` / `name`+`paths` | — |
285/// | `name` | `name = "config"` | `paths` | — |
286/// | `paths` | `paths = ["/etc/app", "."]` | `name` | — |
287/// | `key` | `key = "db"` | always | — |
288/// | `env` | `env = "APP_"` | | no environment layer |
289/// | `nest` | `nest = "__"` | `env` | `"__"` |
290/// | `allow_empty_env` | flag | `env` | off — `FOO=` is unset |
291/// | `profile_env` | `profile_env = "APP_ENV"` | | no profile overlay |
292/// | `watch` | flag | `watch` feature | off |
293/// | `debounce` | `debounce = 250` | `watch` | 250 ms |
294/// | `poll` / `poll_interval` | flag / `= 2000` | `watch` | native backend |
295/// | `diff` | flag | | off |
296/// | `validate` | flag | a `validate()` on the type | off |
297/// | `save` | flag | `Self: Serialize` | off |
298/// | `cache` | `cache = "last.json"` | | no cache — a bad start fails |
299/// | `cache_mode` | `cache_mode = "redacted"` | `cache` | `"full"` |
300/// | `env_files` | `env_files = [".env"]` | `dotenv` feature + `env` | none |
301/// | `schema` | flag | `schema` feature + `Self: JsonSchema` | off |
302/// | `async` | flag | `async` feature | off |
303///
304/// One field attribute: `#[config(secret)]` generates a `Debug` that prints
305/// `***` for the marked fields, and forbids `#[derive(Debug)]` alongside it.
306///
307/// [The book's attribute reference](https://ctolon.github.io/dynamic-config/attribute-reference.html)
308/// carries a section per argument, with an example and the reasoning behind
309/// each default.
310///
311/// # Requirements
312///
313/// The annotated struct must implement `serde::Deserialize` and be
314/// `Send + Sync + 'static`. Type and const parameters are supported — those go
315/// through a `TypeId` registry rather than a `static`, at a measured cost of
316/// roughly 10 ns per read. A **lifetime** parameter is rejected at compile
317/// time: the snapshot outlives every borrow that could name one.
318pub use dynamic_config_macros::dynamic_config;
319
320use serde::de::DeserializeOwned;
321
322/// Reads and deserializes a configuration section.
323///
324/// This is what the generated `load()` calls. Missing files are skipped;
325/// everything else — a parse failure, a missing required field, a value that
326/// cannot become the requested type — is an [`Error`] naming the key path and
327/// the source it came from.
328///
329/// # Errors
330///
331/// See [`ErrorKind`] for the categories.
332///
333/// # Example
334///
335/// ```
336/// # #[cfg(feature = "json")] {
337/// use dynamic_config::{load, Format, LoadSpec, Source};
338/// use serde::Deserialize;
339///
340/// #[derive(Deserialize)]
341/// struct Server { port: u16 }
342///
343/// let sources = [Source::inline(r#"{"server": {"port": 8080}}"#, Format::Json)];
344/// let server: Server = load(&LoadSpec::new("server", &sources).with_env("APP_"))
345/// .expect("the inline document is well formed");
346///
347/// assert_eq!(server.port, 8080);
348/// # }
349/// ```
350pub fn load<T: DeserializeOwned>(spec: &LoadSpec<'_>) -> Result<T, Error> {
351 loader::load(spec)
352}
353
354/// Resolves the section without deserializing it.
355///
356/// Two snapshots can be compared with [`Snapshot::diff`], which is how a reload
357/// reports *which* keys changed rather than only that something did.
358///
359/// # Errors
360///
361/// If a source cannot be read or parsed — the same failures as [`load`].
362pub fn snapshot(spec: &LoadSpec<'_>) -> Result<Snapshot, Error> {
363 loader::snapshot(spec)
364}
365
366/// Where the value at `path` would come from, if anything supplies it.
367///
368/// This is the answer to the question every configuration bug starts with:
369/// *which layer set this?* It re-reads the sources, so it reports what the
370/// **next** load would see rather than what the current snapshot holds.
371///
372/// `path` is dotted and relative to the section, as in `"pool.max_size"`.
373///
374/// # Errors
375///
376/// If a source cannot be read or parsed — the same failures as [`load`].
377///
378/// # Example
379///
380/// ```
381/// # #[cfg(feature = "json")] {
382/// use dynamic_config::{source_of, Format, LoadSpec, Origin, Source};
383///
384/// let sources = [Source::inline(r#"{"db": {"host": "localhost"}}"#, Format::Json)];
385/// let spec = LoadSpec::new("db", &sources);
386///
387/// assert_eq!(source_of(&spec, "host").unwrap(), Some(Origin::Inline));
388/// assert_eq!(source_of(&spec, "port").unwrap(), None);
389/// # }
390/// ```
391pub fn source_of(spec: &LoadSpec<'_>, path: &str) -> Result<Option<Origin>, Error> {
392 loader::source_of(spec, path)
393}
394
395/// Whether anything supplies `path`.
396///
397/// Distinguishes "absent" from "present but falsy", which
398/// `#[serde(default)]` cannot.
399///
400/// # Errors
401///
402/// If a source cannot be read or parsed — the same failures as [`load`].
403pub fn is_set(spec: &LoadSpec<'_>, path: &str) -> Result<bool, Error> {
404 loader::is_set(spec, path)
405}
406
407/// [`load`], moved off the async executor.
408///
409/// Reading configuration touches the filesystem, which would block the worker
410/// it runs on. Where the work actually goes depends on what is available:
411/// tokio's blocking pool with the `tokio` feature, an executor installed by
412/// [`set_blocking_executor`], or a freshly spawned thread. A configuration load
413/// happens at startup and on reload, so a thread per call is a real answer
414/// rather than a placeholder.
415///
416/// `LoadSpec<'static>` is taken by value because the work outlives the call;
417/// the spec the macro emits satisfies that for free.
418///
419/// # Errors
420///
421/// Same as [`load`], plus an [`ErrorKind::Backend`] error if the work never
422/// produced a result — a panic inside it, or a runtime shutting down.
423#[cfg(feature = "async")]
424#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
425pub async fn load_async<T>(spec: LoadSpec<'static>) -> Result<T, Error>
426where
427 T: DeserializeOwned + Send + 'static,
428{
429 off_thread(move || load(&spec)).await
430}
431
432/// Runs blocking configuration work without blocking the caller's executor.
433///
434/// See [`load_async`] for where the work goes.
435///
436/// # Errors
437///
438/// Whatever `work` returns, plus [`ErrorKind::Backend`] if it never produced a
439/// result.
440#[cfg(feature = "async")]
441#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
442pub async fn off_thread<T, F>(work: F) -> Result<T, Error>
443where
444 F: FnOnce() -> Result<T, Error> + Send + 'static,
445 T: Send + 'static,
446{
447 asynchronous::off_thread(work).await
448}
449
450// ---------------------------------------------------------------------------
451// Support items used by the generated code. The redirect *macros* — the
452// feature-gated `__*!` wall — live in `redirects`; the functions stay here
453// because they are reached by path, and a path names the module it lives in.
454// ---------------------------------------------------------------------------
455
456/// Not public API. Lets the generated code name `serde` without the caller
457/// having to depend on it under that exact name.
458#[doc(hidden)]
459pub mod __private {
460 #[cfg(feature = "clap")]
461 pub use clap;
462 #[cfg(feature = "schema")]
463 pub use schemars;
464 pub use serde;
465 #[cfg(feature = "schema")]
466 pub use serde_json;
467}
468
469/// Not public API.
470///
471/// Writes the last configuration that worked, if one is configured. A failure
472/// here is reported and swallowed: a cache that cannot be written is a worse
473/// tomorrow, not a broken today.
474#[doc(hidden)]
475pub fn __write_cache(
476 snapshot: &Snapshot,
477 cache: Option<(&'static str, &'static str, &'static [&'static str])>,
478) {
479 let Some((path, mode, secrets)) = cache else {
480 return;
481 };
482
483 let mode = CacheMode::parse(mode).unwrap_or_default();
484
485 if let Err(error) = cache::write(snapshot, std::path::Path::new(path), mode, secrets) {
486 crate::log::warning!("could not write the configuration cache to {path}: {error}");
487 }
488}
489
490/// Not public API.
491///
492/// The last configuration that worked, when a cold start could not read the
493/// real one.
494///
495/// # Errors
496///
497/// If the cache exists but cannot be read. A missing cache is `Ok(None)`.
498#[doc(hidden)]
499pub fn recover<T: DeserializeOwned>(
500 name: &str,
501 spec: &LoadSpec<'_>,
502 cache: Option<(&'static str, &'static str, &'static [&'static str])>,
503 failure: &Error,
504) -> Result<Option<(T, Snapshot)>, Error> {
505 let Some((path, mode, _)) = cache else {
506 return Ok(None);
507 };
508
509 let mode = CacheMode::parse(mode).unwrap_or_default();
510 let path = std::path::Path::new(path);
511
512 // What the sources resolve to *now*, if they resolve at all — the drift
513 // report needs it, and a parse failure means there is nothing to compare.
514 let current = loader::snapshot(spec).ok();
515
516 match cache::read(path, current.as_ref())? {
517 Recovery::Absent => Ok(None),
518
519 Recovery::Drift(moved) => {
520 report(
521 name,
522 &format!(
523 "cannot start: {failure}. Since the last good configuration: {}",
524 match moved {
525 // The sources did not resolve, so there was nothing to
526 // compare against — said plainly, instead of the old
527 // claim of a value-level diff that never ran.
528 None => "could not compare — the sources do not resolve".to_owned(),
529 Some(moved) => moved.join(", "),
530 },
531 ),
532 );
533
534 Ok(None)
535 }
536
537 Recovery::Usable(cached) if mode.recovers() => {
538 let recovered = loader::recover::<T>(spec, &cached).map_err(|error| {
539 Error::new(
540 ErrorKind::Backend,
541 format!("the cached configuration did not load either: {error}"),
542 )
543 })?;
544
545 report(
546 name,
547 &format!("starting from the last configuration that worked, because: {failure}"),
548 );
549
550 Ok(Some(recovered))
551 }
552
553 Recovery::Usable(_) => Ok(None),
554 }
555}
556
557fn report(name: &str, message: &str) {
558 crate::log::warning!("{name}: {message}");
559}
560
561/// Not public API.
562///
563/// A reload a remote watch caused. Worded to name the trigger, because a
564/// program watching both files and a store wants its log to say which one
565/// moved.
566#[doc(hidden)]
567pub fn __log_remote_reload(name: &str, summary: Option<&str>) {
568 match summary {
569 Some(summary) => crate::log::info!("{name}: reloaded from the remote store, {summary}"),
570 None => crate::log::info!("{name}: reloaded from the remote store"),
571 }
572}
573
574/// Not public API.
575///
576/// A document the store pushed that this program cannot use. Logged as well as
577/// returned: the loop that called this has nobody to hand an error to either,
578/// and a store quietly serving a configuration nothing accepts is worth a line.
579#[doc(hidden)]
580pub fn __log_remote_failure(name: &str, error: &Error) {
581 crate::log::warning!(
582 "{name}: the remote store's document did not apply, keeping the previous \
583 snapshot: {error}"
584 );
585}
586
587/// Not public API.
588///
589/// Renders the keys a reload changed, for the watcher to log. Returning a
590/// string rather than logging keeps this out of the generated code's way and
591/// leaves one log line per reload instead of two.
592///
593/// Always a string — "nothing to say" is not this function's case. The
594/// caller's `Option` means "there was no previous snapshot to compare", and
595/// that decision is made where the previous snapshot lives.
596#[doc(hidden)]
597#[must_use]
598pub fn __summarize_changes(previous: &Snapshot, current: &Snapshot) -> String {
599 let changes = previous.diff(current);
600
601 if changes.is_empty() {
602 return "no keys changed".to_owned();
603 }
604
605 changes
606 .iter()
607 .map(ToString::to_string)
608 .collect::<Vec<_>>()
609 .join(", ")
610}