1use 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#[derive(Debug, Default, Clone)]
69pub struct Env {
70 prefix_override: Option<String>,
71}
72
73impl Env {
74 pub fn new() -> Self {
77 Default::default()
78 }
79
80 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 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 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}