#![allow(clippy::must_use_candidate)]
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::warn;
static DEPRECATION_WARNED: AtomicBool = AtomicBool::new(false);
#[derive(Debug, Clone)]
pub struct EnvVar {
pub standard: String,
pub legacy: Vec<String>,
pub description: Option<String>,
}
impl EnvVar {
#[must_use]
pub fn new(standard: &str) -> Self {
Self {
standard: standard.to_string(),
legacy: Vec::new(),
description: None,
}
}
#[must_use]
pub fn with_legacy(mut self, name: &str) -> Self {
self.legacy.push(name.to_string());
self
}
#[must_use]
pub fn with_legacy_names(mut self, names: &[&str]) -> Self {
for name in names {
self.legacy.push((*name).to_string());
}
self
}
#[must_use]
pub fn with_description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
#[must_use]
pub fn get(&self) -> Option<String> {
if let Ok(value) = std::env::var(&self.standard) {
return Some(value);
}
for legacy_name in &self.legacy {
if let Ok(value) = std::env::var(legacy_name) {
log_deprecation_warning(legacy_name, &self.standard);
return Some(value);
}
}
None
}
#[must_use]
pub fn get_or(&self, default: &str) -> String {
self.get().unwrap_or_else(|| default.to_string())
}
pub fn get_parsed<T: std::str::FromStr>(&self) -> Option<T> {
self.get().and_then(|v| v.parse().ok())
}
#[must_use]
pub fn get_bool(&self) -> Option<bool> {
self.get().map(|v| {
let v = v.to_lowercase();
v == "true" || v == "1" || v == "yes" || v == "on"
})
}
#[must_use]
pub fn get_list(&self) -> Option<Vec<String>> {
self.get()
.map(|v| v.split(',').map(|s| s.trim().to_string()).collect())
}
#[must_use]
pub fn which_name_used(&self) -> Option<&str> {
if std::env::var(&self.standard).is_ok() {
return Some(&self.standard);
}
self.legacy
.iter()
.find(|name| std::env::var(name).is_ok())
.map(String::as_str)
}
}
fn log_deprecation_warning(legacy_name: &str, standard_name: &str) {
let already_warned = DEPRECATION_WARNED.swap(true, Ordering::Relaxed);
if already_warned {
tracing::debug!(
legacy = %legacy_name,
standard = %standard_name,
"Deprecated environment variable used"
);
} else {
warn!(
legacy = %legacy_name,
standard = %standard_name,
"Using deprecated environment variable. Please migrate to the standard name."
);
}
}
#[cfg(test)]
pub fn reset_deprecation_warnings() {
DEPRECATION_WARNED.store(false, Ordering::Relaxed);
}
pub mod postgres {
use super::EnvVar;
pub fn host() -> EnvVar {
EnvVar::new("PGHOST")
.with_legacy_names(&["POSTGRESQL_HOST", "PG_HOST", "POSTGRES_HOST"])
.with_description("PostgreSQL server hostname")
}
pub fn port() -> EnvVar {
EnvVar::new("PGPORT")
.with_legacy_names(&["POSTGRESQL_PORT", "PG_PORT", "POSTGRES_PORT"])
.with_description("PostgreSQL server port")
}
pub fn user() -> EnvVar {
EnvVar::new("PGUSER")
.with_legacy_names(&["POSTGRESQL_USER", "PG_USER", "POSTGRES_USER"])
.with_description("PostgreSQL username")
}
pub fn password() -> EnvVar {
EnvVar::new("PGPASSWORD")
.with_legacy_names(&["POSTGRESQL_PASSWORD", "PG_PASSWORD", "POSTGRES_PASSWORD"])
.with_description("PostgreSQL password")
}
pub fn database() -> EnvVar {
EnvVar::new("PGDATABASE")
.with_legacy_names(&[
"POSTGRESQL_DATABASE",
"PG_DATABASE",
"POSTGRES_DATABASE",
"POSTGRES_DB",
])
.with_description("PostgreSQL database name")
}
pub fn sslmode() -> EnvVar {
EnvVar::new("PGSSLMODE")
.with_legacy_names(&["POSTGRESQL_SSLMODE", "PG_SSLMODE"])
.with_description("PostgreSQL SSL mode")
}
}
pub mod kafka {
use super::EnvVar;
fn kafka_var(name: &str, legacy: &[&str]) -> EnvVar {
let standard = format!("KAFKA_{name}");
let mut var = EnvVar::new(&standard);
for l in legacy {
var = var.with_legacy(l);
}
var
}
pub fn bootstrap_servers() -> EnvVar {
kafka_var("BOOTSTRAP_SERVERS", &["KAFKA_BROKERS"])
.with_description("Kafka broker addresses (comma-separated)")
}
pub fn security_protocol() -> EnvVar {
kafka_var("SECURITY_PROTOCOL", &[])
.with_description("Security protocol (PLAINTEXT, SSL, SASL_PLAINTEXT, SASL_SSL)")
}
pub fn sasl_mechanism() -> EnvVar {
kafka_var("SASL_MECHANISM", &[])
.with_description("SASL mechanism (PLAIN, SCRAM-SHA-256, SCRAM-SHA-512)")
}
pub fn sasl_username() -> EnvVar {
kafka_var("SASL_USERNAME", &["KAFKA_SASL_USER"]).with_description("SASL username")
}
pub fn sasl_password() -> EnvVar {
kafka_var("SASL_PASSWORD", &[]).with_description("SASL password")
}
pub fn group_id() -> EnvVar {
kafka_var("GROUP_ID", &["KAFKA_GROUP", "KAFKA_CONSUMER_GROUP"])
.with_description("Consumer group ID")
}
pub fn client_id() -> EnvVar {
kafka_var("CLIENT_ID", &[]).with_description("Client ID for broker logs")
}
pub fn topics() -> EnvVar {
kafka_var("TOPICS", &["KAFKA_TOPIC"])
.with_description("Topics to subscribe to (comma-separated)")
}
pub fn ssl_ca_location() -> EnvVar {
kafka_var("SSL_CA_LOCATION", &["KAFKA_CA_CERT", "KAFKA_SSL_CA"])
.with_description("Path to SSL CA certificate")
}
pub fn ssl_skip_verify() -> EnvVar {
kafka_var("SSL_SKIP_VERIFY", &["KAFKA_SSL_INSECURE", "KAFKA_INSECURE"])
.with_description("Skip SSL certificate verification")
}
pub fn profile() -> EnvVar {
kafka_var("PROFILE", &[]).with_description("Kafka profile (production, devtest)")
}
pub fn with_prefix(prefix: &str, name: &str) -> EnvVar {
EnvVar::new(&format!("{prefix}_KAFKA_{name}")).with_legacy(&format!("{prefix}_{name}"))
}
}
pub mod vault {
use super::EnvVar;
pub fn addr() -> EnvVar {
EnvVar::new("VAULT_ADDR")
.with_legacy_names(&["OPENBAO_ADDR", "BAO_ADDR"])
.with_description("Vault/OpenBao server address")
}
pub fn token() -> EnvVar {
EnvVar::new("VAULT_TOKEN")
.with_legacy_names(&["OPENBAO_TOKEN", "BAO_TOKEN", "OPENBAO_ROOT_TOKEN"])
.with_description("Vault/OpenBao authentication token")
}
pub fn namespace() -> EnvVar {
EnvVar::new("VAULT_NAMESPACE")
.with_legacy_names(&["OPENBAO_NAMESPACE", "BAO_NAMESPACE"])
.with_description("Vault namespace (Enterprise)")
}
pub fn skip_verify() -> EnvVar {
EnvVar::new("VAULT_SKIP_VERIFY")
.with_legacy_names(&[
"OPENBAO_SKIP_VERIFY",
"BAO_SKIP_VERIFY",
"VAULT_TLS_SKIP_VERIFY",
])
.with_description("Skip TLS certificate verification")
}
pub fn ca_cert() -> EnvVar {
EnvVar::new("VAULT_CACERT")
.with_legacy_names(&["OPENBAO_CACERT", "BAO_CACERT", "VAULT_CA_CERT"])
.with_description("Path to CA certificate for Vault TLS")
}
pub fn approle_role_id() -> EnvVar {
EnvVar::new("VAULT_ROLE_ID")
.with_legacy_names(&["OPENBAO_ROLE_ID", "BAO_ROLE_ID"])
.with_description("AppRole role ID")
}
pub fn approle_secret_id() -> EnvVar {
EnvVar::new("VAULT_SECRET_ID")
.with_legacy_names(&["OPENBAO_SECRET_ID", "BAO_SECRET_ID"])
.with_description("AppRole secret ID")
}
pub fn k8s_role() -> EnvVar {
EnvVar::new("VAULT_K8S_ROLE")
.with_legacy_names(&["OPENBAO_K8S_ROLE", "BAO_K8S_ROLE"])
.with_description("Kubernetes auth role name")
}
}
pub mod aws {
use super::EnvVar;
pub fn access_key_id() -> EnvVar {
EnvVar::new("AWS_ACCESS_KEY_ID")
.with_legacy_names(&["AWS_ACCESS_KEY"])
.with_description("AWS access key ID")
}
pub fn secret_access_key() -> EnvVar {
EnvVar::new("AWS_SECRET_ACCESS_KEY")
.with_legacy_names(&["AWS_SECRET_KEY"])
.with_description("AWS secret access key")
}
pub fn session_token() -> EnvVar {
EnvVar::new("AWS_SESSION_TOKEN")
.with_legacy_names(&["AWS_SECURITY_TOKEN"])
.with_description("AWS session token (for temporary credentials)")
}
pub fn region() -> EnvVar {
EnvVar::new("AWS_DEFAULT_REGION")
.with_legacy_names(&["AWS_REGION"])
.with_description("AWS region")
}
pub fn endpoint_url() -> EnvVar {
EnvVar::new("AWS_ENDPOINT_URL")
.with_legacy_names(&["AWS_ENDPOINT", "LOCALSTACK_ENDPOINT"])
.with_description("Custom AWS endpoint URL")
}
}
pub mod clickhouse {
use super::EnvVar;
pub fn host() -> EnvVar {
EnvVar::new("CLICKHOUSE_HOST")
.with_legacy_names(&["CH_HOST"])
.with_description("ClickHouse server hostname")
}
pub fn native_port() -> EnvVar {
EnvVar::new("CLICKHOUSE_NATIVE_PORT")
.with_legacy_names(&["CLICKHOUSE_PORT", "CH_PORT"])
.with_description("ClickHouse native protocol port (default: 9000)")
}
pub fn http_port() -> EnvVar {
EnvVar::new("CLICKHOUSE_HTTP_PORT")
.with_legacy_names(&["CH_HTTP_PORT"])
.with_description("ClickHouse HTTP port (default: 8123)")
}
pub fn user() -> EnvVar {
EnvVar::new("CLICKHOUSE_USER")
.with_legacy_names(&["CH_USER", "CLICKHOUSE_USERNAME"])
.with_description("ClickHouse username")
}
pub fn password() -> EnvVar {
EnvVar::new("CLICKHOUSE_PASSWORD")
.with_legacy_names(&["CH_PASSWORD"])
.with_description("ClickHouse password")
}
pub fn database() -> EnvVar {
EnvVar::new("CLICKHOUSE_DATABASE")
.with_legacy_names(&["CH_DATABASE", "CLICKHOUSE_DB"])
.with_description("ClickHouse database name")
}
}
#[must_use]
pub fn load_all_standard() -> HashMap<String, Option<String>> {
let mut vars = HashMap::new();
vars.insert("pg.host".into(), postgres::host().get());
vars.insert("pg.port".into(), postgres::port().get());
vars.insert("pg.user".into(), postgres::user().get());
vars.insert("pg.database".into(), postgres::database().get());
vars.insert(
"kafka.bootstrap_servers".into(),
kafka::bootstrap_servers().get(),
);
vars.insert(
"kafka.security_protocol".into(),
kafka::security_protocol().get(),
);
vars.insert("kafka.sasl_mechanism".into(), kafka::sasl_mechanism().get());
vars.insert("kafka.sasl_username".into(), kafka::sasl_username().get());
vars.insert("vault.addr".into(), vault::addr().get());
vars.insert("vault.namespace".into(), vault::namespace().get());
vars.insert("aws.region".into(), aws::region().get());
vars.insert("clickhouse.host".into(), clickhouse::host().get());
vars.insert("clickhouse.database".into(), clickhouse::database().get());
vars
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn setup() {
reset_deprecation_warnings();
}
#[test]
fn test_env_var_standard_name() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("TEST_STANDARD_VAR", Some("standard_value"), || {
let var = EnvVar::new("TEST_STANDARD_VAR").with_legacy("TEST_LEGACY_VAR");
assert_eq!(var.get(), Some("standard_value".to_string()));
});
}
#[test]
fn test_env_var_legacy_fallback() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("TEST_LEGACY_VAR2", Some("legacy_value"), || {
let var = EnvVar::new("TEST_STANDARD_VAR2").with_legacy("TEST_LEGACY_VAR2");
assert_eq!(var.get(), Some("legacy_value".to_string()));
});
}
#[test]
fn test_env_var_standard_takes_precedence() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_vars(
[
("TEST_STANDARD_VAR3", Some("standard")),
("TEST_LEGACY_VAR3", Some("legacy")),
],
|| {
let var = EnvVar::new("TEST_STANDARD_VAR3").with_legacy("TEST_LEGACY_VAR3");
assert_eq!(var.get(), Some("standard".to_string()));
},
);
}
#[test]
fn test_env_var_missing() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
let var = EnvVar::new("NONEXISTENT_VAR").with_legacy("ALSO_NONEXISTENT");
assert_eq!(var.get(), None);
}
#[test]
fn test_env_var_get_bool() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_vars(
[
("TEST_BOOL_TRUE", Some("true")),
("TEST_BOOL_ONE", Some("1")),
("TEST_BOOL_YES", Some("YES")),
("TEST_BOOL_FALSE", Some("false")),
],
|| {
assert_eq!(EnvVar::new("TEST_BOOL_TRUE").get_bool(), Some(true));
assert_eq!(EnvVar::new("TEST_BOOL_ONE").get_bool(), Some(true));
assert_eq!(EnvVar::new("TEST_BOOL_YES").get_bool(), Some(true));
assert_eq!(EnvVar::new("TEST_BOOL_FALSE").get_bool(), Some(false));
},
);
}
#[test]
fn test_env_var_get_list() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("TEST_LIST", Some("a, b, c"), || {
let var = EnvVar::new("TEST_LIST");
assert_eq!(
var.get_list(),
Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
);
});
}
#[test]
fn test_postgres_env_vars() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("PGHOST", Some("localhost"), || {
assert_eq!(postgres::host().get(), Some("localhost".to_string()));
});
}
#[test]
fn test_postgres_legacy_fallback() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_vars(
[
("PGHOST", None::<&str>),
("POSTGRESQL_HOST", Some("legacy-host")),
],
|| assert_eq!(postgres::host().get(), Some("legacy-host".to_string())),
);
}
#[test]
fn test_kafka_env_vars() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("KAFKA_BOOTSTRAP_SERVERS", Some("kafka:9092"), || {
assert_eq!(
kafka::bootstrap_servers().get(),
Some("kafka:9092".to_string())
);
});
}
#[test]
fn test_vault_env_vars() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("VAULT_ADDR", Some("https://vault:8200"), || {
assert_eq!(vault::addr().get(), Some("https://vault:8200".to_string()));
});
}
#[test]
fn test_vault_openbao_fallback() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_vars(
[
("VAULT_ADDR", None::<&str>),
("OPENBAO_ADDR", Some("https://openbao:8200")),
],
|| {
assert_eq!(
vault::addr().get(),
Some("https://openbao:8200".to_string())
);
},
);
}
#[test]
fn test_which_name_used() {
let _lock = ENV_LOCK.lock().unwrap();
setup();
temp_env::with_var("TEST_WHICH_LEGACY", Some("value"), || {
let var = EnvVar::new("TEST_WHICH_STANDARD").with_legacy("TEST_WHICH_LEGACY");
assert_eq!(var.which_name_used(), Some("TEST_WHICH_LEGACY"));
});
}
}