use crate::{Error, Load, Payload, Source};
use cfg_if::cfg_if;
use std::{collections::HashMap, env};
pub const NAME: &str = "Environment-Variables";
pub const SOURCE: &str = "env";
const ALLOWED_OPTIONS: &[&str] = &["prefix", "strip_prefix", "separator", "lowercase"];
#[derive(Debug, Default, Clone)]
pub struct Env {
prefix_override: Option<String>,
}
impl Env {
pub fn new() -> Self {
Default::default()
}
pub fn detect_prefix() -> Option<String> {
let mut prefix = option_env!("CARGO_BIN_NAME").unwrap_or("").to_string();
if prefix.is_empty() {
prefix = option_env!("CARGO_CRATE_NAME").unwrap_or("").to_string();
}
if prefix.is_empty()
&& let Ok(path) = env::current_exe()
&& let Some(file_name) = path.file_name().and_then(|name| name.to_str())
{
prefix = file_name.to_string();
#[cfg(windows)]
if prefix.len() >= 4
&& prefix.as_bytes()[prefix.len() - 4..].eq_ignore_ascii_case(b".exe")
{
prefix.truncate(prefix.len() - 4);
}
}
if !prefix.is_empty() {
prefix.push('_');
}
if prefix.is_empty() {
None
} else {
Some(prefix)
}
}
pub fn set_maybe_prefix<P: Into<String>>(&mut self, maybe_prefix: Option<P>) {
if let Some(prefix) = maybe_prefix {
self.set_prefix(prefix);
}
}
pub fn set_prefix<P: Into<String>>(&mut self, prefix: P) {
self.prefix_override = Some(prefix.into());
}
pub fn with_prefix<P: Into<String>>(mut self, prefix: P) -> Self {
self.set_prefix(prefix.into());
self
}
}
impl Load for Env {
fn name(&self) -> &str {
NAME
}
fn supported_source_list(&self) -> Vec<String> {
vec![SOURCE.to_string()]
}
fn load(&self, source: Source) -> Result<Vec<Payload>, Error> {
let options = source.options().clone();
let resource = source.resource().to_string();
if !resource.is_empty() {
return Err(Error::InvalidResource {
loader: NAME.to_string(),
resource: resource.to_string(),
reason: "resource must be empty".into(),
});
}
for key in options.keys() {
if !ALLOWED_OPTIONS.contains(&key) {
return Err(Error::InvalidOption {
loader: NAME.to_string(),
key: key.to_string(),
reason: "unknown option".into(),
});
}
}
let maybe_prefix = if let Some(prefix_override) = &self.prefix_override {
Some(prefix_override.clone())
} else {
match options.get("prefix") {
None => None,
Some(value) => {
if let Some(prefix) = value.as_string() {
Some(prefix.into())
} else {
return Err(Error::InvalidOption {
loader: NAME.to_string(),
key: "prefix".to_string(),
reason: format!("expected string, found {}", value.type_name()),
});
}
}
}
};
let separator = match options.get("separator") {
None => None,
Some(value) => {
if let Some(separator) = value.as_string() {
Some(separator.clone())
} else {
return Err(Error::InvalidOption {
loader: NAME.to_string(),
key: "separator".to_string(),
reason: format!("expected string, found {}", value.type_name()),
});
}
}
};
let strip_prefix = if let Some(strip_prefix) = options.get("strip_prefix") {
if let Some(strip_prefix) = strip_prefix.as_bool() {
strip_prefix
} else {
if maybe_prefix.is_some() {
return Err(Error::InvalidOption {
loader: NAME.to_string(),
key: "strip_prefix".to_string(),
reason: format!("expected boolean, found {}", strip_prefix.type_name()),
});
}
false
}
} else {
maybe_prefix.is_some()
};
let lowercase = if let Some(value) = options.get("lowercase") {
if let Some(value) = value.as_bool() {
value
} else {
return Err(Error::InvalidOption {
loader: NAME.to_string(),
key: "lowercase".to_string(),
reason: format!("expected boolean, found {}", value.type_name()),
});
}
} else {
true
};
let prefix = maybe_prefix.unwrap_or_default();
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Loading configuration from environment variables", prefix = prefix, strip_prefix = strip_prefix, separator = ?separator, lowercase = lowercase);
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Loading configuration from environment variables\" prefix={prefix} strip_prefix={strip_prefix} separator={separator:?} lowercase={lowercase}");
}
}
let mut grouped: HashMap<Option<String>, Vec<u8>> = HashMap::new();
for (key, value) in env::vars() {
if !prefix.is_empty() && !key.starts_with(&prefix) {
continue;
}
let mut env_key = key;
if strip_prefix {
env_key = env_key.chars().skip(prefix.chars().count()).collect();
}
if env_key.is_empty() {
continue;
}
let (name, content_key) = match &separator {
None => (None, env_key),
Some(separator) => {
let mut parts = env_key.splitn(2, separator.as_str());
let first = parts.next().unwrap_or("").trim();
let Some(rest) = parts.next() else {
continue;
};
let rest = rest.trim();
if first.is_empty() || rest.is_empty() {
continue;
}
let entry_name = if lowercase {
let lower = first.to_lowercase();
if lower != first {
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::debug!(msg = "Lowercased environment variable entry name", from = first, to = lower.as_str(), env_key = env_key);
} else if #[cfg(feature = "logging")] {
log::debug!("msg=\"Lowercased environment variable entry name\" from={first} to={lower} env_key={env_key}");
}
}
}
lower
} else {
first.to_string()
};
(Some(entry_name), rest.to_string())
}
};
let line = format!("{content_key}={value:?}");
if let Some(content) = grouped.get_mut(&name) {
content.push(b'\n');
content.extend_from_slice(line.as_bytes());
} else {
grouped.insert(name, line.into_bytes());
}
}
let mut payload_list = Vec::with_capacity(grouped.len());
for (maybe_name, content) in grouped {
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::trace!(msg = "Detected configuration from environment variables", name = ?maybe_name.as_deref().unwrap_or("<empty>"), format = "env");
} else if #[cfg(feature = "logging")] {
log::trace!("msg=\"Detected configuration from environment variables\" name={} format=\"env\"", maybe_name.as_deref().unwrap_or("<empty>"));
}
}
payload_list.push(Payload {
source: source.clone(),
maybe_name,
maybe_format: Some("env".into()),
content,
});
}
cfg_if! {
if #[cfg(feature = "tracing")] {
tracing::info!(msg = "Loaded configuration from environment variables", group_count = payload_list.len());
} else if #[cfg(feature = "logging")] {
log::info!("msg=\"Loaded configuration from environment variables\" group_count={}", payload_list.len());
}
}
Ok(payload_list)
}
}
#[cfg(all(test, feature = "env"))]
mod tests {
use super::*;
use std::env;
use tanzim_source::{Options, SourceBuilder};
fn make_source_with_options(options: Options) -> Source {
let mut builder = SourceBuilder::new().with_source("env");
builder = builder.with_options(options);
builder.build().unwrap()
}
#[test]
fn load_groups_environment_variables_by_name() {
unsafe {
env::set_var("TANZIM_TEST__FOO__BAR", "baz");
env::set_var("TANZIM_TEST__QUX__ABC", "123");
}
let mut options = Options::new();
options.insert("prefix", "TANZIM_TEST__");
options.insert("separator", "__");
let loaded = Env::new().load(make_source_with_options(options)).unwrap();
let mut foo = None;
let mut qux = None;
for payload in &loaded {
if payload.maybe_name == Some("foo".to_string()) {
foo = Some(payload);
} else if payload.maybe_name == Some("qux".to_string()) {
qux = Some(payload);
}
}
let foo = foo.expect("foo payload");
assert_eq!(foo.maybe_format, Some("env".to_string()));
assert!(String::from_utf8_lossy(&foo.content).contains("BAR=\"baz\""));
let qux = qux.expect("qux payload");
assert!(String::from_utf8_lossy(&qux.content).contains("ABC=\"123\""));
}
#[test]
fn load_without_separator_puts_all_keys_in_one_payload() {
unsafe {
env::set_var("TANZIM_FLAT__FOO", "1");
env::set_var("TANZIM_FLAT__BAR", "2");
}
let mut options = Options::new();
options.insert("prefix", "TANZIM_FLAT__");
let loaded = Env::new().load(make_source_with_options(options)).unwrap();
assert_eq!(loaded.len(), 1);
let payload = &loaded[0];
assert!(payload.maybe_name.is_none());
let content = String::from_utf8_lossy(&payload.content);
assert!(content.contains("FOO=\"1\""));
assert!(content.contains("BAR=\"2\""));
}
#[test]
fn load_rejects_non_empty_resource() {
let source = SourceBuilder::new()
.with_source("env")
.with_resource("oops")
.build()
.unwrap();
let error = Env::new().load(source).unwrap_err();
assert!(matches!(error, Error::InvalidResource { .. }));
}
#[test]
fn load_honors_strip_prefix_and_lowercase_options() {
unsafe {
env::set_var("TANZIM_CASE__Foo__BAR", "1");
}
let mut options = Options::new();
options.insert("prefix", "TANZIM_CASE__");
options.insert("separator", "__");
options.insert("lowercase", false);
let loaded = Env::new().load(make_source_with_options(options)).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].maybe_name.as_deref(), Some("Foo"));
}
#[test]
fn load_rejects_unknown_option() {
let mut options = Options::new();
options.insert("bogus", true);
let error = Env::new()
.load(make_source_with_options(options))
.unwrap_err();
assert!(matches!(error, Error::InvalidOption { .. }));
}
#[test]
fn load_rejects_bad_separator_type() {
let mut options = Options::new();
options.insert("separator", 1_i64);
let error = Env::new()
.load(make_source_with_options(options))
.unwrap_err();
assert!(matches!(error, Error::InvalidOption { key, .. } if key == "separator"));
}
#[test]
fn with_prefix_override_skips_source_option() {
unsafe {
env::set_var("PINNED__X", "yes");
}
let source = SourceBuilder::new()
.with_source("env")
.with_option("prefix", "OTHER__")
.build()
.unwrap();
let loaded = Env::new().with_prefix("PINNED__").load(source).unwrap();
let content = String::from_utf8_lossy(&loaded[0].content);
assert!(content.contains(r#"X="yes""#));
}
}