Skip to main content

dynamic_config/
bindings.rs

1//! Binding one field to one environment variable, by name.
2//!
3//! The [`env`](crate::LoadSpec::env_prefix) layer covers the case where the
4//! variable names are yours to choose: `APP_DB_POOL__MAX_SIZE` follows from the
5//! prefix, the key and the field. It does not cover the case where they are
6//! not.
7//!
8//! ```text
9//! PORT                 the platform picked it — Heroku, Cloud Run, Fly
10//! DATABASE_URL         a convention older than this program
11//! REDIS_URL            an add-on wrote it into the environment
12//! ```
13//!
14//! None of those can be renamed, and none of them fit a prefix. A binding says
15//! *this field comes from that variable*, and nothing else changes:
16//!
17//! ```rust,no_run
18//! # #[cfg(feature = "toml")] {
19//! # use serde::Deserialize;
20//! # #[dynamic_config::dynamic_config]
21//! # #[derive(Deserialize)] struct ServerConfig { port: u16 }
22//! ServerConfig::bind_env("port", "PORT")?;
23//!
24//! ServerConfig::builder("server")
25//!     .file("config.toml")
26//!     .env("APP_")
27//!     .init()?;
28//! # }
29//! # Ok::<(), dynamic_config::Error>(())
30//! ```
31//!
32//! # Where it sits
33//!
34//! ```text
35//! defaults < files < remote < APP_SERVER_* < bindings < flags < overrides
36//! ```
37//!
38//! Just above the prefixed environment layer, because a binding is the more
39//! specific statement: somebody named this variable on purpose, and the
40//! prefixed one is a convention.
41//!
42//! # Read at load time, not at binding time
43//!
44//! The variable is looked up during each `load()`, so a reload picks up a
45//! change to it. Binding a variable that is not set contributes nothing — it is
46//! not an error, because the whole point is that the platform may or may not
47//! have set it.
48
49use std::collections::BTreeMap;
50use std::sync::{Arc, Mutex};
51
52use crate::value::Value;
53
54use crate::error::Error;
55
56/// The environment variables bound to fields of one configuration type.
57///
58/// `EnvBindings::new()` is `const`, so this lives in a `static` — which is how
59/// `#[dynamic_config]` emits it.
60#[derive(Debug, Default)]
61pub struct EnvBindings {
62    /// Key path → variable name.
63    entries: Mutex<BTreeMap<String, String>>,
64}
65
66impl EnvBindings {
67    /// No bindings.
68    #[must_use]
69    pub const fn new() -> Self {
70        Self {
71            entries: Mutex::new(BTreeMap::new()),
72        }
73    }
74
75    /// Binds the field at `path` to the environment variable `variable`.
76    ///
77    /// Takes effect on the next `load()`. Binding the same path twice replaces
78    /// the first binding rather than layering it: two variables for one field
79    /// would have no defensible order between them.
80    ///
81    /// # Errors
82    ///
83    /// If `path` is empty or has an empty segment — `"a..b"` names nothing.
84    pub fn bind(&self, path: &str, variable: &str) -> Result<(), Error> {
85        crate::layer::check_path(path)?;
86
87        self.lock().insert(path.to_owned(), variable.to_owned());
88
89        Ok(())
90    }
91
92    /// Drops every binding.
93    pub fn clear(&self) {
94        self.lock().clear();
95    }
96
97    /// Whether anything is bound.
98    #[must_use]
99    pub fn is_empty(&self) -> bool {
100        self.lock().is_empty()
101    }
102
103    /// The variable bound to `path`, if any.
104    #[must_use]
105    pub fn variable(&self, path: &str) -> Option<String> {
106        self.lock().get(path).cloned()
107    }
108
109    /// One provider per binding, so a value can be traced to the variable that
110    /// supplied it rather than to "a binding" in general.
111    ///
112    /// figment attaches metadata per provider, not per key, so naming the
113    /// variable means one provider each. There are as many as the program made
114    /// bindings — a handful — and they are built once per load.
115    ///
116    /// `fallback` is what the `.env` files say, for the variables the real
117    /// environment does not set. A binding names one variable exactly, and a
118    /// deployment that writes that variable into a `.env` file rather than
119    /// exporting it means the same thing by it.
120    /// Every bound variable that has a value, as `(path, variable, value)`.
121    ///
122    /// The same resolution the provider does, answering with the value
123    /// instead of a layer — one variable, one contribution, so provenance
124    /// names the variable that supplied the leaf.
125    pub(crate) fn resolved(
126        &self,
127        allow_empty: bool,
128        fallback: Arc<BTreeMap<String, String>>,
129    ) -> Vec<(String, String, crate::Value)> {
130        self.lock()
131            .iter()
132            .filter_map(|(path, variable)| {
133                resolve(variable, allow_empty, &fallback)
134                    .map(|value| (path.clone(), variable.clone(), value))
135            })
136            .collect()
137    }
138
139    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, String>> {
140        // Recovered rather than propagated, as everywhere else in the crate:
141        // the map has no invariant a panic could break.
142        self.entries
143            .lock()
144            .unwrap_or_else(std::sync::PoisonError::into_inner)
145    }
146}
147
148/// Reads one variable, or `None` if it does not usefully exist.
149///
150/// The real environment first, then the `.env` files — the order those two
151/// layers already sit in, so a binding does not invert it.
152fn resolve(
153    variable: &str,
154    allow_empty: bool,
155    fallback: &BTreeMap<String, String>,
156) -> Option<Value> {
157    let from_environment = std::env::var_os(variable)
158        .and_then(|text| text.to_str().map(ToOwned::to_owned))
159        .filter(|text| allow_empty || !text.trim().is_empty());
160
161    let text = match from_environment {
162        Some(text) => text,
163        None => fallback.get(variable)?.clone(),
164    };
165
166    // The same rule as the prefixed layer and `.env` files, `allow_empty_env`
167    // included: an unset value rendered into a deployment template leaves
168    // exactly `PORT=` (or a run of spaces), and letting that blank out a good
169    // value is a bad afternoon — unless the program asked for exactly that.
170    if text.trim().is_empty() && !allow_empty {
171        return None;
172    }
173
174    let text = text.as_str();
175
176    // Parsed the way the environment layer parses: `8080` is a number, `[1,2]`
177    // a list, and anything else a string.
178    Some(crate::text_value::from_text(text))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn a_path_with_an_empty_segment_is_refused() {
187        let bindings = EnvBindings::new();
188
189        assert!(bindings.bind("pool..max", "X").is_err());
190        assert!(bindings.bind("", "X").is_err());
191        assert!(bindings.bind("pool.max", "X").is_ok());
192    }
193
194    #[test]
195    fn binding_the_same_path_twice_replaces_rather_than_layers() {
196        let bindings = EnvBindings::new();
197
198        bindings.bind("port", "OLD_PORT").unwrap();
199        bindings.bind("port", "PORT").unwrap();
200
201        assert_eq!(bindings.variable("port").as_deref(), Some("PORT"));
202    }
203
204    #[test]
205    fn clearing_removes_everything() {
206        let bindings = EnvBindings::new();
207
208        bindings.bind("port", "PORT").unwrap();
209        assert!(!bindings.is_empty());
210
211        bindings.clear();
212        assert!(bindings.is_empty());
213    }
214}