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.set(env).map_err(|_| EnvError::Dotenv("already initialised".into()))?;
194        Ok(())
195    }
196
197    /// Provisions the singleton from an explicit map (for tests). No-ops when already provisioned.
198    #[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    /// Returns the provisioned environment.
206    ///
207    /// Panics when [`AppEnvironment::with_env_file`] has not run yet.
208    pub fn get() -> &'static Self {
209        INSTANCE.get().expect("AppEnvironment has not been provisioned yet — call AppEnvironment::with_env_file first")
210    }
211
212    /// Returns the provisioned environment, or `None` when not yet provisioned.
213    pub fn try_get() -> Option<&'static Self> {
214        INSTANCE.get()
215    }
216
217    /// Reports whether the environment kind is production.
218    pub fn is_production(&self) -> bool {
219        self.kind == EnvironmentKind::Production
220    }
221
222    /// Reports whether SQL statement logging is enabled (`LOG_SQL`).
223    pub fn should_log_sql(&self) -> bool {
224        self.log_sql
225    }
226
227    /// Looks up `key` in the loaded map, falling back to the process environment. Returns `None` when absent in both.
228    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    /// Looks up `key` as in [`AppEnvironment::get_value`]. Returns [`EnvError::MissingKeys`] when absent in both.
236    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    // ── internal ──────────────────────────────────────────────────────────
241
242    fn load(path: Option<&str>) -> Result<Self, EnvError> {
243        let mut config_map: HashMap<String, String> = HashMap::new();
244
245        // Try dotenv
246        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        // dotenvy populates std::env; also collect file entries if available
254        // We collect by reading the file manually for config_map fidelity
255        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        // Also merge any env vars already in process (dotenvy already did, but ensure coverage)
284        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}