Skip to main content

shared_framework/env/
mod.rs

1//! Process-wide service configuration loaded from dotenv files and the environment.
2//!
3//! [`AppEnvironment`] is a singleton (backed by a `OnceCell`): provision it once
4//! with [`AppEnvironment::with_env_file`], then read it with
5//! [`AppEnvironment::get`] or [`AppEnvironment::try_get`]. It carries service
6//! identity (`SERVICE_NAME`, `SERVICE_VERSION`, `BUILD_NUMBER`), connection URLs
7//! for Postgres/Redis/RabbitMQ/MongoDB, ports and replica counts, and the
8//! [`EnvironmentKind`], [`ProcessRole`], and [`SetupMode`] selectors.
9//!
10//! ```ignore
11//! AppEnvironment::with_env_file(None)?;
12//! let env = AppEnvironment::get();
13//! let pg_url = env.pg_url.clone();
14//! ```
15
16use once_cell::sync::OnceCell;
17use std::collections::HashMap;
18use thiserror::Error;
19
20/// Deployment environment selector, parsed from `ENVIRONMENT_KIND` (case-insensitive).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EnvironmentKind {
23    /// Local development environment.
24    Development,
25    /// Debug environment.
26    Debug,
27    /// Staging environment.
28    Staging,
29    /// Production environment; enables production-only behaviour such as quieter error output.
30    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/// Role of this process, parsed from `PROCESS_ROLE` (case-insensitive, defaults to `"WORKER"`).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ProcessRole {
49    /// Background worker process.
50    Worker,
51    /// Queue consumer process.
52    Consumer,
53    /// HTTP API server process.
54    ApiServer,
55    /// Streaming process.
56    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/// Setup selector, parsed from `SETUP_MODE` (case-insensitive, defaults to `"PRODUCTION"`).
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SetupMode {
75    /// Full production setup.
76    Production,
77    /// Partial setup.
78    Partial,
79    /// Local setup.
80    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/// PostgreSQL SSL mode selector.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum DatabaseSslMode {
98    /// No SSL.
99    Disable,
100    /// Require SSL without certificate verification.
101    Require,
102    /// Verify the server certificate against a CA.
103    VerifyCa,
104    /// Verify the server certificate and hostname.
105    VerifyFull,
106}
107
108/// Loaded service configuration. Obtain via [`AppEnvironment::get`] after provisioning.
109///
110/// Required keys are `SERVICE_NAME`, `SERVICE_VERSION`, `BUILD_NUMBER`,
111/// `ENVIRONMENT_KIND`, `SERVER_PORT`, `SERVICE_URL`, `POSTGRESQL_URL`,
112/// `REDIS_URL`, `RABBITMQ_URL`, and `MONGODB_URL`; loading fails with
113/// [`EnvError::MissingKeys`] when any are absent.
114#[derive(Debug, Clone)]
115pub struct AppEnvironment {
116    /// Service name from `SERVICE_NAME`.
117    pub name: String,
118    /// Service version from `SERVICE_VERSION`.
119    pub version_code: String,
120    /// Build number from `BUILD_NUMBER`.
121    pub build_number: i32,
122    /// Whether SQL statements are logged, from `LOG_SQL` (defaults to `false`).
123    pub log_sql: bool,
124    /// Number of HTTP server instances, from `SERVER_COUNT` (defaults to `1`).
125    pub server_count: usize,
126    /// Number of worker instances, from `WORKER_COUNT` (defaults to `0`).
127    pub worker_count: usize,
128    /// Number of socket server instances, from `SOCKET_COUNT` (defaults to `0`).
129    pub socket_count: usize,
130    /// Postgres connection URL from `POSTGRESQL_URL`.
131    pub pg_url: String,
132    /// Redis connection URL from `REDIS_URL`.
133    pub redis_url: String,
134    /// RabbitMQ connection URL from `RABBITMQ_URL`.
135    pub rabbitmq_url: String,
136    /// RabbitMQ virtual host from `RABBITMQ_VHOST` (defaults to `"/"`).
137    pub rabbitmq_vhost: String,
138    /// MongoDB connection URL from `MONGODB_URL`.
139    pub mongodb_url: String,
140    /// Elasticsearch URL from `ELASTICSEARCH_URL` (defaults to `"<none>"`).
141    pub es_url: String,
142    /// Elasticsearch API key from `ELASTICSEARCH_API_KEY` (defaults to `"<none>"`).
143    pub es_api_key: String,
144    /// HTTP listen port from `SERVER_PORT`.
145    pub server_port: u16,
146    /// Socket listen port from `SOCKET_PORT` (defaults to `8081`).
147    pub socket_port: u16,
148    /// Queue prefetch count from `PREFETCH_COUNT` (defaults to `1`).
149    pub prefetch_count: usize,
150    /// Public service URL from `SERVICE_URL`.
151    pub url: String,
152    /// Setup selector from `SETUP_MODE` (defaults to production).
153    pub setup_mode: SetupMode,
154    /// Deployment environment selector from `ENVIRONMENT_KIND`.
155    pub kind: EnvironmentKind,
156    /// Process role from `PROCESS_ROLE` (defaults to worker).
157    pub process_role: ProcessRole,
158    config_map: HashMap<String, String>,
159}
160
161/// Errors returned when loading or querying [`AppEnvironment`].
162#[derive(Debug, Error)]
163pub enum EnvError {
164    /// One or more required keys were absent. Carries the comma-joined key list.
165    #[error("missing required environment variables: {0}")]
166    MissingKeys(String),
167    /// A present key failed to parse. Carries the key and the reason.
168    #[error("invalid value for {key}: {msg}")]
169    InvalidValue {
170        /// The offending key.
171        key: String,
172        /// Why its value was rejected.
173        msg: String,
174    },
175    /// Dotenv loading or singleton provisioning failed.
176    #[error("dotenv error: {0}")]
177    Dotenv(String),
178}
179
180static INSTANCE: OnceCell<AppEnvironment> = OnceCell::new();
181
182impl AppEnvironment {
183    /// Provisions the singleton from the given dotenv file, or from `.env` plus the
184    /// process environment when `path` is `None`. Idempotent: no-ops when already provisioned.
185    ///
186    /// Returns [`EnvError::MissingKeys`] when a required key is absent, or
187    /// [`EnvError::InvalidValue`] when a value fails to parse.
188    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    /// Provisions the singleton from an explicit map (for tests). No-ops when already provisioned.
200    #[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    /// Returns the provisioned environment.
208    ///
209    /// Panics when [`AppEnvironment::with_env_file`] has not run yet.
210    pub fn get() -> &'static Self {
211        INSTANCE.get().expect("AppEnvironment has not been provisioned yet — call AppEnvironment::with_env_file first")
212    }
213
214    /// Returns the provisioned environment, or `None` when not yet provisioned.
215    pub fn try_get() -> Option<&'static Self> {
216        INSTANCE.get()
217    }
218
219    /// Reports whether the environment kind is production.
220    pub fn is_production(&self) -> bool {
221        self.kind == EnvironmentKind::Production
222    }
223
224    /// Reports whether SQL statement logging is enabled (`LOG_SQL`).
225    pub fn should_log_sql(&self) -> bool {
226        self.log_sql
227    }
228
229    /// Looks up `key` in the loaded map, falling back to the process environment. Returns `None` when absent in both.
230    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    /// Looks up `key` as in [`AppEnvironment::get_value`]. Returns [`EnvError::MissingKeys`] when absent in both.
243    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    // ── internal ──────────────────────────────────────────────────────────
249
250    fn load(path: Option<&str>) -> Result<Self, EnvError> {
251        let mut config_map: HashMap<String, String> = HashMap::new();
252
253        // Try dotenv
254        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        // dotenvy populates std::env; also collect file entries if available
262        // We collect by reading the file manually for config_map fidelity
263        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        // Also merge any env vars already in process (dotenvy already did, but ensure coverage)
292        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        // Intentionally resolve via environment-first lookup (mirrors `get_value`/`get_required`):
301        // values may come from the config map OR from the process environment (e.g. Docker/K8s injection).
302        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}