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 figment::value::{Dict, Value};
53use figment::{Metadata, Profile, Provider};
54
55use crate::error::Error;
56
57/// The environment variables bound to fields of one configuration type.
58///
59/// `EnvBindings::new()` is `const`, so this lives in a `static` — which is how
60/// `#[dynamic_config]` emits it.
61#[derive(Debug, Default)]
62pub struct EnvBindings {
63 /// Key path → variable name.
64 entries: Mutex<BTreeMap<String, String>>,
65}
66
67impl EnvBindings {
68 /// No bindings.
69 #[must_use]
70 pub const fn new() -> Self {
71 Self {
72 entries: Mutex::new(BTreeMap::new()),
73 }
74 }
75
76 /// Binds the field at `path` to the environment variable `variable`.
77 ///
78 /// Takes effect on the next `load()`. Binding the same path twice replaces
79 /// the first binding rather than layering it: two variables for one field
80 /// would have no defensible order between them.
81 ///
82 /// # Errors
83 ///
84 /// If `path` is empty or has an empty segment — `"a..b"` names nothing.
85 pub fn bind(&self, path: &str, variable: &str) -> Result<(), Error> {
86 crate::layer::check_path(path)?;
87
88 self.lock().insert(path.to_owned(), variable.to_owned());
89
90 Ok(())
91 }
92
93 /// Drops every binding.
94 pub fn clear(&self) {
95 self.lock().clear();
96 }
97
98 /// Whether anything is bound.
99 #[must_use]
100 pub fn is_empty(&self) -> bool {
101 self.lock().is_empty()
102 }
103
104 /// The variable bound to `path`, if any.
105 #[must_use]
106 pub fn variable(&self, path: &str) -> Option<String> {
107 self.lock().get(path).cloned()
108 }
109
110 /// One provider per binding, so a value can be traced to the variable that
111 /// supplied it rather than to "a binding" in general.
112 ///
113 /// figment attaches metadata per provider, not per key, so naming the
114 /// variable means one provider each. There are as many as the program made
115 /// bindings — a handful — and they are built once per load.
116 ///
117 /// `fallback` is what the `.env` files say, for the variables the real
118 /// environment does not set. A binding names one variable exactly, and a
119 /// deployment that writes that variable into a `.env` file rather than
120 /// exporting it means the same thing by it.
121 pub(crate) fn providers(
122 &self,
123 key: &str,
124 allow_empty: bool,
125 fallback: Arc<BTreeMap<String, String>>,
126 ) -> Vec<BindingProvider> {
127 self.lock()
128 .iter()
129 .map(|(path, variable)| BindingProvider {
130 path: path.clone(),
131 variable: variable.clone(),
132 key: key.to_owned(),
133 allow_empty,
134 fallback: Arc::clone(&fallback),
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/// Prefixed onto the variable's name, so the loader can recognise this layer
149/// and report the variable rather than a category.
150pub(crate) const BINDING_PREFIX: &str = "the environment variable ";
151
152/// One binding: one path, one variable.
153pub(crate) struct BindingProvider {
154 path: String,
155 variable: String,
156 key: String,
157 allow_empty: bool,
158 /// What the `.env` files say, consulted only when the real environment
159 /// does not set the variable — the same order the layers themselves are in.
160 fallback: Arc<BTreeMap<String, String>>,
161}
162
163impl Provider for BindingProvider {
164 fn metadata(&self) -> Metadata {
165 Metadata::named(format!("{BINDING_PREFIX}{}", self.variable))
166 }
167
168 fn data(&self) -> figment::Result<figment::value::Map<Profile, Dict>> {
169 let mut values = Dict::new();
170
171 if let Some(value) = resolve(&self.variable, self.allow_empty, &self.fallback) {
172 crate::layer::insert_path(&mut values, &self.path, value);
173 }
174
175 let mut map = figment::value::Map::new();
176 map.insert(
177 Profile::from(crate::loader::section_profile(&self.key)),
178 values,
179 );
180
181 Ok(map)
182 }
183}
184
185/// Reads one variable, or `None` if it does not usefully exist.
186///
187/// The real environment first, then the `.env` files — the order those two
188/// layers already sit in, so a binding does not invert it.
189fn resolve(
190 variable: &str,
191 allow_empty: bool,
192 fallback: &BTreeMap<String, String>,
193) -> Option<Value> {
194 let from_environment = std::env::var_os(variable)
195 .and_then(|text| text.to_str().map(ToOwned::to_owned))
196 .filter(|text| allow_empty || !text.trim().is_empty());
197
198 let text = match from_environment {
199 Some(text) => text,
200 None => fallback.get(variable)?.clone(),
201 };
202
203 // The same rule as the prefixed layer and `.env` files, `allow_empty_env`
204 // included: an unset value rendered into a deployment template leaves
205 // exactly `PORT=` (or a run of spaces), and letting that blank out a good
206 // value is a bad afternoon — unless the program asked for exactly that.
207 if text.trim().is_empty() && !allow_empty {
208 return None;
209 }
210
211 let text = text.as_str();
212
213 // Parsed the way the environment layer parses: `8080` is a number, `[1,2]`
214 // a list, and anything else a string.
215 Some(
216 text.parse::<Value>()
217 .unwrap_or_else(|_| Value::from(text.to_owned())),
218 )
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn a_path_with_an_empty_segment_is_refused() {
227 let bindings = EnvBindings::new();
228
229 assert!(bindings.bind("pool..max", "X").is_err());
230 assert!(bindings.bind("", "X").is_err());
231 assert!(bindings.bind("pool.max", "X").is_ok());
232 }
233
234 #[test]
235 fn binding_the_same_path_twice_replaces_rather_than_layers() {
236 let bindings = EnvBindings::new();
237
238 bindings.bind("port", "OLD_PORT").unwrap();
239 bindings.bind("port", "PORT").unwrap();
240
241 assert_eq!(bindings.variable("port").as_deref(), Some("PORT"));
242 }
243
244 #[test]
245 fn clearing_removes_everything() {
246 let bindings = EnvBindings::new();
247
248 bindings.bind("port", "PORT").unwrap();
249 assert!(!bindings.is_empty());
250
251 bindings.clear();
252 assert!(bindings.is_empty());
253 }
254}