use once_cell::sync::OnceCell;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvironmentKind {
Development,
Debug,
Staging,
Production,
}
impl std::str::FromStr for EnvironmentKind {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"DEVELOPMENT" => Ok(Self::Development),
"DEBUG" => Ok(Self::Debug),
"STAGING" => Ok(Self::Staging),
"PRODUCTION" => Ok(Self::Production),
other => Err(format!("invalid ENVIRONMENT_KIND: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessRole {
Worker,
Consumer,
ApiServer,
Stream,
}
impl std::str::FromStr for ProcessRole {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"WORKER" => Ok(Self::Worker),
"CONSUMER" => Ok(Self::Consumer),
"API_SERVER" => Ok(Self::ApiServer),
"STREAM" => Ok(Self::Stream),
other => Err(format!("invalid PROCESS_ROLE: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupMode {
Production,
Partial,
Local,
}
impl std::str::FromStr for SetupMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_uppercase().as_str() {
"PRODUCTION" => Ok(Self::Production),
"PARTIAL" => Ok(Self::Partial),
"LOCAL" => Ok(Self::Local),
other => Err(format!("invalid SETUP_MODE: {other}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatabaseSslMode {
Disable,
Require,
VerifyCa,
VerifyFull,
}
#[derive(Debug, Clone)]
pub struct AppEnvironment {
pub name: String,
pub version_code: String,
pub build_number: i32,
pub log_sql: bool,
pub server_count: usize,
pub worker_count: usize,
pub socket_count: usize,
pub pg_url: String,
pub redis_url: String,
pub rabbitmq_url: String,
pub rabbitmq_vhost: String,
pub mongodb_url: String,
pub es_url: String,
pub es_api_key: String,
pub server_port: u16,
pub socket_port: u16,
pub prefetch_count: usize,
pub url: String,
pub setup_mode: SetupMode,
pub kind: EnvironmentKind,
pub process_role: ProcessRole,
config_map: HashMap<String, String>,
}
#[derive(Debug, Error)]
pub enum EnvError {
#[error("missing required environment variables: {0}")]
MissingKeys(String),
#[error("invalid value for {key}: {msg}")]
InvalidValue {
key: String,
msg: String,
},
#[error("dotenv error: {0}")]
Dotenv(String),
}
static INSTANCE: OnceCell<AppEnvironment> = OnceCell::new();
impl AppEnvironment {
pub fn with_env_file(path: Option<&str>) -> Result<(), EnvError> {
if INSTANCE.get().is_some() {
return Ok(());
}
let env = Self::load(path)?;
INSTANCE.set(env).map_err(|_| EnvError::Dotenv("already initialised".into()))?;
Ok(())
}
#[cfg(test)]
pub fn init_for_test(map: HashMap<String, String>) -> Result<(), EnvError> {
let env = Self::from_map(map)?;
let _ = INSTANCE.set(env);
Ok(())
}
pub fn get() -> &'static Self {
INSTANCE.get().expect("AppEnvironment has not been provisioned yet — call AppEnvironment::with_env_file first")
}
pub fn try_get() -> Option<&'static Self> {
INSTANCE.get()
}
pub fn is_production(&self) -> bool {
self.kind == EnvironmentKind::Production
}
pub fn should_log_sql(&self) -> bool {
self.log_sql
}
pub fn get_value(&self, key: &str) -> Option<String> {
if let Some(v) = self.config_map.get(key) {
return Some(v.clone());
}
std::env::var(key).ok()
}
pub fn get_required(&self, key: &str) -> Result<String, EnvError> {
self.get_value(key).ok_or_else(|| EnvError::MissingKeys(key.to_string()))
}
fn load(path: Option<&str>) -> Result<Self, EnvError> {
let mut config_map: HashMap<String, String> = HashMap::new();
let dotenv_result = if let Some(p) = path {
dotenvy::from_filename(p).ok()
} else {
dotenvy::dotenv().ok()
};
let _ = dotenv_result;
if let Some(p) = path {
if let Ok(content) = std::fs::read_to_string(p) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
let k = k.trim().to_string();
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
config_map.insert(k, v);
}
}
}
} else if let Ok(content) = std::fs::read_to_string(".env") {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
let k = k.trim().to_string();
let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
config_map.insert(k, v);
}
}
}
for (k, v) in std::env::vars() {
config_map.entry(k).or_insert(v);
}
Self::from_map(config_map)
}
fn from_map(map: HashMap<String, String>) -> Result<Self, EnvError> {
let get_req = |key: &str| -> Result<String, EnvError> {
map.get(key)
.cloned()
.or_else(|| std::env::var(key).ok())
.ok_or_else(|| EnvError::MissingKeys(key.to_string()))
};
let get_or = |key: &str, default: &str| -> String {
map.get(key)
.cloned()
.or_else(|| std::env::var(key).ok())
.unwrap_or_else(|| default.to_string())
};
let mut missing = Vec::new();
let required_keys = [
"SERVICE_NAME",
"SERVICE_VERSION",
"BUILD_NUMBER",
"ENVIRONMENT_KIND",
"SERVER_PORT",
"SERVICE_URL",
"POSTGRESQL_URL",
"REDIS_URL",
"RABBITMQ_URL",
"MONGODB_URL",
];
for k in required_keys {
if map.get(k).is_none() && std::env::var(k).is_err() {
missing.push(k.to_string());
}
}
if !missing.is_empty() {
return Err(EnvError::MissingKeys(missing.join(", ")));
}
let name = get_req("SERVICE_NAME")?;
let version_code = get_req("SERVICE_VERSION")?;
let build_number: i32 = get_req("BUILD_NUMBER")?
.parse()
.map_err(|_| EnvError::InvalidValue { key: "BUILD_NUMBER".into(), msg: "not an integer".into() })?;
let server_port: u16 = get_req("SERVER_PORT")?
.parse()
.map_err(|_| EnvError::InvalidValue { key: "SERVER_PORT".into(), msg: "not a port".into() })?;
let url = get_req("SERVICE_URL")?;
let pg_url = get_req("POSTGRESQL_URL")?;
let redis_url = get_req("REDIS_URL")?;
let rabbitmq_url = get_req("RABBITMQ_URL")?;
let mongodb_url = get_req("MONGODB_URL")?;
let kind: EnvironmentKind = get_req("ENVIRONMENT_KIND")?
.parse()
.map_err(|e| EnvError::InvalidValue { key: "ENVIRONMENT_KIND".into(), msg: e })?;
let process_role: ProcessRole = get_or("PROCESS_ROLE", "WORKER")
.parse()
.map_err(|e| EnvError::InvalidValue { key: "PROCESS_ROLE".into(), msg: e })?;
let setup_mode: SetupMode = get_or("SETUP_MODE", "PRODUCTION")
.parse()
.map_err(|e| EnvError::InvalidValue { key: "SETUP_MODE".into(), msg: e })?;
let log_sql: bool = get_or("LOG_SQL", "false").parse().unwrap_or(false);
let server_count: usize = get_or("SERVER_COUNT", "1").parse().unwrap_or(1);
let worker_count: usize = get_or("WORKER_COUNT", "0").parse().unwrap_or(0);
let socket_count: usize = get_or("SOCKET_COUNT", "0").parse().unwrap_or(0);
let socket_port: u16 = get_or("SOCKET_PORT", "8081").parse().unwrap_or(8081);
let prefetch_count: usize = get_or("PREFETCH_COUNT", "1").parse().unwrap_or(1);
let es_url = get_or("ELASTICSEARCH_URL", "<none>");
let es_api_key = get_or("ELASTICSEARCH_API_KEY", "<none>");
let rabbitmq_vhost = get_or("RABBITMQ_VHOST", "/");
Ok(Self {
name,
version_code,
build_number,
log_sql,
server_count,
worker_count,
socket_count,
pg_url,
redis_url,
rabbitmq_url,
rabbitmq_vhost,
mongodb_url,
es_url,
es_api_key,
server_port,
socket_port,
prefetch_count,
url,
setup_mode,
kind,
process_role,
config_map: map,
})
}
}