dynamic-config 0.9.0

Hot-reloadable, lock-free application configuration with a one-attribute API.
Documentation
//! Binding one field to one environment variable, by name.
//!
//! The [`env`](crate::LoadSpec::env_prefix) layer covers the case where the
//! variable names are yours to choose: `APP_DB_POOL__MAX_SIZE` follows from the
//! prefix, the key and the field. It does not cover the case where they are
//! not.
//!
//! ```text
//! PORT                 the platform picked it — Heroku, Cloud Run, Fly
//! DATABASE_URL         a convention older than this program
//! REDIS_URL            an add-on wrote it into the environment
//! ```
//!
//! None of those can be renamed, and none of them fit a prefix. A binding says
//! *this field comes from that variable*, and nothing else changes:
//!
//! ```rust,no_run
//! # #[cfg(feature = "toml")] {
//! # use serde::Deserialize;
//! # #[dynamic_config::dynamic_config]
//! # #[derive(Deserialize)] struct ServerConfig { port: u16 }
//! ServerConfig::bind_env("port", "PORT")?;
//!
//! ServerConfig::builder("server")
//!     .file("config.toml")
//!     .env("APP_")
//!     .init()?;
//! # }
//! # Ok::<(), dynamic_config::Error>(())
//! ```
//!
//! # Where it sits
//!
//! ```text
//! defaults < files < remote < APP_SERVER_* < bindings < flags < overrides
//! ```
//!
//! Just above the prefixed environment layer, because a binding is the more
//! specific statement: somebody named this variable on purpose, and the
//! prefixed one is a convention.
//!
//! # Read at load time, not at binding time
//!
//! The variable is looked up during each `load()`, so a reload picks up a
//! change to it. Binding a variable that is not set contributes nothing — it is
//! not an error, because the whole point is that the platform may or may not
//! have set it.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use crate::value::Value;

use crate::error::Error;

/// The environment variables bound to fields of one configuration type.
///
/// `EnvBindings::new()` is `const`, so this lives in a `static` — which is how
/// `#[dynamic_config]` emits it.
#[derive(Debug, Default)]
pub struct EnvBindings {
    /// Key path → variable name.
    entries: Mutex<BTreeMap<String, String>>,
}

impl EnvBindings {
    /// No bindings.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            entries: Mutex::new(BTreeMap::new()),
        }
    }

    /// Binds the field at `path` to the environment variable `variable`.
    ///
    /// Takes effect on the next `load()`. Binding the same path twice replaces
    /// the first binding rather than layering it: two variables for one field
    /// would have no defensible order between them.
    ///
    /// # Errors
    ///
    /// If `path` is empty or has an empty segment — `"a..b"` names nothing.
    pub fn bind(&self, path: &str, variable: &str) -> Result<(), Error> {
        crate::layer::check_path(path)?;

        self.lock().insert(path.to_owned(), variable.to_owned());

        Ok(())
    }

    /// Drops every binding.
    pub fn clear(&self) {
        self.lock().clear();
    }

    /// Whether anything is bound.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.lock().is_empty()
    }

    /// The variable bound to `path`, if any.
    #[must_use]
    pub fn variable(&self, path: &str) -> Option<String> {
        self.lock().get(path).cloned()
    }

    /// One provider per binding, so a value can be traced to the variable that
    /// supplied it rather than to "a binding" in general.
    ///
    /// figment attaches metadata per provider, not per key, so naming the
    /// variable means one provider each. There are as many as the program made
    /// bindings — a handful — and they are built once per load.
    ///
    /// `fallback` is what the `.env` files say, for the variables the real
    /// environment does not set. A binding names one variable exactly, and a
    /// deployment that writes that variable into a `.env` file rather than
    /// exporting it means the same thing by it.
    /// Every bound variable that has a value, as `(path, variable, value)`.
    ///
    /// The same resolution the provider does, answering with the value
    /// instead of a layer — one variable, one contribution, so provenance
    /// names the variable that supplied the leaf.
    pub(crate) fn resolved(
        &self,
        allow_empty: bool,
        fallback: Arc<BTreeMap<String, String>>,
    ) -> Vec<(String, String, crate::Value)> {
        self.lock()
            .iter()
            .filter_map(|(path, variable)| {
                resolve(variable, allow_empty, &fallback)
                    .map(|value| (path.clone(), variable.clone(), value))
            })
            .collect()
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, String>> {
        // Recovered rather than propagated, as everywhere else in the crate:
        // the map has no invariant a panic could break.
        self.entries
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }
}

/// Reads one variable, or `None` if it does not usefully exist.
///
/// The real environment first, then the `.env` files — the order those two
/// layers already sit in, so a binding does not invert it.
fn resolve(
    variable: &str,
    allow_empty: bool,
    fallback: &BTreeMap<String, String>,
) -> Option<Value> {
    let from_environment = std::env::var_os(variable)
        .and_then(|text| text.to_str().map(ToOwned::to_owned))
        .filter(|text| allow_empty || !text.trim().is_empty());

    let text = match from_environment {
        Some(text) => text,
        None => fallback.get(variable)?.clone(),
    };

    // The same rule as the prefixed layer and `.env` files, `allow_empty_env`
    // included: an unset value rendered into a deployment template leaves
    // exactly `PORT=` (or a run of spaces), and letting that blank out a good
    // value is a bad afternoon — unless the program asked for exactly that.
    if text.trim().is_empty() && !allow_empty {
        return None;
    }

    let text = text.as_str();

    // Parsed the way the environment layer parses: `8080` is a number, `[1,2]`
    // a list, and anything else a string.
    Some(crate::text_value::from_text(text))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_path_with_an_empty_segment_is_refused() {
        let bindings = EnvBindings::new();

        assert!(bindings.bind("pool..max", "X").is_err());
        assert!(bindings.bind("", "X").is_err());
        assert!(bindings.bind("pool.max", "X").is_ok());
    }

    #[test]
    fn binding_the_same_path_twice_replaces_rather_than_layers() {
        let bindings = EnvBindings::new();

        bindings.bind("port", "OLD_PORT").unwrap();
        bindings.bind("port", "PORT").unwrap();

        assert_eq!(bindings.variable("port").as_deref(), Some("PORT"));
    }

    #[test]
    fn clearing_removes_everything() {
        let bindings = EnvBindings::new();

        bindings.bind("port", "PORT").unwrap();
        assert!(!bindings.is_empty());

        bindings.clear();
        assert!(bindings.is_empty());
    }
}