Skip to main content

tanzim_load/
env.rs

1//! Environment-variable loader (`env` feature).
2//!
3//! Reads process environment variables and groups them into configuration entries using a
4//! configurable `prefix` and an optional key `separator`.
5//!
6//! **Source:** `env` (the source resource must be empty — a non-empty resource is rejected with
7//! [`Error::InvalidResource`])
8//!
9//! # Behaviour
10//!
11//! - Only variables whose name starts with `prefix` are considered. With `strip_prefix = true`
12//!   (the default when a prefix is set) the prefix is removed from each key first.
13//! - Without a `separator`, every matching variable becomes a `KEY="value"` line in a single
14//!   unnamed entry (`maybe_name = None`) that merges into the configuration root.
15//! - With a `separator`, each key is split once on its first occurrence: the left part is the
16//!   entry name and the right part is the key within that entry. Keys that don't contain the
17//!   separator (or whose split yields an empty side) are skipped.
18//! - Every produced entry has `maybe_format = "env"`, so the `KEY="value"` lines are handed to
19//!   the `env` parser. Entry names are lower-cased when `lowercase = true` (the default).
20//!
21//! # Options
22//!
23//! - `prefix` — string (default: detected from `CARGO_BIN_NAME`, `CARGO_CRATE_NAME`, or the
24//!   executable name, with `_` suffix)
25//! - `strip_prefix` — boolean (default `true`; only applies when `prefix` is non-empty)
26//! - `separator` — string (no default; when set, splits keys into entry name and content key)
27//! - `lowercase` — boolean (default `true`; whether to lowercase the entry names)
28//!
29//! # Example
30//!
31//! ```text
32//! env
33//! env(prefix=APP_NAME_,separator=.)
34//! ```
35
36use crate::{Error, Load, Payload, Source};
37use cfg_if::cfg_if;
38use std::{collections::HashMap, env};
39
40pub const NAME: &str = "Environment-Variables";
41pub const SOURCE: &str = "env";
42
43/// Loader for the `env` source: reads process environment variables into configuration entries.
44///
45/// See the [module docs](self) for the grouping behaviour and options. Construct with
46/// [`Env::new`], or pin a prefix with [`Env::with_prefix`] instead of relying on the
47/// auto-detected default.
48///
49/// # Example
50///
51/// ```
52/// use tanzim_load::{env::Env, Load};
53/// use tanzim_source::SourceBuilder;
54///
55/// // SAFETY: example-only; single-threaded doctest env vars.
56/// unsafe { std::env::set_var("MYAPP_DEBUG", "true"); }
57///
58/// let source = SourceBuilder::new()
59///     .with_source("env")
60///     .with_option("prefix", "MYAPP_")
61///     .build()
62///     .unwrap();
63///
64/// let payloads = Env::new().load(source).unwrap();
65/// let content = String::from_utf8_lossy(&payloads[0].content);
66/// assert!(content.contains(r#"DEBUG="true""#));
67/// ```
68#[derive(Debug, Default, Clone)]
69pub struct Env {
70    prefix_override: Option<String>,
71}
72
73impl Env {
74    /// Create a loader whose prefix is taken from the source's `prefix` option, or
75    /// auto-detected (see [`Env::detect_prefix`]) when that option is absent.
76    pub fn new() -> Self {
77        Default::default()
78    }
79
80    /// Detect the prefix from the environment variables.
81    /// The prefix is the string that is prepended to the environment variable names.
82    /// The default prefix is the name of the cargo bin `CARGO_BIN_NAME`, cargo crate `CARGO_CRATE_NAME`, or the executable name, with `_` suffix.
83    pub fn detect_prefix() -> Option<String> {
84        let mut prefix = option_env!("CARGO_BIN_NAME").unwrap_or("").to_string();
85        if prefix.is_empty() {
86            prefix = option_env!("CARGO_CRATE_NAME").unwrap_or("").to_string();
87        }
88        if prefix.is_empty()
89            && let Ok(path) = env::current_exe()
90            && let Some(file_name) = path.file_name().and_then(|name| name.to_str())
91        {
92            prefix = file_name.to_string();
93            #[cfg(windows)]
94            if prefix.len() >= 4
95                && prefix.as_bytes()[prefix.len() - 4..].eq_ignore_ascii_case(b".exe")
96            {
97                prefix.truncate(prefix.len() - 4);
98            }
99        }
100        if !prefix.is_empty() {
101            prefix.push('_');
102        }
103
104        if prefix.is_empty() {
105            None
106        } else {
107            Some(prefix)
108        }
109    }
110
111    pub fn set_maybe_prefix<P: Into<String>>(&mut self, maybe_prefix: Option<P>) {
112        if let Some(prefix) = maybe_prefix {
113            self.set_prefix(prefix);
114        }
115    }
116
117    pub fn set_prefix<P: Into<String>>(&mut self, prefix: P) {
118        self.prefix_override = Some(prefix.into());
119    }
120
121    pub fn with_prefix<P: Into<String>>(mut self, prefix: P) -> Self {
122        self.set_prefix(prefix.into());
123        self
124    }
125}
126
127impl Load for Env {
128    fn name(&self) -> &str {
129        NAME
130    }
131
132    fn supported_source_list(&self) -> Vec<String> {
133        vec![SOURCE.to_string()]
134    }
135
136    fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
137        let options = source.options().clone();
138        let resource = source.resource().to_string();
139
140        if !resource.is_empty() {
141            return Err(Error::InvalidResource {
142                loader: NAME.to_string(),
143                resource: resource.to_string(),
144                reason: "resource must be empty".into(),
145            });
146        }
147
148        let maybe_prefix = if let Some(prefix_override) = &self.prefix_override {
149            Some(prefix_override.clone())
150        } else {
151            match options.get("prefix") {
152                None => None,
153                Some(value) => {
154                    if let Some(prefix) = value.as_string() {
155                        Some(prefix.into())
156                    } else {
157                        return Err(Error::InvalidOption {
158                            loader: NAME.to_string(),
159                            key: "prefix".to_string(),
160                            reason: format!("expected string, found {}", value.type_name()),
161                        });
162                    }
163                }
164            }
165        };
166
167        let separator = match options.get("separator") {
168            None => None,
169            Some(value) => {
170                if let Some(separator) = value.as_string() {
171                    Some(separator.clone())
172                } else {
173                    return Err(Error::InvalidOption {
174                        loader: NAME.to_string(),
175                        key: "separator".to_string(),
176                        reason: format!("expected string, found {}", value.type_name()),
177                    });
178                }
179            }
180        };
181
182        let strip_prefix = if let Some(strip_prefix) = options.get("strip_prefix") {
183            if let Some(strip_prefix) = strip_prefix.as_bool() {
184                strip_prefix
185            } else {
186                if maybe_prefix.is_some() {
187                    return Err(Error::InvalidOption {
188                        loader: NAME.to_string(),
189                        key: "strip_prefix".to_string(),
190                        reason: format!("expected boolean, found {}", strip_prefix.type_name()),
191                    });
192                }
193                false
194            }
195        } else {
196            maybe_prefix.is_some()
197        };
198
199        let lowercase = if let Some(value) = options.get("lowercase") {
200            if let Some(value) = value.as_bool() {
201                value
202            } else {
203                return Err(Error::InvalidOption {
204                    loader: NAME.to_string(),
205                    key: "lowercase".to_string(),
206                    reason: format!("expected boolean, found {}", value.type_name()),
207                });
208            }
209        } else {
210            true
211        };
212
213        let prefix = maybe_prefix.unwrap_or_default();
214
215        cfg_if! {
216            if #[cfg(feature = "tracing")] {
217                tracing::debug!(msg = "Loading configuration from environment variables", prefix = prefix, strip_prefix = strip_prefix, separator = ?separator, lowercase = lowercase);
218            } else if #[cfg(feature = "logging")] {
219                log::debug!("msg=\"Loading configuration from environment variables\" prefix={prefix} strip_prefix={strip_prefix} separator={separator:?} lowercase={lowercase}");
220            }
221        }
222
223        let mut grouped: HashMap<Option<String>, Vec<u8>> = HashMap::new();
224
225        for (key, value) in env::vars() {
226            if !prefix.is_empty() && !key.starts_with(&prefix) {
227                continue;
228            }
229
230            let mut env_key = key;
231            if strip_prefix {
232                env_key = env_key.chars().skip(prefix.chars().count()).collect();
233            }
234            if env_key.is_empty() {
235                continue;
236            }
237
238            let (name, content_key) = match &separator {
239                None => (None, env_key),
240                Some(separator) => {
241                    let mut parts = env_key.splitn(2, separator.as_str());
242                    let first = parts.next().unwrap_or("").trim();
243                    let Some(rest) = parts.next() else {
244                        continue;
245                    };
246                    let rest = rest.trim();
247                    if first.is_empty() || rest.is_empty() {
248                        continue;
249                    }
250                    let entry_name = if lowercase {
251                        let lower = first.to_lowercase();
252                        if lower != first {
253                            cfg_if! {
254                                if #[cfg(feature = "tracing")] {
255                                    tracing::debug!(msg = "Lowercased environment variable entry name", from = first, to = lower.as_str(), env_key = env_key);
256                                } else if #[cfg(feature = "logging")] {
257                                    log::debug!("msg=\"Lowercased environment variable entry name\" from={first} to={lower} env_key={env_key}");
258                                }
259                            }
260                        }
261                        lower
262                    } else {
263                        first.to_string()
264                    };
265                    (Some(entry_name), rest.to_string())
266                }
267            };
268
269            let line = format!("{content_key}={value:?}");
270            if let Some(content) = grouped.get_mut(&name) {
271                content.push(b'\n');
272                content.extend_from_slice(line.as_bytes());
273            } else {
274                grouped.insert(name, line.into_bytes());
275            }
276        }
277
278        let mut payload_list = Vec::with_capacity(grouped.len());
279        for (maybe_name, content) in grouped {
280            cfg_if! {
281                if #[cfg(feature = "tracing")] {
282                    tracing::trace!(msg = "Detected configuration from environment variables", name = ?maybe_name.as_deref().unwrap_or("<empty>"), format = "env");
283                } else if #[cfg(feature = "logging")] {
284                    log::trace!("msg=\"Detected configuration from environment variables\" name={} format=\"env\"", maybe_name.as_deref().unwrap_or("<empty>"));
285                }
286            }
287            payload_list.push(Payload {
288                source: source.clone(),
289                maybe_name,
290                maybe_format: Some("env".into()),
291                content,
292            });
293        }
294
295        cfg_if! {
296            if #[cfg(feature = "tracing")] {
297                tracing::info!(msg = "Loaded configuration from environment variables", group_count = payload_list.len());
298            } else if #[cfg(feature = "logging")] {
299                log::info!("msg=\"Loaded configuration from environment variables\" group_count={}", payload_list.len());
300            }
301        }
302
303        Ok(payload_list)
304    }
305}
306
307#[cfg(all(test, feature = "env"))]
308mod tests {
309    use super::*;
310    use std::env;
311    use tanzim_source::{Options, SourceBuilder};
312
313    fn make_source_with_options(options: Options) -> Source {
314        let mut builder = SourceBuilder::new().with_source("env");
315        builder = builder.with_options(options);
316        builder.build().unwrap()
317    }
318
319    #[test]
320    fn load_groups_environment_variables_by_name() {
321        // SAFETY: test-only; single-threaded test env vars.
322        unsafe {
323            env::set_var("TANZIM_TEST__FOO__BAR", "baz");
324            env::set_var("TANZIM_TEST__QUX__ABC", "123");
325        }
326
327        let mut options = Options::new();
328        options.insert("prefix", "TANZIM_TEST__");
329        options.insert("separator", "__");
330        let loaded = Env::new().load(make_source_with_options(options)).unwrap();
331
332        let mut foo = None;
333        let mut qux = None;
334        for payload in &loaded {
335            if payload.maybe_name == Some("foo".to_string()) {
336                foo = Some(payload);
337            } else if payload.maybe_name == Some("qux".to_string()) {
338                qux = Some(payload);
339            }
340        }
341
342        let foo = foo.expect("foo payload");
343        assert_eq!(foo.maybe_format, Some("env".to_string()));
344        assert!(String::from_utf8_lossy(&foo.content).contains("BAR=\"baz\""));
345
346        let qux = qux.expect("qux payload");
347        assert!(String::from_utf8_lossy(&qux.content).contains("ABC=\"123\""));
348    }
349
350    #[test]
351    fn load_without_separator_puts_all_keys_in_one_payload() {
352        // SAFETY: test-only; single-threaded test env vars.
353        unsafe {
354            env::set_var("TANZIM_FLAT__FOO", "1");
355            env::set_var("TANZIM_FLAT__BAR", "2");
356        }
357
358        let mut options = Options::new();
359        options.insert("prefix", "TANZIM_FLAT__");
360        let loaded = Env::new().load(make_source_with_options(options)).unwrap();
361
362        assert_eq!(loaded.len(), 1);
363        let payload = &loaded[0];
364        assert!(payload.maybe_name.is_none());
365        let content = String::from_utf8_lossy(&payload.content);
366        assert!(content.contains("FOO=\"1\""));
367        assert!(content.contains("BAR=\"2\""));
368    }
369
370    #[test]
371    fn load_rejects_non_empty_resource() {
372        let source = SourceBuilder::new()
373            .with_source("env")
374            .with_resource("oops")
375            .build()
376            .unwrap();
377        let error = Env::new().load(source).unwrap_err();
378        assert!(matches!(error, Error::InvalidResource { .. }));
379    }
380
381    #[test]
382    fn load_honors_strip_prefix_and_lowercase_options() {
383        unsafe {
384            env::set_var("TANZIM_CASE__Foo__BAR", "1");
385        }
386        let mut options = Options::new();
387        options.insert("prefix", "TANZIM_CASE__");
388        options.insert("separator", "__");
389        options.insert("lowercase", false);
390        let loaded = Env::new().load(make_source_with_options(options)).unwrap();
391        assert_eq!(loaded.len(), 1);
392        assert_eq!(loaded[0].maybe_name.as_deref(), Some("Foo"));
393    }
394
395    #[test]
396    fn load_ignores_unknown_option() {
397        let mut options = Options::new();
398        options.insert("bogus", true);
399        Env::new()
400            .load(make_source_with_options(options))
401            .expect("unknown options are ignored");
402    }
403
404    #[test]
405    fn load_rejects_bad_separator_type() {
406        let mut options = Options::new();
407        options.insert("separator", 1_i64);
408        let error = Env::new()
409            .load(make_source_with_options(options))
410            .unwrap_err();
411        assert!(matches!(error, Error::InvalidOption { key, .. } if key == "separator"));
412    }
413
414    #[test]
415    fn with_prefix_override_skips_source_option() {
416        unsafe {
417            env::set_var("PINNED__X", "yes");
418        }
419        let source = SourceBuilder::new()
420            .with_source("env")
421            .with_option("prefix", "OTHER__")
422            .build()
423            .unwrap();
424        let loaded = Env::new().with_prefix("PINNED__").load(source).unwrap();
425        let content = String::from_utf8_lossy(&loaded[0].content);
426        assert!(content.contains(r#"X="yes""#));
427    }
428}