Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Validation & Diagnostics

validate

#![allow(unused)]
fn main() {
#[dynamic_config]
#[derive(Deserialize, Validate)]        // validator, garde, or a method of your own
struct Pool { min_size: u16, max_size: u16 }

Pool::builder("pool")
    .file("config.toml")
    .validate(|pool| dynamic_config::Error::ok_or_invalid(pool.validate()))
    .init()?;
}

.validate(f) runs after deserializing and before anything installs — on init, on every watch reload, and on a recovery from the cache — so a reload that fails validation keeps the previous snapshot exactly as a parse failure does. For the case where every field is valid on its own and the whole is still nonsense.

The check is a function you pass, not a method the macro resolves, so this crate never pins a version of a validation library: Error::ok_or_invalid(..) adapts whatever Result yours returns into ErrorKind::Invalid.

Key-level diffs

#![allow(unused)]
fn main() {
DbConfig::on_reload(|old, new| {
    for change in dynamic_config::changed_paths(old, new).unwrap_or_default() {
        tracing::info!(%change, "configuration changed");
    }
});
}
pool.max_size changed
tls added

changed_paths reports which keys a reload changed. Paths only, never values — otherwise a reload of db.password would do in the log exactly what #[config(secret)] exists to prevent.

It runs in an on_reload hook, so it applies to every reload, not only the watcher's: a document a remote watch pushed through its sink is reported the same way. A program with no config file at all, watching only a store, still learns what moved. For two resolved trees rather than two structs, Snapshot::diff answers the same question.

#[config(secret)]

#![allow(unused)]
fn main() {
#[dynamic_config]
#[derive(Deserialize)]          // note: no `Debug`
struct DatabaseConfig {
    host: String,
    #[config(secret)]
    password: String,
}
// DatabaseConfig { host: "localhost", password: "***" }
}

Generates a Debug that redacts the marked fields. #[derive(Debug)] alongside it is a compile error rather than a race between two impls.

Checking without booting

$ myapp --check
[server]
  host                         set as command-line flag
  port                         from APP_SERVER_*
  tags                         in /etc/myapp/config.json

  hsot: unknown key, did you mean `host`?

  would not load: port: invalid type: found a string, expected u16

check() reports every key with the layer that supplied it, any key the struct does not name, and why a load would fail. It works when the load fails, which is the only time it is worth running.

No values, ever. A report that showed them would be pasted into an issue tracker with the database password in it, undoing #[config(secret)].

What unknown-key detection catches

Top-level keys of the section, compared against the struct's field names — db.hsot is caught, db.pool.mx_size is not. A proc-macro sees a field's type name, not its fields, so nothing here knows what lives inside pool.

Suggestions use an alignment distance in which a transposition costs one edit, because prot for port is how keys actually get mistyped; the threshold scales with the name, so id tolerates one edit and connection_timeout tolerates four.

Detection is skipped entirely when any field is #[serde(flatten)]: a flattened field legitimately absorbs keys the outer struct never names, and reporting those as typos would be worse than reporting nothing.

Where it is skipped — a flattened field, a bare Builder::new, a schemaless configuration — the report says so: Report::unknown_checked is false, and the rendering carries unknown keys: not checked (no field list). An empty list and a list nobody built mean opposite things, and only one of them is an all-clear.

Where did this value come from?

#![allow(unused)]
fn main() {
DbConfig::source_of("port")?;   // Some(Origin::Env("APP_DB_PORT"))
DbConfig::is_set("pool.tls")?;  // false — absent, not "present but false"
}

Both re-read the sources, so they report what the next load would see rather than what the current snapshot holds. The snapshot answers for itself: DbConfig::snapshot()?.source_of("port") names the source of the value that was actually resolved into that snapshot — provenance is captured while the load still knows it. A snapshot that did not come from a live resolution (one read back from the cache) has none.

Why is this value what it is?

source_of names the winner. explain shows the whole argument:

#![allow(unused)]
fn main() {
println!("{}", DbConfig::explain("pool.max_size")?);
}
pool.max_size = 32

layer        source                 value
default      set as default         8
file         in config.toml         16
environment  from APP_DB_POOL__MAX_SIZE  absent
override     set as override        32   ← winner

One row per layer that has anything to say, lowest precedence first; the winner is the highest row with a value. Unlike every other diagnostic in this crate, an explanation contains values — that is its point; you asked. Fields marked #[config(secret)] come back with every value already *** (the origins stay — where a secret comes from is the useful half), and Explanation::redacted blanks any explanation the caller knows to be sensitive — including a secret's old key kept alive by an alias, which the field's marking cannot cover. The rows are public on the returned Explanation for anything that wants its own format.

The same two questions from a shell, without writing a program:

dynamic-config explain pool.max_size --file config.toml --key db --env APP_
dynamic-config diff old.toml new.toml --key db     # paths only, never values

That is dynamic-config-cli, a workspace member of its own. A CLI cannot see your attribute, so the flags restate the load — they have to match what the application declares, or the answer is about a different load.

Errors

One error type; figment::Error never reaches a signature, so a figment major version bump is not automatically a breaking change here. Every error carries the key path and the source that set the value:

pool.max_size: invalid type: found a string, expected u16 (from APP_DB_)

The offending value is not in the message. The key, what kind of thing was there, and the type that was wanted are all there — everything needed to fix it. The value is not, because a password pasted into a numeric field would otherwise land in a log line, and every other diagnostic here goes to some length to make sure that cannot happen.

Error::kind() returns Io, Parse, Missing, Type, Env, Invalid, Remote, Decrypt or Backend.