zirv_queue/config/mod.rs
1use std::sync::OnceLock;
2
3use dotenvy::dotenv;
4use serde::Deserialize;
5
6/// A single global instance of the `Config` that is set once at runtime.
7///
8/// This static utilizes [`OnceLock`] to ensure it can be initialized only once.
9/// Access it via [`Config::get_config`], which will call [`Config::from_env`]
10/// if the config hasn’t been set yet.
11///
12/// # Example
13///
14/// ```rust
15/// // Typically you would call `Config::init()` early in your program:
16/// // Config::init().expect("Failed to initialize config");
17/// // Then, later you can access the global config:
18/// // let cfg = Config::get_config();
19/// // println!("Database URL: {}", cfg.database_url);
20/// ```
21static GLOBAL_CONFIG: OnceLock<Config> = OnceLock::new();
22
23/// Application-wide configuration loaded from environment variables.
24///
25/// The struct fields correspond to specific environment variables. Some fields
26/// have default values specified by helper functions (e.g. `default_max_concurrent_jobs`).
27///
28/// # Fields
29///
30/// - `database_url`: **Required**. Must be present in the environment as `DATABASE_URL`.
31/// - `max_concurrent_jobs`: Optional. Defaults to `4` if not set.
32/// - `tick_rate_ms`: Optional. Defaults to `1000` if not set.
33/// - `max_retry_attempts`: Optional. Defaults to `3` if not set.
34/// - `retry_interval_ms`: Optional. Defaults to `300_000` (5 minutes) if not set.
35///
36/// # Example
37///
38/// ```rust
39/// // Loading from environment:
40/// // 1. Optionally load .env file
41/// // 2. Read config variables (DATABASE_URL is required)
42///
43/// // let cfg = Config::from_env();
44/// // println!("Loaded config: {:?}", cfg);
45/// ```
46#[derive(Deserialize, Debug)]
47pub struct Config {
48 /// The URL for your database connection. Must be set via `DATABASE_URL`.
49 pub database_url: String,
50
51 /// Maximum number of concurrent jobs the application will handle.
52 /// Defaults to `4` if not present in the environment.
53 #[serde(default = "default_max_concurrent_jobs")]
54 pub max_concurrent_jobs: usize,
55
56 /// Interval in milliseconds at which some recurring task or loop should tick.
57 /// Defaults to `1000` if not set.
58 #[serde(default = "default_tick_rate_ms")]
59 pub tick_rate_ms: u64,
60
61 /// Number of retry attempts for a failed operation. Defaults to `3`.
62 #[serde(default = "default_max_retry_attempts")]
63 pub max_retry_attempts: i32,
64
65 /// Interval in milliseconds between retries. Defaults to `300_000` (5 minutes).
66 #[serde(default = "default_retry_interval_ms")]
67 pub retry_interval_ms: i64,
68}
69
70/// Provides a default value of `4` for `max_concurrent_jobs`.
71fn default_max_concurrent_jobs() -> usize {
72 4
73}
74
75/// Provides a default value of `1000` for `tick_rate_ms`.
76fn default_tick_rate_ms() -> u64 {
77 1000
78}
79
80/// Provides a default value of `3` for `max_retry_attempts`.
81fn default_max_retry_attempts() -> i32 {
82 3
83}
84
85/// Provides a default value of `300_000` (5 minutes) for `retry_interval_ms`.
86fn default_retry_interval_ms() -> i64 {
87 300_000
88}
89
90impl Config {
91 /// Initializes and sets the global [`Config`] once using environment variables.
92 ///
93 /// This function loads a `.env` file (if present) and then constructs a `Config`
94 /// from the environment using [`envy::from_env`].
95 /// It attempts to store the resulting `Config` in the global static `GLOBAL_CONFIG`.
96 ///
97 /// If the global config is already set, an error is returned.
98 ///
99 /// # Errors
100 ///
101 /// Returns an error if:
102 /// - **Any required environment variable** is missing (e.g. `DATABASE_URL`),
103 /// - or if the config has already been set and you tried to set it again.
104 ///
105 /// # Example
106 ///
107 /// ```rust,no_run
108 /// // Typically called early in the program:
109 /// // Config::init().expect("Failed to initialize config");
110 /// ```
111 pub fn init() -> Result<(), Box<dyn std::error::Error>> {
112 dotenv().ok();
113
114 let config = envy::from_env::<Config>()?;
115
116 match GLOBAL_CONFIG.set(config) {
117 Ok(_) => Ok(()),
118 Err(_) => Err("Failed to set global config".into()),
119 }
120 }
121
122 /// Constructs a new [`Config`] from environment variables without setting the global.
123 ///
124 /// This function will:
125 /// 1. Load a `.env` file if present,
126 /// 2. Parse environment variables into a [`Config`].
127 ///
128 /// # Panics
129 ///
130 /// Panics if any required variable (e.g. `DATABASE_URL`) is missing or invalid.
131 ///
132 /// # Example
133 ///
134 /// ```rust,no_run
135 /// # use zirv_queue::Config; // Hidden import so `Config` is recognized in the doc test
136 /// let cfg = Config::from_env(); // Panics if required variables are missing
137 /// println!("{:?}", cfg);
138 /// ```
139 pub fn from_env() -> Self {
140 dotenv().ok();
141 envy::from_env::<Config>().expect("Failed to read configuration from environment")
142 }
143
144 /// Returns a reference to the globally stored [`Config`].
145 ///
146 /// If the global config has not been set yet, this function will call
147 /// [`Config::from_env`] to populate it for the first time.
148 ///
149 /// # Example
150 ///
151 /// ```rust,no_run
152 /// // Ensure it's initialized once, typically at startup:
153 /// // Config::init().ok();
154 ///
155 /// // Retrieve the config anywhere else in the code:
156 /// # use zirv_queue::Config; // Hidden import so `Config` is recognized in the doc test
157 /// let cfg = Config::get_config();
158 /// println!("Current DB URL: {}", cfg.database_url);
159 /// ```
160 pub fn get_config() -> &'static Config {
161 GLOBAL_CONFIG.get_or_init(Config::from_env)
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::env;
169
170 /// Helper function: Remove relevant environment variables for a clean test slate.
171 fn clear_env_vars() {
172 env::remove_var("DATABASE_URL");
173 env::remove_var("MAX_CONCURRENT_JOBS");
174 env::remove_var("TICK_RATE_MS");
175 env::remove_var("MAX_RETRY_ATTEMPTS");
176 env::remove_var("RETRY_INTERVAL_MS");
177 }
178
179 /// Test 1: Ensure default values are used for optional fields when they're not set.
180 /// `DATABASE_URL` is mandatory, so we set it to avoid panic.
181 #[test]
182 fn test_defaults_when_env_not_set() {
183 clear_env_vars();
184 env::set_var("DATABASE_URL", "mysql://root:password@localhost/db");
185
186 let config = Config::from_env();
187
188 // Required field
189 assert_eq!(config.database_url, "mysql://root:password@localhost/db");
190
191 // Check defaults
192 assert_eq!(config.max_concurrent_jobs, 4);
193 assert_eq!(config.tick_rate_ms, 1000);
194 assert_eq!(config.max_retry_attempts, 3);
195 assert_eq!(config.retry_interval_ms, 300_000);
196 }
197
198 /// Test 2: Ensure all set environment variables are properly loaded.
199 /// This includes both required and optional fields.
200 #[test]
201 fn test_custom_env_values() {
202 clear_env_vars();
203
204 env::set_var("DATABASE_URL", "postgres://user:pass@localhost/custom_db");
205 env::set_var("MAX_CONCURRENT_JOBS", "8");
206 env::set_var("TICK_RATE_MS", "500");
207 env::set_var("MAX_RETRY_ATTEMPTS", "10");
208 env::set_var("RETRY_INTERVAL_MS", "600000"); // 10 minutes
209
210 let config = Config::from_env();
211
212 assert_eq!(config.database_url, "postgres://user:pass@localhost/custom_db");
213 assert_eq!(config.max_concurrent_jobs, 8);
214 assert_eq!(config.tick_rate_ms, 500);
215 assert_eq!(config.max_retry_attempts, 10);
216 assert_eq!(config.retry_interval_ms, 600_000);
217 }
218
219 /// Test 3: Confirm that omitting the required `DATABASE_URL` panics in `from_env()`.
220 #[test]
221 #[should_panic(expected = "Failed to read configuration from environment")]
222 fn test_missing_db_url_panics() {
223 clear_env_vars();
224 // Not setting DATABASE_URL should trigger a panic in from_env().
225 let _config = Config::from_env();
226 }
227
228 /// Test 4: Validate `get_config()` lazy-initializes the global config if `init()` wasn’t called.
229 /// We also check that once set, `get_config()` always returns the same reference.
230 #[test]
231 fn test_get_config_lazy_initialization() {
232 clear_env_vars();
233 env::set_var("DATABASE_URL", "lazy://init.db");
234 // Not calling `Config::init()` explicitly
235
236 let cfg_ref_1 = Config::get_config();
237 assert_eq!(cfg_ref_1.database_url, "lazy://init.db");
238
239 // Setting environment variables after the first get_config() call
240 // won't affect the config because it's already been initialized.
241 env::set_var("DATABASE_URL", "changed://won_t_take_effect.db");
242
243 let cfg_ref_2 = Config::get_config();
244 assert_eq!(cfg_ref_1 as *const Config, cfg_ref_2 as *const Config);
245 assert_eq!(cfg_ref_2.database_url, "lazy://init.db");
246 }
247
248 /// Test 5: Demonstrate how invalid numeric environment variables fail in `from_env()`.
249 /// We expect a panic from envy if parsing fails.
250 #[test]
251 #[should_panic(expected = "Failed to read configuration from environment")]
252 fn test_invalid_numeric_parse_panics() {
253 clear_env_vars();
254
255 env::set_var("DATABASE_URL", "mysql://root:pwd@localhost/invalid_parse");
256 env::set_var("MAX_CONCURRENT_JOBS", "not_a_number");
257
258 // This should panic because "not_a_number" cannot be parsed into a usize.
259 let _config = Config::from_env();
260 }
261}