1use std::env;
2use std::str::FromStr;
3use std::collections::HashMap;
4use thiserror::Error;
5
6pub trait AppConfigTrait: Sized {
8 fn from_env() -> Result<Self, ConfigError>;
10
11 fn validate(&self) -> Result<(), ConfigError>;
13
14 fn config_sources(&self) -> HashMap<String, ConfigSource>;
16}
17
18#[derive(Debug, Clone)]
20pub enum ConfigSource {
21 EnvVar(String),
22 Default(String),
23 Nested,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Environment {
29 Development,
30 Testing,
31 Production,
32}
33
34impl FromStr for Environment {
35 type Err = ConfigError;
36
37 fn from_str(s: &str) -> Result<Self, Self::Err> {
38 match s.to_lowercase().as_str() {
39 "development" | "dev" => Ok(Environment::Development),
40 "testing" | "test" => Ok(Environment::Testing),
41 "production" | "prod" => Ok(Environment::Production),
42 _ => Err(ConfigError::InvalidValue {
43 field: "environment".to_string(),
44 value: s.to_string(),
45 expected: "development, testing, or production".to_string(),
46 }),
47 }
48 }
49}
50
51impl Default for Environment {
52 fn default() -> Self {
53 Environment::Development
54 }
55}
56
57#[derive(Debug, Clone)]
59pub struct AppConfig {
60 pub name: String,
61 pub environment: Environment,
62 pub database_url: String,
63 pub jwt_secret: Option<String>,
64 pub server: ServerConfig,
65 pub logging: LoggingConfig,
66}
67
68#[derive(Debug, Clone)]
70pub struct ServerConfig {
71 pub host: String,
72 pub port: u16,
73 pub workers: usize,
74}
75
76#[derive(Debug, Clone)]
78pub struct LoggingConfig {
79 pub level: String,
80 pub format: String,
81}
82
83impl AppConfigTrait for AppConfig {
84 fn from_env() -> Result<Self, ConfigError> {
85 let name = get_env_or_default("APP_NAME", "elif-app")?;
86 let environment = get_env_or_default("APP_ENV", "development")?;
87 let environment = Environment::from_str(&environment)?;
88
89 let database_url = get_env_required("DATABASE_URL")?;
90 let jwt_secret = get_env_optional("JWT_SECRET");
91
92 let server = ServerConfig::from_env()?;
93 let logging = LoggingConfig::from_env()?;
94
95 Ok(AppConfig {
96 name,
97 environment,
98 database_url,
99 jwt_secret,
100 server,
101 logging,
102 })
103 }
104
105 fn validate(&self) -> Result<(), ConfigError> {
106 if self.name.is_empty() {
108 return Err(ConfigError::ValidationFailed {
109 field: "name".to_string(),
110 reason: "App name cannot be empty".to_string(),
111 });
112 }
113
114 if self.database_url.is_empty() {
116 return Err(ConfigError::ValidationFailed {
117 field: "database_url".to_string(),
118 reason: "Database URL cannot be empty".to_string(),
119 });
120 }
121
122 if self.environment == Environment::Production && self.jwt_secret.is_none() {
124 return Err(ConfigError::ValidationFailed {
125 field: "jwt_secret".to_string(),
126 reason: "JWT secret is required in production".to_string(),
127 });
128 }
129
130 self.server.validate()?;
132 self.logging.validate()?;
133
134 Ok(())
135 }
136
137 fn config_sources(&self) -> HashMap<String, ConfigSource> {
138 let mut sources = HashMap::new();
139 sources.insert("name".to_string(),
140 ConfigSource::EnvVar("APP_NAME".to_string()));
141 sources.insert("environment".to_string(),
142 ConfigSource::EnvVar("APP_ENV".to_string()));
143 sources.insert("database_url".to_string(),
144 ConfigSource::EnvVar("DATABASE_URL".to_string()));
145 sources.insert("jwt_secret".to_string(),
146 ConfigSource::EnvVar("JWT_SECRET".to_string()));
147 sources.insert("server".to_string(),
148 ConfigSource::Nested);
149 sources.insert("logging".to_string(),
150 ConfigSource::Nested);
151 sources
152 }
153}
154
155impl AppConfigTrait for ServerConfig {
156 fn from_env() -> Result<Self, ConfigError> {
157 let host = get_env_or_default("SERVER_HOST", "0.0.0.0")?;
158 let port = get_env_or_default("SERVER_PORT", "3000")?;
159 let port = port.parse::<u16>()
160 .map_err(|_| ConfigError::InvalidValue {
161 field: "port".to_string(),
162 value: port,
163 expected: "valid port number (0-65535)".to_string(),
164 })?;
165
166 let workers = get_env_or_default("SERVER_WORKERS", "0")?;
167 let workers = workers.parse::<usize>()
168 .map_err(|_| ConfigError::InvalidValue {
169 field: "workers".to_string(),
170 value: workers,
171 expected: "valid number".to_string(),
172 })?;
173
174 let workers = if workers == 0 {
176 num_cpus::get()
177 } else {
178 workers
179 };
180
181 Ok(ServerConfig {
182 host,
183 port,
184 workers,
185 })
186 }
187
188 fn validate(&self) -> Result<(), ConfigError> {
189 if self.host.is_empty() {
191 return Err(ConfigError::ValidationFailed {
192 field: "host".to_string(),
193 reason: "Host cannot be empty".to_string(),
194 });
195 }
196
197 if self.port == 0 {
199 return Err(ConfigError::ValidationFailed {
200 field: "port".to_string(),
201 reason: "Port cannot be 0".to_string(),
202 });
203 }
204
205 if self.workers == 0 {
207 return Err(ConfigError::ValidationFailed {
208 field: "workers".to_string(),
209 reason: "Workers cannot be 0 after auto-detection".to_string(),
210 });
211 }
212
213 Ok(())
214 }
215
216 fn config_sources(&self) -> HashMap<String, ConfigSource> {
217 let mut sources = HashMap::new();
218 sources.insert("host".to_string(),
219 ConfigSource::EnvVar("SERVER_HOST".to_string()));
220 sources.insert("port".to_string(),
221 ConfigSource::EnvVar("SERVER_PORT".to_string()));
222 sources.insert("workers".to_string(),
223 ConfigSource::EnvVar("SERVER_WORKERS".to_string()));
224 sources
225 }
226}
227
228impl AppConfigTrait for LoggingConfig {
229 fn from_env() -> Result<Self, ConfigError> {
230 let level = get_env_or_default("LOG_LEVEL", "info")?;
231 let format = get_env_or_default("LOG_FORMAT", "compact")?;
232
233 Ok(LoggingConfig {
234 level,
235 format,
236 })
237 }
238
239 fn validate(&self) -> Result<(), ConfigError> {
240 let valid_levels = ["trace", "debug", "info", "warn", "error"];
242 if !valid_levels.contains(&self.level.to_lowercase().as_str()) {
243 return Err(ConfigError::InvalidValue {
244 field: "level".to_string(),
245 value: self.level.clone(),
246 expected: "trace, debug, info, warn, or error".to_string(),
247 });
248 }
249
250 let valid_formats = ["compact", "pretty", "json"];
252 if !valid_formats.contains(&self.format.to_lowercase().as_str()) {
253 return Err(ConfigError::InvalidValue {
254 field: "format".to_string(),
255 value: self.format.clone(),
256 expected: "compact, pretty, or json".to_string(),
257 });
258 }
259
260 Ok(())
261 }
262
263 fn config_sources(&self) -> HashMap<String, ConfigSource> {
264 let mut sources = HashMap::new();
265 sources.insert("level".to_string(),
266 ConfigSource::EnvVar("LOG_LEVEL".to_string()));
267 sources.insert("format".to_string(),
268 ConfigSource::EnvVar("LOG_FORMAT".to_string()));
269 sources
270 }
271}
272
273pub struct ConfigWatcher {
275 config: AppConfig,
276 last_check: std::time::Instant,
277 check_interval: std::time::Duration,
278}
279
280impl ConfigWatcher {
281 pub fn new(config: AppConfig) -> Self {
282 Self {
283 config,
284 last_check: std::time::Instant::now(),
285 check_interval: std::time::Duration::from_secs(1),
286 }
287 }
288
289 pub fn check_for_changes(&mut self) -> Result<Option<AppConfig>, ConfigError> {
291 let now = std::time::Instant::now();
292 if now.duration_since(self.last_check) < self.check_interval {
293 return Ok(None);
294 }
295
296 self.last_check = now;
297
298 if self.config.environment == Environment::Development {
300 let new_config = AppConfig::from_env()?;
301 new_config.validate()?;
302
303 let changed = format!("{:?}", new_config) != format!("{:?}", self.config);
305
306 if changed {
307 self.config = new_config.clone();
308 return Ok(Some(new_config));
309 }
310 }
311
312 Ok(None)
313 }
314
315 pub fn config(&self) -> &AppConfig {
317 &self.config
318 }
319}
320
321fn get_env_required(key: &str) -> Result<String, ConfigError> {
323 env::var(key).map_err(|_| ConfigError::MissingEnvVar {
324 var: key.to_string(),
325 })
326}
327
328fn get_env_optional(key: &str) -> Option<String> {
329 env::var(key).ok()
330}
331
332fn get_env_or_default(key: &str, default: &str) -> Result<String, ConfigError> {
333 Ok(env::var(key).unwrap_or_else(|_| default.to_string()))
334}
335
336#[derive(Error, Debug)]
337pub enum ConfigError {
338 #[error("Missing required environment variable: {var}")]
339 MissingEnvVar { var: String },
340
341 #[error("Invalid value for {field}: '{value}', expected {expected}")]
342 InvalidValue { field: String, value: String, expected: String },
343
344 #[error("Validation failed for {field}: {reason}")]
345 ValidationFailed { field: String, reason: String },
346
347 #[error("Configuration parsing error: {message}")]
348 ParseError { message: String },
349
350 #[error("Configuration reload error: {message}")]
351 ReloadError { message: String },
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use std::env;
358 use std::sync::Mutex;
359
360 static TEST_MUTEX: Mutex<()> = Mutex::new(());
362
363 fn set_test_env() {
365 env::set_var("APP_NAME", "test-app");
366 env::set_var("APP_ENV", "testing");
367 env::set_var("DATABASE_URL", "sqlite::memory:");
368 env::set_var("JWT_SECRET", "test-secret-key");
369 env::set_var("SERVER_HOST", "127.0.0.1");
370 env::set_var("SERVER_PORT", "8080");
371 env::set_var("SERVER_WORKERS", "4");
372 env::set_var("LOG_LEVEL", "debug");
373 env::set_var("LOG_FORMAT", "pretty");
374 }
375
376 fn clean_test_env() {
377 env::remove_var("APP_NAME");
378 env::remove_var("APP_ENV");
379 env::remove_var("DATABASE_URL");
380 env::remove_var("JWT_SECRET");
381 env::remove_var("SERVER_HOST");
382 env::remove_var("SERVER_PORT");
383 env::remove_var("SERVER_WORKERS");
384 env::remove_var("LOG_LEVEL");
385 env::remove_var("LOG_FORMAT");
386 }
387
388 #[test]
389 fn test_app_config_from_env() {
390 let _guard = TEST_MUTEX.lock().unwrap();
391 set_test_env();
392
393 let config = AppConfig::from_env().unwrap();
394
395 assert_eq!(config.name, "test-app");
396 assert_eq!(config.environment, Environment::Testing);
397 assert_eq!(config.database_url, "sqlite::memory:");
398 assert_eq!(config.jwt_secret, Some("test-secret-key".to_string()));
399 assert_eq!(config.server.host, "127.0.0.1");
400 assert_eq!(config.server.port, 8080);
401 assert_eq!(config.server.workers, 4);
402 assert_eq!(config.logging.level, "debug");
403 assert_eq!(config.logging.format, "pretty");
404
405 clean_test_env();
406 }
407
408 #[test]
409 fn test_app_config_defaults() {
410 let _guard = TEST_MUTEX.lock().unwrap();
411 clean_test_env();
412 env::set_var("DATABASE_URL", "sqlite::memory:");
413
414 let config = AppConfig::from_env().unwrap();
415
416 assert_eq!(config.name, "elif-app");
417 assert_eq!(config.environment, Environment::Development);
418 assert_eq!(config.server.host, "0.0.0.0");
419 assert_eq!(config.server.port, 3000);
420 assert_eq!(config.logging.level, "info");
421 assert_eq!(config.logging.format, "compact");
422
423 clean_test_env();
424 }
425
426 #[test]
427 fn test_missing_required_env_var() {
428 let _guard = TEST_MUTEX.lock().unwrap();
429 clean_test_env();
430 let result = AppConfig::from_env();
433 assert!(result.is_err());
434
435 if let Err(ConfigError::MissingEnvVar { var }) = result {
436 assert_eq!(var, "DATABASE_URL");
437 } else {
438 panic!("Expected MissingEnvVar error");
439 }
440 }
441
442 #[test]
443 fn test_config_validation() {
444 let _guard = TEST_MUTEX.lock().unwrap();
445 set_test_env();
446
447 let config = AppConfig::from_env().unwrap();
448 assert!(config.validate().is_ok());
449
450 clean_test_env();
451 }
452
453 #[test]
454 fn test_production_jwt_secret_validation() {
455 let _guard = TEST_MUTEX.lock().unwrap();
456 set_test_env();
457 env::set_var("APP_ENV", "production");
458 env::remove_var("JWT_SECRET");
459
460 let config = AppConfig::from_env().unwrap();
461 let result = config.validate();
462
463 assert!(result.is_err());
464 if let Err(ConfigError::ValidationFailed { field, .. }) = result {
465 assert_eq!(field, "jwt_secret");
466 } else {
467 panic!("Expected ValidationFailed error for jwt_secret");
468 }
469
470 clean_test_env();
471 }
472
473 #[test]
474 fn test_invalid_port() {
475 let _guard = TEST_MUTEX.lock().unwrap();
476 set_test_env();
477 env::set_var("SERVER_PORT", "invalid");
478
479 let result = AppConfig::from_env();
480 assert!(result.is_err());
481
482 if let Err(ConfigError::InvalidValue { field, .. }) = result {
483 assert_eq!(field, "port");
484 } else {
485 panic!("Expected InvalidValue error for port");
486 }
487
488 clean_test_env();
489 }
490
491 #[test]
492 fn test_invalid_log_level() {
493 let _guard = TEST_MUTEX.lock().unwrap();
494 set_test_env();
495 env::set_var("LOG_LEVEL", "invalid");
496
497 let config = AppConfig::from_env().unwrap();
498 let result = config.validate();
499
500 assert!(result.is_err());
501 if let Err(ConfigError::InvalidValue { field, .. }) = result {
502 assert_eq!(field, "level");
503 } else {
504 panic!("Expected InvalidValue error for log level");
505 }
506
507 clean_test_env();
508 }
509
510 #[test]
511 fn test_environment_parsing() {
512 assert_eq!(Environment::from_str("development").unwrap(), Environment::Development);
513 assert_eq!(Environment::from_str("dev").unwrap(), Environment::Development);
514 assert_eq!(Environment::from_str("testing").unwrap(), Environment::Testing);
515 assert_eq!(Environment::from_str("test").unwrap(), Environment::Testing);
516 assert_eq!(Environment::from_str("production").unwrap(), Environment::Production);
517 assert_eq!(Environment::from_str("prod").unwrap(), Environment::Production);
518
519 assert!(Environment::from_str("invalid").is_err());
520 }
521
522 #[test]
523 fn test_config_sources() {
524 let _guard = TEST_MUTEX.lock().unwrap();
525 set_test_env();
526
527 let config = AppConfig::from_env().unwrap();
528 let sources = config.config_sources();
529
530 assert!(matches!(sources.get("name"), Some(ConfigSource::EnvVar(_))));
531 assert!(matches!(sources.get("server"), Some(ConfigSource::Nested)));
532
533 clean_test_env();
534 }
535
536 #[test]
537 fn test_config_watcher() {
538 let _guard = TEST_MUTEX.lock().unwrap();
539 set_test_env();
540
541 let config = AppConfig::from_env().unwrap();
542 let mut watcher = ConfigWatcher::new(config);
543
544 let result = watcher.check_for_changes().unwrap();
546 assert!(result.is_none());
547
548 clean_test_env();
549 }
550}