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
194 .set(env)
195 .map_err(|_| EnvError::Dotenv("already initialised".into()))?;
196 Ok(())
197 }
198
199 #[cfg(test)]
201 pub fn init_for_test(map: HashMap<String, String>) -> Result<(), EnvError> {
202 let env = Self::from_map(map)?;
203 let _ = INSTANCE.set(env);
204 Ok(())
205 }
206
207 pub fn get() -> &'static Self {
211 INSTANCE.get().expect("AppEnvironment has not been provisioned yet — call AppEnvironment::with_env_file first")
212 }
213
214 pub fn try_get() -> Option<&'static Self> {
216 INSTANCE.get()
217 }
218
219 pub fn is_production(&self) -> bool {
221 self.kind == EnvironmentKind::Production
222 }
223
224 pub fn should_log_sql(&self) -> bool {
226 self.log_sql
227 }
228
229 pub fn get_value(&self, key: &str) -> Option<String> {
231 if let Some(v) = std::env::var(key).ok() {
232 return Some(v.clone());
233 }
234
235 if let Some(v) = self.config_map.get(key) {
236 return Some(v.clone());
237 }
238
239 None
240 }
241
242 pub fn get_required(&self, key: &str) -> Result<String, EnvError> {
244 self.get_value(key)
245 .ok_or_else(|| EnvError::MissingKeys(key.to_string()))
246 }
247
248 fn load(path: Option<&str>) -> Result<Self, EnvError> {
251 let mut config_map: HashMap<String, String> = HashMap::new();
252
253 let dotenv_result = if let Some(p) = path {
255 dotenvy::from_filename(p).ok()
256 } else {
257 dotenvy::dotenv().ok()
258 };
259 let _ = dotenv_result;
260
261 if let Some(p) = path {
264 if let Ok(content) = std::fs::read_to_string(p) {
265 for line in content.lines() {
266 let line = line.trim();
267 if line.is_empty() || line.starts_with('#') {
268 continue;
269 }
270 if let Some((k, v)) = line.split_once('=') {
271 let k = k.trim().to_string();
272 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
273 config_map.insert(k, v);
274 }
275 }
276 }
277 } else if let Ok(content) = std::fs::read_to_string(".env") {
278 for line in content.lines() {
279 let line = line.trim();
280 if line.is_empty() || line.starts_with('#') {
281 continue;
282 }
283 if let Some((k, v)) = line.split_once('=') {
284 let k = k.trim().to_string();
285 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
286 config_map.insert(k, v);
287 }
288 }
289 }
290
291 for (k, v) in std::env::vars() {
293 config_map.entry(k).or_insert(v);
294 }
295
296 Self::from_map(config_map)
297 }
298
299 fn from_map(map: HashMap<String, String>) -> Result<Self, EnvError> {
300 let get_req = |key: &str| -> Result<String, EnvError> {
303 std::env::var(key)
304 .ok()
305 .or_else(|| map.get(key).cloned())
306 .ok_or_else(|| EnvError::MissingKeys(key.to_string()))
307 };
308 let get_or = |key: &str, default: &str| -> String {
309 std::env::var(key)
310 .ok()
311 .or_else(|| map.get(key).cloned())
312 .unwrap_or_else(|| default.to_string())
313 };
314
315 let name = get_req("SERVICE_NAME")?;
316 let version_code = get_req("SERVICE_VERSION")?;
317 let build_number: i32 =
318 get_req("BUILD_NUMBER")?
319 .parse()
320 .map_err(|_| EnvError::InvalidValue {
321 key: "BUILD_NUMBER".into(),
322 msg: "not an integer".into(),
323 })?;
324 let server_port: u16 =
325 get_req("SERVER_PORT")?
326 .parse()
327 .map_err(|_| EnvError::InvalidValue {
328 key: "SERVER_PORT".into(),
329 msg: "not a port".into(),
330 })?;
331 let url = get_req("SERVICE_URL")?;
332 let pg_url = get_req("POSTGRESQL_URL")?;
333 let redis_url = get_req("REDIS_URL")?;
334 let rabbitmq_url = get_req("RABBITMQ_URL")?;
335 let mongodb_url = get_req("MONGODB_URL")?;
336
337 let kind: EnvironmentKind =
338 get_req("ENVIRONMENT_KIND")?
339 .parse()
340 .map_err(|e| EnvError::InvalidValue {
341 key: "ENVIRONMENT_KIND".into(),
342 msg: e,
343 })?;
344 let process_role: ProcessRole =
345 get_or("PROCESS_ROLE", "WORKER")
346 .parse()
347 .map_err(|e| EnvError::InvalidValue {
348 key: "PROCESS_ROLE".into(),
349 msg: e,
350 })?;
351 let setup_mode: SetupMode =
352 get_or("SETUP_MODE", "PRODUCTION")
353 .parse()
354 .map_err(|e| EnvError::InvalidValue {
355 key: "SETUP_MODE".into(),
356 msg: e,
357 })?;
358
359 let log_sql: bool = get_or("LOG_SQL", "false").parse().unwrap_or(false);
360 let server_count: usize = get_or("SERVER_COUNT", "1").parse().unwrap_or(1);
361 let worker_count: usize = get_or("WORKER_COUNT", "0").parse().unwrap_or(0);
362 let socket_count: usize = get_or("SOCKET_COUNT", "0").parse().unwrap_or(0);
363 let socket_port: u16 = get_or("SOCKET_PORT", "8081").parse().unwrap_or(8081);
364 let prefetch_count: usize = get_or("PREFETCH_COUNT", "1").parse().unwrap_or(1);
365 let es_url = get_or("ELASTICSEARCH_URL", "<none>");
366 let es_api_key = get_or("ELASTICSEARCH_API_KEY", "<none>");
367 let rabbitmq_vhost = get_or("RABBITMQ_VHOST", "/");
368
369 Ok(Self {
370 name,
371 version_code,
372 build_number,
373 log_sql,
374 server_count,
375 worker_count,
376 socket_count,
377 pg_url,
378 redis_url,
379 rabbitmq_url,
380 rabbitmq_vhost,
381 mongodb_url,
382 es_url,
383 es_api_key,
384 server_port,
385 socket_port,
386 prefetch_count,
387 url,
388 setup_mode,
389 kind,
390 process_role,
391 config_map: map,
392 })
393 }
394}