Skip to main content

qubit_config/source/
env_config_source.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! # System Environment Variable Configuration Source
11//!
12//! Loads configuration from the current process's environment variables.
13//!
14//! # Key Transformation
15//!
16//! When a prefix is set, only variables matching the prefix are loaded, and
17//! the prefix is stripped from the key name. The key is then lowercased and
18//! underscores are converted to dots to produce the config key.
19//!
20//! For example, with prefix `APP_`:
21//! - `APP_SERVER_HOST=localhost` → `server.host = "localhost"`
22//! - `APP_SERVER_PORT=8080` → `server.port = "8080"`
23//!
24//! Without a prefix, all environment variables are loaded as-is.
25//!
26
27use std::{
28    collections::HashMap,
29    ffi::{
30        OsStr,
31        OsString,
32    },
33};
34
35use crate::{
36    Config,
37    ConfigError,
38    ConfigResult,
39    utils,
40};
41
42use super::{
43    ConfigSource,
44    config_source::load_transactionally,
45};
46
47/// Configuration source that loads from system environment variables
48///
49/// # Examples
50///
51/// ```rust
52/// use qubit_config::source::{EnvConfigSource, ConfigSource};
53/// use qubit_config::Config;
54///
55/// // Load all env vars
56/// let source = EnvConfigSource::new();
57///
58/// // Load only vars with prefix "APP_", strip prefix and normalize key
59/// let source = EnvConfigSource::with_prefix("APP_");
60///
61/// let mut config = Config::new();
62/// source.load(&mut config).unwrap();
63/// ```
64///
65#[derive(Debug, Clone)]
66pub struct EnvConfigSource {
67    /// Optional prefix filter; only variables with this prefix are loaded
68    prefix: Option<String>,
69    /// Whether to strip the prefix from the key
70    strip_prefix: bool,
71    /// Whether to convert underscores to dots in the key
72    convert_underscores: bool,
73    /// Whether to lowercase the key
74    lowercase_keys: bool,
75}
76
77impl EnvConfigSource {
78    /// Creates a new `EnvConfigSource` that loads all environment variables.
79    ///
80    /// Keys are loaded as-is (no prefix filtering, no transformation).
81    ///
82    /// # Returns
83    ///
84    /// A source that ingests every `std::env::vars()` entry.
85    #[inline]
86    pub fn new() -> Self {
87        Self {
88            prefix: None,
89            strip_prefix: false,
90            convert_underscores: false,
91            lowercase_keys: false,
92        }
93    }
94
95    /// Creates a new `EnvConfigSource` that filters by prefix and normalizes
96    /// keys.
97    ///
98    /// Only variables with the given prefix are loaded. The prefix is stripped,
99    /// the key is lowercased, and underscores are converted to dots.
100    ///
101    /// # Parameters
102    ///
103    /// * `prefix` - The prefix to filter by (e.g., `"APP_"`)
104    ///
105    /// # Returns
106    ///
107    /// A source with prefix filtering and key normalization enabled.
108    #[inline]
109    pub fn with_prefix(prefix: &str) -> Self {
110        Self {
111            prefix: Some(prefix.to_string()),
112            strip_prefix: true,
113            convert_underscores: true,
114            lowercase_keys: true,
115        }
116    }
117
118    /// Creates a new `EnvConfigSource` with a custom prefix and explicit
119    /// options.
120    ///
121    /// # Parameters
122    ///
123    /// * `prefix` - The prefix to filter by
124    /// * `strip_prefix` - Whether to strip the prefix from the key
125    /// * `convert_underscores` - Whether to convert underscores to dots
126    /// * `lowercase_keys` - Whether to lowercase the key
127    ///
128    /// # Returns
129    ///
130    /// A configured [`EnvConfigSource`].
131    #[inline]
132    pub fn with_options(prefix: &str, strip_prefix: bool, convert_underscores: bool, lowercase_keys: bool) -> Self {
133        Self {
134            prefix: Some(prefix.to_string()),
135            strip_prefix,
136            convert_underscores,
137            lowercase_keys,
138        }
139    }
140
141    /// Transforms an environment variable key according to the source's
142    /// settings.
143    ///
144    /// # Parameters
145    ///
146    /// * `key` - Original environment variable name.
147    ///
148    /// # Returns
149    ///
150    /// The key after optional prefix strip, lowercasing, and underscore
151    /// replacement.
152    fn transform_key(&self, key: &str) -> String {
153        let mut result = key.to_string();
154
155        if self.strip_prefix
156            && let Some(prefix) = &self.prefix
157            && result.starts_with(prefix.as_str())
158        {
159            result = result[prefix.len()..].to_string();
160        }
161
162        if self.lowercase_keys {
163            result = result.to_lowercase();
164        }
165
166        if self.convert_underscores {
167            result = result.replace('_', ".");
168        }
169
170        result
171    }
172
173    /// Returns whether source key transformations can merge distinct names.
174    ///
175    /// # Returns
176    ///
177    /// `true` when the source should reject duplicate normalized keys emitted
178    /// by a single load operation.
179    #[inline]
180    fn can_collapse_distinct_keys(&self) -> bool {
181        self.strip_prefix || self.convert_underscores || self.lowercase_keys
182    }
183
184    /// Checks whether an environment variable key matches a UTF-8 prefix.
185    ///
186    /// # Parameters
187    ///
188    /// * `key` - Environment variable key from [`std::env::vars_os`].
189    /// * `prefix` - UTF-8 prefix configured on this source.
190    ///
191    /// # Returns
192    ///
193    /// `true` if the key starts with `prefix`. On Unix, non-Unicode keys are
194    /// compared as bytes so unrelated invalid keys can still be skipped by a
195    /// prefixed source.
196    fn env_key_matches_prefix(key: &OsStr, prefix: &str) -> bool {
197        key.to_str().map_or_else(
198            || Self::non_unicode_env_key_matches_prefix(key, prefix),
199            |key| key.starts_with(prefix),
200        )
201    }
202
203    /// Checks a non-Unicode environment key against a UTF-8 prefix.
204    ///
205    /// # Parameters
206    ///
207    /// * `key` - Non-Unicode environment variable key.
208    /// * `prefix` - UTF-8 prefix configured on this source.
209    ///
210    /// # Returns
211    ///
212    /// `true` on Unix when the raw key bytes start with the UTF-8 prefix bytes;
213    /// `false` on platforms where raw environment bytes are unavailable.
214    #[cfg(unix)]
215    fn non_unicode_env_key_matches_prefix(key: &OsStr, prefix: &str) -> bool {
216        use std::os::unix::ffi::OsStrExt;
217
218        key.as_bytes().starts_with(prefix.as_bytes())
219    }
220
221    /// Checks a non-Unicode environment key against a UTF-8 prefix.
222    ///
223    /// # Parameters
224    ///
225    /// * `_key` - Non-Unicode environment variable key.
226    /// * `_prefix` - UTF-8 prefix configured on this source.
227    ///
228    /// # Returns
229    ///
230    /// Always `false` on non-Unix platforms because raw environment bytes are not
231    /// available through the standard library.
232    #[cfg(not(unix))]
233    fn non_unicode_env_key_matches_prefix(_key: &OsStr, _prefix: &str) -> bool {
234        false
235    }
236
237    /// Converts an OS environment string to UTF-8.
238    ///
239    /// # Parameters
240    ///
241    /// * `value` - Environment key or value returned by [`std::env::vars_os`].
242    /// * `label` - Human-readable label included in parse errors.
243    ///
244    /// # Returns
245    ///
246    /// `Ok(String)` when `value` is valid UTF-8.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`ConfigError::ParseError`] when `value` is not valid Unicode,
251    /// preserving a lossy representation in the diagnostic message.
252    fn env_os_string_to_string(value: OsString, label: &str) -> ConfigResult<String> {
253        value.into_string().map_err(|value| {
254            ConfigError::ParseError(format!("{label} is not valid Unicode: {}", value.to_string_lossy(),))
255        })
256    }
257}
258
259impl Default for EnvConfigSource {
260    #[inline]
261    fn default() -> Self {
262        Self::new()
263    }
264}
265
266impl ConfigSource for EnvConfigSource {
267    fn load(&self, config: &mut Config) -> ConfigResult<()> {
268        load_transactionally(self, config)
269    }
270
271    fn load_into(&self, config: &mut Config) -> ConfigResult<()> {
272        let mut normalized_keys = HashMap::new();
273
274        for (key_os, value_os) in std::env::vars_os() {
275            // Filter by prefix if set
276            if let Some(prefix) = &self.prefix
277                && !Self::env_key_matches_prefix(&key_os, prefix)
278            {
279                continue;
280            }
281
282            let key = Self::env_os_string_to_string(key_os, "Environment variable key")?;
283            let value = Self::env_os_string_to_string(value_os, &format!("Value for environment variable '{key}'"))?;
284            let transformed_key = self.transform_key(&key);
285            if self.strip_prefix || self.convert_underscores {
286                utils::validate_normalized_config_key(&transformed_key, &key)?;
287            }
288            if self.can_collapse_distinct_keys()
289                && let Some(existing) = normalized_keys.insert(transformed_key.clone(), key.clone())
290            {
291                return Err(ConfigError::KeyConflict {
292                    path: transformed_key,
293                    existing: format!("environment variable '{existing}'"),
294                    incoming: format!("environment variable '{key}'"),
295                });
296            }
297            config.set(&transformed_key, value)?;
298        }
299
300        Ok(())
301    }
302}