1use once_cell::sync::OnceCell;
17use std::collections::HashMap;
18use thiserror::Error;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EnvironmentKind {
23 Development,
25 Debug,
27 Staging,
29 Production,
31}
32
33impl std::str::FromStr for EnvironmentKind {
34 type Err = String;
35 fn from_str(s: &str) -> Result<Self, Self::Err> {
36 match s.to_uppercase().as_str() {
37 "DEVELOPMENT" => Ok(Self::Development),
38 "DEBUG" => Ok(Self::Debug),
39 "STAGING" => Ok(Self::Staging),
40 "PRODUCTION" => Ok(Self::Production),
41 other => Err(format!("invalid ENVIRONMENT_KIND: {other}")),
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ProcessRole {
49 Worker,
51 Consumer,
53 ApiServer,
55 Stream,
57}
58
59impl std::str::FromStr for ProcessRole {
60 type Err = String;
61 fn from_str(s: &str) -> Result<Self, Self::Err> {
62 match s.to_uppercase().as_str() {
63 "WORKER" => Ok(Self::Worker),
64 "CONSUMER" => Ok(Self::Consumer),
65 "API_SERVER" => Ok(Self::ApiServer),
66 "STREAM" => Ok(Self::Stream),
67 other => Err(format!("invalid PROCESS_ROLE: {other}")),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SetupMode {
75 Production,
77 Partial,
79 Local,
81}
82
83impl std::str::FromStr for SetupMode {
84 type Err = String;
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
86 match s.to_uppercase().as_str() {
87 "PRODUCTION" => Ok(Self::Production),
88 "PARTIAL" => Ok(Self::Partial),
89 "LOCAL" => Ok(Self::Local),
90 other => Err(format!("invalid SETUP_MODE: {other}")),
91 }
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum DatabaseSslMode {
98 Disable,
100 Require,
102 VerifyCa,
104 VerifyFull,
106}
107
108#[derive(Debug, Clone)]
115pub struct AppEnvironment {
116 pub name: String,
118 pub version_code: String,
120 pub build_number: i32,
122 pub log_sql: bool,
124 pub server_count: usize,
126 pub worker_count: usize,
128 pub socket_count: usize,
130 pub pg_url: String,
132 pub redis_url: String,
134 pub rabbitmq_url: String,
136 pub rabbitmq_vhost: String,
138 pub mongodb_url: String,
140 pub es_url: String,
142 pub es_api_key: String,
144 pub server_port: u16,
146 pub socket_port: u16,
148 pub prefetch_count: usize,
150 pub url: String,
152 pub setup_mode: SetupMode,
154 pub kind: EnvironmentKind,
156 pub process_role: ProcessRole,
158 config_map: HashMap<String, String>,
159}
160
161#[derive(Debug, Error)]
163pub enum EnvError {
164 #[error("missing required environment variables: {0}")]
166 MissingKeys(String),
167 #[error("invalid value for {key}: {msg}")]
169 InvalidValue {
170 key: String,
172 msg: String,
174 },
175 #[error("dotenv error: {0}")]
177 Dotenv(String),
178}
179
180static INSTANCE: OnceCell<AppEnvironment> = OnceCell::new();
181
182impl AppEnvironment {
183 pub fn with_env_file(path: Option<&str>) -> Result<(), EnvError> {
189 if INSTANCE.get().is_some() {
190 return Ok(());
191 }
192 let env = Self::load(path)?;
193 INSTANCE.set(env).map_err(|_| EnvError::Dotenv("already initialised".into()))?;
194 Ok(())
195 }
196
197 #[cfg(test)]
199 pub fn init_for_test(map: HashMap<String, String>) -> Result<(), EnvError> {
200 let env = Self::from_map(map)?;
201 let _ = INSTANCE.set(env);
202 Ok(())
203 }
204
205 pub fn get() -> &'static Self {
209 INSTANCE.get().expect("AppEnvironment has not been provisioned yet — call AppEnvironment::with_env_file first")
210 }
211
212 pub fn try_get() -> Option<&'static Self> {
214 INSTANCE.get()
215 }
216
217 pub fn is_production(&self) -> bool {
219 self.kind == EnvironmentKind::Production
220 }
221
222 pub fn should_log_sql(&self) -> bool {
224 self.log_sql
225 }
226
227 pub fn get_value(&self, key: &str) -> Option<String> {
229 if let Some(v) = self.config_map.get(key) {
230 return Some(v.clone());
231 }
232 std::env::var(key).ok()
233 }
234
235 pub fn get_required(&self, key: &str) -> Result<String, EnvError> {
237 self.get_value(key).ok_or_else(|| EnvError::MissingKeys(key.to_string()))
238 }
239
240 fn load(path: Option<&str>) -> Result<Self, EnvError> {
243 let mut config_map: HashMap<String, String> = HashMap::new();
244
245 let dotenv_result = if let Some(p) = path {
247 dotenvy::from_filename(p).ok()
248 } else {
249 dotenvy::dotenv().ok()
250 };
251 let _ = dotenv_result;
252
253 if let Some(p) = path {
256 if let Ok(content) = std::fs::read_to_string(p) {
257 for line in content.lines() {
258 let line = line.trim();
259 if line.is_empty() || line.starts_with('#') {
260 continue;
261 }
262 if let Some((k, v)) = line.split_once('=') {
263 let k = k.trim().to_string();
264 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
265 config_map.insert(k, v);
266 }
267 }
268 }
269 } else if let Ok(content) = std::fs::read_to_string(".env") {
270 for line in content.lines() {
271 let line = line.trim();
272 if line.is_empty() || line.starts_with('#') {
273 continue;
274 }
275 if let Some((k, v)) = line.split_once('=') {
276 let k = k.trim().to_string();
277 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
278 config_map.insert(k, v);
279 }
280 }
281 }
282
283 for (k, v) in std::env::vars() {
285 config_map.entry(k).or_insert(v);
286 }
287
288 Self::from_map(config_map)
289 }
290
291 fn from_map(map: HashMap<String, String>) -> Result<Self, EnvError> {
292 let get_req = |key: &str| -> Result<String, EnvError> {
293 map.get(key)
294 .cloned()
295 .or_else(|| std::env::var(key).ok())
296 .ok_or_else(|| EnvError::MissingKeys(key.to_string()))
297 };
298 let get_or = |key: &str, default: &str| -> String {
299 map.get(key)
300 .cloned()
301 .or_else(|| std::env::var(key).ok())
302 .unwrap_or_else(|| default.to_string())
303 };
304
305 let mut missing = Vec::new();
306 let required_keys = [
307 "SERVICE_NAME",
308 "SERVICE_VERSION",
309 "BUILD_NUMBER",
310 "ENVIRONMENT_KIND",
311 "SERVER_PORT",
312 "SERVICE_URL",
313 "POSTGRESQL_URL",
314 "REDIS_URL",
315 "RABBITMQ_URL",
316 "MONGODB_URL",
317 ];
318 for k in required_keys {
319 if map.get(k).is_none() && std::env::var(k).is_err() {
320 missing.push(k.to_string());
321 }
322 }
323 if !missing.is_empty() {
324 return Err(EnvError::MissingKeys(missing.join(", ")));
325 }
326
327 let name = get_req("SERVICE_NAME")?;
328 let version_code = get_req("SERVICE_VERSION")?;
329 let build_number: i32 = get_req("BUILD_NUMBER")?
330 .parse()
331 .map_err(|_| EnvError::InvalidValue { key: "BUILD_NUMBER".into(), msg: "not an integer".into() })?;
332 let server_port: u16 = get_req("SERVER_PORT")?
333 .parse()
334 .map_err(|_| EnvError::InvalidValue { key: "SERVER_PORT".into(), msg: "not a port".into() })?;
335 let url = get_req("SERVICE_URL")?;
336 let pg_url = get_req("POSTGRESQL_URL")?;
337 let redis_url = get_req("REDIS_URL")?;
338 let rabbitmq_url = get_req("RABBITMQ_URL")?;
339 let mongodb_url = get_req("MONGODB_URL")?;
340
341 let kind: EnvironmentKind = get_req("ENVIRONMENT_KIND")?
342 .parse()
343 .map_err(|e| EnvError::InvalidValue { key: "ENVIRONMENT_KIND".into(), msg: e })?;
344 let process_role: ProcessRole = get_or("PROCESS_ROLE", "WORKER")
345 .parse()
346 .map_err(|e| EnvError::InvalidValue { key: "PROCESS_ROLE".into(), msg: e })?;
347 let setup_mode: SetupMode = get_or("SETUP_MODE", "PRODUCTION")
348 .parse()
349 .map_err(|e| EnvError::InvalidValue { key: "SETUP_MODE".into(), msg: e })?;
350
351 let log_sql: bool = get_or("LOG_SQL", "false").parse().unwrap_or(false);
352 let server_count: usize = get_or("SERVER_COUNT", "1").parse().unwrap_or(1);
353 let worker_count: usize = get_or("WORKER_COUNT", "0").parse().unwrap_or(0);
354 let socket_count: usize = get_or("SOCKET_COUNT", "0").parse().unwrap_or(0);
355 let socket_port: u16 = get_or("SOCKET_PORT", "8081").parse().unwrap_or(8081);
356 let prefetch_count: usize = get_or("PREFETCH_COUNT", "1").parse().unwrap_or(1);
357 let es_url = get_or("ELASTICSEARCH_URL", "<none>");
358 let es_api_key = get_or("ELASTICSEARCH_API_KEY", "<none>");
359 let rabbitmq_vhost = get_or("RABBITMQ_VHOST", "/");
360
361 Ok(Self {
362 name,
363 version_code,
364 build_number,
365 log_sql,
366 server_count,
367 worker_count,
368 socket_count,
369 pg_url,
370 redis_url,
371 rabbitmq_url,
372 rabbitmq_vhost,
373 mongodb_url,
374 es_url,
375 es_api_key,
376 server_port,
377 socket_port,
378 prefetch_count,
379 url,
380 setup_mode,
381 kind,
382 process_role,
383 config_map: map,
384 })
385 }
386}