provcfg 0.1.0

Configuration library that keeps track of the origin and history of all config values.
Documentation

provcfg

A Rust config loader that tracks where each value came from.

provcfg (provenance config) layers configuration sources (compiled-in defaults, files, environment variables, CLI flags) into one struct, and keeps the provenance of every leaf field: which source set the active value, and which earlier sources it overrode.

Choosing between provcfg and config

For most projects the config crate is the better fit. It is mature, supports more formats, and is less ceremony if all you need is the merged result.

Use provcfg when you need to know which source set each value, for example:

  • a settings page that labels each field "from file" / "from env" / "default";
  • diagnosing "why is this value set to that?" across layered sources;
  • an admin UI that renders the effective config together with its origin.

If you never ask "where did this come from?", prefer config.

What it does

Derive Configurable on a plain config struct. Config::build returns a companion *Prov struct whose every leaf is a ValueHistory: the value paired with the Source it came from.

use provcfg::{Category, Config, Configurable};

#[derive(Configurable, serde::Deserialize, Clone, Default)]
struct Settings {
    host: String,
    port: u16,
}

// `APP_HOST=db.internal` is set in the environment; `APP_PORT` is not.
let settings = Config::new()
    .add_env("APP")
    .build::<SettingsProv>()
    .unwrap();

assert_eq!(settings.host.value(), "db.internal");
assert_eq!(settings.host.source().category(), Category::Env);

// `port` was set by nobody, so it falls back to the compiled-in default.
assert_eq!(settings.port.value(), &0);
assert_eq!(settings.port.source().category(), Category::Default);

Layering sources

Sources are applied in the order they are added. A later source overrides an earlier one for any leaf it sets. Unset leaves keep the earlier value, and the overridden values stay in each leaf's history.

// `from_cli` is a `SettingsPartial` (every field an `Option`) produced by a
// CLI parser; here it carries only `port = 9090`.
let settings = Config::new()
    .add_toml_str("defaults.toml", "host = \"localhost\"\nport = 8080")
    .add_env("APP")        // environment has APP_HOST=db.internal
    .add_cli(from_cli)     // command line had --port 9090
    .build::<SettingsProv>()
    .unwrap();

assert_eq!(settings.host.value(), "db.internal");           // env overrode the file
assert_eq!(settings.host.source().category(), Category::Env);
assert_eq!(settings.port.value(), &9090);                   // cli overrode the file
assert_eq!(settings.port.source().category(), Category::Cli);

Inspecting provenance

sources_map returns a flat dotted.path -> Category map for a settings UI:

let map = settings.sources_map();
assert_eq!(map.get("host"), Some(&Category::Env));
assert_eq!(map.get("port"), Some(&Category::Cli));

walk_leaves visits every leaf with its value, category, and a secret flag, so an effective-config view can redact sensitive fields:

settings.walk_leaves("", &mut |path, value, category, is_secret| {
    let shown = if is_secret {
        "<redacted>".to_string()
    } else {
        serde_json::to_string(value).unwrap()
    };
    println!("{path} = {shown}  ({})", category.as_str());
});

Field attributes

#[derive(Configurable, serde::Deserialize, Clone, Default)]
struct Settings {
    #[configurable(nested)]               // recurse, tracking provenance per leaf
    database: DatabaseConfig,
    #[configurable(secret)]               // redact in UIs (ValueHistory::is_secret)
    api_token: String,
    #[configurable(rename = "LOG_LEVEL")] // rename source key (verbatim for file formats; lowercase alias for env)
    log_level: String,
    #[configurable(env_list)]             // accept "a,b,c" for a Vec<String>
    allowed_hosts: Vec<String>,
    #[configurable(skip)]                 // keep on the struct, hide from provcfg
    runtime_cache: Cache,
}

Custom sources

To support a format the built-ins don't cover (XML, a database, a secret store), implement the Source trait. Most sources build something that implements serde::Deserializer (a serde_json::Value works fine) and erase it through erased_serde:

use provcfg::erased_serde;
use provcfg::{Category, Config, Source};

struct MapSource { name: String, data: serde_json::Value }

impl Source for MapSource {
    fn name(&self) -> &str { &self.name }
    fn category(&self) -> Category { Category::Custom("map") }
    fn deserialize(
        &self,
        seed: &mut dyn for<'de> FnMut(
            &mut dyn erased_serde::Deserializer<'de>,
        ) -> Result<(), erased_serde::Error>,
    ) -> Result<(), erased_serde::Error> {
        let mut erased = <dyn erased_serde::Deserializer>::erase(&self.data);
        seed(&mut erased)
    }
}

let cfg = Config::new()
    .add_source(MapSource { name: "demo".into(), data: serde_json::json!({ "host": "x" }) })
    .build::<SettingsProv>()
    .unwrap();

Two contract notes:

  • Source::deserialize is called synchronously from Config::build. I/O and async work belong in the source's constructor: fetch on new, store the materialized data, hand it out on demand.
  • Convert your own error into erased_serde::Error via <erased_serde::Error as serde::de::Error>::custom(my_error). Config::build wraps that in Error::Deserialize with the source's name().

See the Source trait's rustdoc for the full doctested example.

Cargo features

Only env is enabled by default; opt into the file and CLI sources you need.

Feature Enables Extra dependency
env EnvSource / add_env (default) serde_json
json JsonStr / add_json_* serde_json
toml TomlStr / add_toml_* toml
cli CliSource / add_cli serde_json
clap-derive ClapArgs derive (provcfg-clap) clap
[dependencies]
provcfg = { version = "0.1", features = ["toml", "cli"] }

clap integration

With the clap-derive feature, derive ClapArgs alongside Configurable to generate a clap-compatible args struct that flows straight into a CLI source. See the provcfg-clap crate.

License

Licensed under either of:

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.