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}