1use std::net::SocketAddr;
6
7#[derive(Debug, Clone)]
9pub struct Config {
10 pub database_url: String,
12 pub http_addr: SocketAddr,
14 pub max_concurrent_instances: u32,
16}
17
18impl Config {
19 pub fn from_env() -> Result<Self, ConfigError> {
28 let database_url = std::env::var("RUNTARA_DATABASE_URL")
29 .map_err(|_| ConfigError::Missing("RUNTARA_DATABASE_URL"))?;
30
31 let http_port: u16 = std::env::var("RUNTARA_HTTP_PORT")
32 .unwrap_or_else(|_| "8001".to_string())
33 .parse()
34 .map_err(|_| {
35 ConfigError::Invalid("RUNTARA_HTTP_PORT", "must be a valid port number")
36 })?;
37
38 let max_concurrent_instances: u32 = std::env::var("RUNTARA_MAX_CONCURRENT_INSTANCES")
39 .unwrap_or_else(|_| "32".to_string())
40 .parse()
41 .map_err(|_| {
42 ConfigError::Invalid(
43 "RUNTARA_MAX_CONCURRENT_INSTANCES",
44 "must be a positive integer",
45 )
46 })?;
47
48 Ok(Self {
49 database_url,
50 http_addr: SocketAddr::from(([0, 0, 0, 0], http_port)),
51 max_concurrent_instances,
52 })
53 }
54}
55
56#[derive(Debug, thiserror::Error)]
58pub enum ConfigError {
59 #[error("missing required environment variable: {0}")]
61 Missing(&'static str),
62
63 #[error("invalid value for {0}: {1}")]
65 Invalid(&'static str, &'static str),
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71 use std::env;
72 use std::sync::Mutex;
73
74 static ENV_MUTEX: Mutex<()> = Mutex::new(());
76
77 struct EnvGuard {
79 vars: Vec<(String, Option<String>)>,
80 }
81
82 impl EnvGuard {
83 fn new() -> Self {
84 Self { vars: Vec::new() }
85 }
86
87 fn set(&mut self, key: &str, value: &str) {
88 let old = env::var(key).ok();
89 self.vars.push((key.to_string(), old));
90 unsafe { env::set_var(key, value) };
92 }
93
94 fn remove(&mut self, key: &str) {
95 let old = env::var(key).ok();
96 self.vars.push((key.to_string(), old));
97 unsafe { env::remove_var(key) };
99 }
100 }
101
102 impl Drop for EnvGuard {
103 fn drop(&mut self) {
104 for (key, value) in self.vars.drain(..).rev() {
105 unsafe {
107 match value {
108 Some(v) => env::set_var(&key, v),
109 None => env::remove_var(&key),
110 }
111 }
112 }
113 }
114 }
115
116 #[test]
117 fn test_config_from_env_with_defaults() {
118 let _lock = ENV_MUTEX.lock().unwrap();
119 let mut guard = EnvGuard::new();
120
121 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
122 guard.remove("RUNTARA_HTTP_PORT");
123 guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
124
125 let config = Config::from_env().unwrap();
126
127 assert_eq!(config.database_url, "postgres://localhost/test");
128 assert_eq!(config.http_addr.port(), 8001);
129 assert_eq!(config.max_concurrent_instances, 32);
130 }
131
132 #[test]
133 fn test_config_from_env_with_custom_port() {
134 let _lock = ENV_MUTEX.lock().unwrap();
135 let mut guard = EnvGuard::new();
136
137 guard.set("RUNTARA_DATABASE_URL", "sqlite:test.db");
138 guard.set("RUNTARA_HTTP_PORT", "9999");
139 guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
140
141 let config = Config::from_env().unwrap();
142
143 assert_eq!(config.database_url, "sqlite:test.db");
144 assert_eq!(config.http_addr.port(), 9999);
145 assert_eq!(config.max_concurrent_instances, 32);
146 }
147
148 #[test]
149 fn test_config_from_env_with_custom_max_instances() {
150 let _lock = ENV_MUTEX.lock().unwrap();
151 let mut guard = EnvGuard::new();
152
153 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
154 guard.remove("RUNTARA_HTTP_PORT");
155 guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "100");
156
157 let config = Config::from_env().unwrap();
158
159 assert_eq!(config.max_concurrent_instances, 100);
160 }
161
162 #[test]
163 fn test_config_from_env_all_custom() {
164 let _lock = ENV_MUTEX.lock().unwrap();
165 let mut guard = EnvGuard::new();
166
167 guard.set("RUNTARA_DATABASE_URL", "postgres://user:pass@db:5432/prod");
168 guard.set("RUNTARA_HTTP_PORT", "8080");
169 guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "256");
170
171 let config = Config::from_env().unwrap();
172
173 assert_eq!(config.database_url, "postgres://user:pass@db:5432/prod");
174 assert_eq!(config.http_addr.port(), 8080);
175 assert_eq!(config.max_concurrent_instances, 256);
176 }
177
178 #[test]
179 fn test_config_missing_database_url() {
180 let _lock = ENV_MUTEX.lock().unwrap();
181 let mut guard = EnvGuard::new();
182
183 guard.remove("RUNTARA_DATABASE_URL");
184
185 let result = Config::from_env();
186 assert!(result.is_err());
187
188 let err = result.unwrap_err();
189 assert!(matches!(err, ConfigError::Missing("RUNTARA_DATABASE_URL")));
190 assert!(err.to_string().contains("RUNTARA_DATABASE_URL"));
191 }
192
193 #[test]
194 fn test_config_invalid_http_port() {
195 let _lock = ENV_MUTEX.lock().unwrap();
196 let mut guard = EnvGuard::new();
197
198 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
199 guard.set("RUNTARA_HTTP_PORT", "not_a_number");
200
201 let result = Config::from_env();
202 assert!(result.is_err());
203
204 let err = result.unwrap_err();
205 assert!(matches!(err, ConfigError::Invalid("RUNTARA_HTTP_PORT", _)));
206 }
207
208 #[test]
209 fn test_config_invalid_http_port_out_of_range() {
210 let _lock = ENV_MUTEX.lock().unwrap();
211 let mut guard = EnvGuard::new();
212
213 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
214 guard.set("RUNTARA_HTTP_PORT", "99999"); let result = Config::from_env();
217 assert!(result.is_err());
218
219 let err = result.unwrap_err();
220 assert!(matches!(err, ConfigError::Invalid("RUNTARA_HTTP_PORT", _)));
221 }
222
223 #[test]
224 fn test_config_invalid_max_concurrent_instances() {
225 let _lock = ENV_MUTEX.lock().unwrap();
226 let mut guard = EnvGuard::new();
227
228 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
229 guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "abc");
230
231 let result = Config::from_env();
232 assert!(result.is_err());
233
234 let err = result.unwrap_err();
235 assert!(matches!(
236 err,
237 ConfigError::Invalid("RUNTARA_MAX_CONCURRENT_INSTANCES", _)
238 ));
239 }
240
241 #[test]
242 fn test_config_negative_max_concurrent_instances() {
243 let _lock = ENV_MUTEX.lock().unwrap();
244 let mut guard = EnvGuard::new();
245
246 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
247 guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "-5");
248
249 let result = Config::from_env();
250 assert!(result.is_err());
251 }
252
253 #[test]
254 fn test_config_error_display() {
255 let missing = ConfigError::Missing("MY_VAR");
256 assert_eq!(
257 missing.to_string(),
258 "missing required environment variable: MY_VAR"
259 );
260
261 let invalid = ConfigError::Invalid("MY_VAR", "must be a number");
262 assert_eq!(
263 invalid.to_string(),
264 "invalid value for MY_VAR: must be a number"
265 );
266 }
267
268 #[test]
269 fn test_config_debug() {
270 let _lock = ENV_MUTEX.lock().unwrap();
271 let mut guard = EnvGuard::new();
272
273 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
274 guard.remove("RUNTARA_HTTP_PORT");
275 guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
276
277 let config = Config::from_env().unwrap();
278 let debug_str = format!("{:?}", config);
279
280 assert!(debug_str.contains("Config"));
281 assert!(debug_str.contains("database_url"));
282 assert!(debug_str.contains("http_addr"));
283 }
284
285 #[test]
286 fn test_config_clone() {
287 let _lock = ENV_MUTEX.lock().unwrap();
288 let mut guard = EnvGuard::new();
289
290 guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
291 guard.remove("RUNTARA_HTTP_PORT");
292 guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
293
294 let config = Config::from_env().unwrap();
295 let cloned = config.clone();
296
297 assert_eq!(config.database_url, cloned.database_url);
298 assert_eq!(config.http_addr, cloned.http_addr);
299 assert_eq!(
300 config.max_concurrent_instances,
301 cloned.max_concurrent_instances
302 );
303 }
304}