Skip to main content

fd_core/
config.rs

1//! Configuration management for FerrumDeck
2
3use serde::Deserialize;
4
5/// Main application configuration
6#[derive(Debug, Clone, Deserialize)]
7pub struct Config {
8    /// Environment (development, staging, production)
9    #[serde(default = "default_env")]
10    pub env: String,
11
12    /// Logging configuration
13    #[serde(default)]
14    pub log: LogConfig,
15
16    /// Gateway configuration
17    #[serde(default)]
18    pub gateway: GatewayConfig,
19
20    /// Database configuration
21    pub database: DatabaseConfig,
22
23    /// Redis configuration
24    pub redis: RedisConfig,
25
26    /// OpenTelemetry configuration
27    #[serde(default)]
28    pub otel: OtelConfig,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32pub struct LogConfig {
33    #[serde(default = "default_log_level")]
34    pub level: String,
35
36    #[serde(default = "default_log_format")]
37    pub format: String,
38}
39
40impl Default for LogConfig {
41    fn default() -> Self {
42        Self {
43            level: default_log_level(),
44            format: default_log_format(),
45        }
46    }
47}
48
49#[derive(Debug, Clone, Deserialize)]
50pub struct GatewayConfig {
51    #[serde(default = "default_host")]
52    pub host: String,
53
54    #[serde(default = "default_port")]
55    pub port: u16,
56
57    #[serde(default = "default_workers")]
58    pub workers: usize,
59}
60
61impl Default for GatewayConfig {
62    fn default() -> Self {
63        Self {
64            host: default_host(),
65            port: default_port(),
66            workers: default_workers(),
67        }
68    }
69}
70
71#[derive(Debug, Clone, Deserialize)]
72pub struct DatabaseConfig {
73    pub url: String,
74
75    #[serde(default = "default_max_connections")]
76    pub max_connections: u32,
77
78    #[serde(default = "default_min_connections")]
79    pub min_connections: u32,
80}
81
82#[derive(Debug, Clone, Deserialize)]
83pub struct RedisConfig {
84    pub url: String,
85
86    #[serde(default = "default_queue_prefix")]
87    pub queue_prefix: String,
88
89    #[serde(default = "default_cache_prefix")]
90    pub cache_prefix: String,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94pub struct OtelConfig {
95    #[serde(default)]
96    pub enabled: bool,
97
98    pub endpoint: Option<String>,
99
100    #[serde(default = "default_service_name")]
101    pub service_name: String,
102}
103
104impl Default for OtelConfig {
105    fn default() -> Self {
106        Self {
107            enabled: false,
108            endpoint: None,
109            service_name: default_service_name(),
110        }
111    }
112}
113
114// Default value functions
115fn default_env() -> String {
116    "development".to_string()
117}
118fn default_log_level() -> String {
119    "debug".to_string()
120}
121fn default_log_format() -> String {
122    "pretty".to_string()
123}
124fn default_host() -> String {
125    "0.0.0.0".to_string()
126}
127fn default_port() -> u16 {
128    8080
129}
130fn default_workers() -> usize {
131    4
132}
133fn default_max_connections() -> u32 {
134    20
135}
136fn default_min_connections() -> u32 {
137    5
138}
139fn default_queue_prefix() -> String {
140    "fd:queue:".to_string()
141}
142fn default_cache_prefix() -> String {
143    "fd:cache:".to_string()
144}
145fn default_service_name() -> String {
146    "ferrumdeck".to_string()
147}
148
149impl Config {
150    /// Load configuration from environment and optional file
151    pub fn load() -> Result<Self, config::ConfigError> {
152        // Load .env file if present
153        let _ = dotenvy::dotenv();
154
155        let builder = config::Config::builder()
156            // Set defaults
157            .set_default("env", "development")?
158            // Load from environment with FERRUMDECK_ prefix
159            .add_source(
160                config::Environment::with_prefix("FERRUMDECK")
161                    .separator("_")
162                    .try_parsing(true),
163            )
164            // Database from DATABASE_URL
165            .add_source(
166                config::Environment::default()
167                    .prefix("DATABASE")
168                    .separator("_"),
169            )
170            // Redis from REDIS_URL
171            .add_source(
172                config::Environment::default()
173                    .prefix("REDIS")
174                    .separator("_"),
175            )
176            // Gateway from GATEWAY_
177            .add_source(
178                config::Environment::default()
179                    .prefix("GATEWAY")
180                    .separator("_"),
181            )
182            // OTel from OTEL_
183            .add_source(config::Environment::default().prefix("OTEL").separator("_"));
184
185        builder.build()?.try_deserialize()
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // ============================================================
194    // CORE-CFG-001: Config struct definitions
195    // ============================================================
196
197    #[test]
198    fn test_config_struct_has_required_fields() {
199        // Verify Config struct has all expected fields by construction
200        let config = Config {
201            env: "test".to_string(),
202            log: LogConfig::default(),
203            gateway: GatewayConfig::default(),
204            database: DatabaseConfig {
205                url: "postgres://test".to_string(),
206                max_connections: 10,
207                min_connections: 2,
208            },
209            redis: RedisConfig {
210                url: "redis://test".to_string(),
211                queue_prefix: "test:".to_string(),
212                cache_prefix: "cache:".to_string(),
213            },
214            otel: OtelConfig::default(),
215        };
216        assert_eq!(config.env, "test");
217    }
218
219    #[test]
220    fn test_config_is_cloneable() {
221        let config = Config {
222            env: "test".to_string(),
223            log: LogConfig::default(),
224            gateway: GatewayConfig::default(),
225            database: DatabaseConfig {
226                url: "postgres://test".to_string(),
227                max_connections: 10,
228                min_connections: 2,
229            },
230            redis: RedisConfig {
231                url: "redis://test".to_string(),
232                queue_prefix: "test:".to_string(),
233                cache_prefix: "cache:".to_string(),
234            },
235            otel: OtelConfig::default(),
236        };
237        let cloned = config.clone();
238        assert_eq!(cloned.env, config.env);
239    }
240
241    #[test]
242    fn test_config_is_debuggable() {
243        let config = LogConfig::default();
244        let debug_str = format!("{:?}", config);
245        assert!(debug_str.contains("LogConfig"));
246    }
247
248    // ============================================================
249    // CORE-CFG-002: Default values
250    // ============================================================
251
252    #[test]
253    fn test_log_config_default_level() {
254        let config = LogConfig::default();
255        assert_eq!(config.level, "debug");
256    }
257
258    #[test]
259    fn test_log_config_default_format() {
260        let config = LogConfig::default();
261        assert_eq!(config.format, "pretty");
262    }
263
264    #[test]
265    fn test_gateway_config_default_host() {
266        let config = GatewayConfig::default();
267        assert_eq!(config.host, "0.0.0.0");
268    }
269
270    #[test]
271    fn test_gateway_config_default_port() {
272        let config = GatewayConfig::default();
273        assert_eq!(config.port, 8080);
274    }
275
276    #[test]
277    fn test_gateway_config_default_workers() {
278        let config = GatewayConfig::default();
279        assert_eq!(config.workers, 4);
280    }
281
282    #[test]
283    fn test_otel_config_default_disabled() {
284        let config = OtelConfig::default();
285        assert!(!config.enabled);
286    }
287
288    #[test]
289    fn test_otel_config_default_endpoint_none() {
290        let config = OtelConfig::default();
291        assert!(config.endpoint.is_none());
292    }
293
294    #[test]
295    fn test_otel_config_default_service_name() {
296        let config = OtelConfig::default();
297        assert_eq!(config.service_name, "ferrumdeck");
298    }
299
300    #[test]
301    fn test_default_env_is_development() {
302        assert_eq!(default_env(), "development");
303    }
304
305    #[test]
306    fn test_default_max_connections() {
307        assert_eq!(default_max_connections(), 20);
308    }
309
310    #[test]
311    fn test_default_min_connections() {
312        assert_eq!(default_min_connections(), 5);
313    }
314
315    #[test]
316    fn test_default_queue_prefix() {
317        assert_eq!(default_queue_prefix(), "fd:queue:");
318    }
319
320    #[test]
321    fn test_default_cache_prefix() {
322        assert_eq!(default_cache_prefix(), "fd:cache:");
323    }
324
325    // ============================================================
326    // CORE-CFG-003: Serde deserialization
327    // ============================================================
328
329    #[test]
330    fn test_log_config_deserialize_from_json() {
331        let json = r#"{"level": "info", "format": "json"}"#;
332        let config: LogConfig = serde_json::from_str(json).unwrap();
333        assert_eq!(config.level, "info");
334        assert_eq!(config.format, "json");
335    }
336
337    #[test]
338    fn test_log_config_deserialize_with_defaults() {
339        let json = r#"{}"#;
340        let config: LogConfig = serde_json::from_str(json).unwrap();
341        assert_eq!(config.level, "debug");
342        assert_eq!(config.format, "pretty");
343    }
344
345    #[test]
346    fn test_gateway_config_deserialize_from_json() {
347        let json = r#"{"host": "127.0.0.1", "port": 9000, "workers": 8}"#;
348        let config: GatewayConfig = serde_json::from_str(json).unwrap();
349        assert_eq!(config.host, "127.0.0.1");
350        assert_eq!(config.port, 9000);
351        assert_eq!(config.workers, 8);
352    }
353
354    #[test]
355    fn test_gateway_config_deserialize_with_defaults() {
356        let json = r#"{}"#;
357        let config: GatewayConfig = serde_json::from_str(json).unwrap();
358        assert_eq!(config.host, "0.0.0.0");
359        assert_eq!(config.port, 8080);
360        assert_eq!(config.workers, 4);
361    }
362
363    #[test]
364    fn test_database_config_deserialize_from_json() {
365        let json = r#"{"url": "postgres://user:pass@host/db", "max_connections": 50, "min_connections": 10}"#;
366        let config: DatabaseConfig = serde_json::from_str(json).unwrap();
367        assert_eq!(config.url, "postgres://user:pass@host/db");
368        assert_eq!(config.max_connections, 50);
369        assert_eq!(config.min_connections, 10);
370    }
371
372    #[test]
373    fn test_database_config_requires_url() {
374        let json = r#"{"max_connections": 50}"#;
375        let result: Result<DatabaseConfig, _> = serde_json::from_str(json);
376        assert!(result.is_err());
377    }
378
379    #[test]
380    fn test_redis_config_deserialize_from_json() {
381        let json = r#"{"url": "redis://localhost:6379", "queue_prefix": "myapp:", "cache_prefix": "mycache:"}"#;
382        let config: RedisConfig = serde_json::from_str(json).unwrap();
383        assert_eq!(config.url, "redis://localhost:6379");
384        assert_eq!(config.queue_prefix, "myapp:");
385        assert_eq!(config.cache_prefix, "mycache:");
386    }
387
388    #[test]
389    fn test_redis_config_requires_url() {
390        let json = r#"{"queue_prefix": "test:"}"#;
391        let result: Result<RedisConfig, _> = serde_json::from_str(json);
392        assert!(result.is_err());
393    }
394
395    #[test]
396    fn test_otel_config_deserialize_from_json() {
397        let json =
398            r#"{"enabled": true, "endpoint": "http://jaeger:4317", "service_name": "my-service"}"#;
399        let config: OtelConfig = serde_json::from_str(json).unwrap();
400        assert!(config.enabled);
401        assert_eq!(config.endpoint, Some("http://jaeger:4317".to_string()));
402        assert_eq!(config.service_name, "my-service");
403    }
404
405    #[test]
406    fn test_otel_config_deserialize_with_defaults() {
407        let json = r#"{}"#;
408        let config: OtelConfig = serde_json::from_str(json).unwrap();
409        assert!(!config.enabled);
410        assert!(config.endpoint.is_none());
411        assert_eq!(config.service_name, "ferrumdeck");
412    }
413
414    // ============================================================
415    // CORE-CFG-004: Full config deserialization
416    // ============================================================
417
418    #[test]
419    fn test_full_config_deserialize() {
420        let json = r#"{
421            "env": "production",
422            "log": {"level": "warn", "format": "json"},
423            "gateway": {"host": "0.0.0.0", "port": 80, "workers": 16},
424            "database": {"url": "postgres://prod/db"},
425            "redis": {"url": "redis://prod:6379"},
426            "otel": {"enabled": true, "endpoint": "http://otel:4317"}
427        }"#;
428        let config: Config = serde_json::from_str(json).unwrap();
429        assert_eq!(config.env, "production");
430        assert_eq!(config.log.level, "warn");
431        assert_eq!(config.gateway.port, 80);
432        assert!(config.otel.enabled);
433    }
434
435    // ============================================================
436    // CORE-CFG-005: Config value validation
437    // ============================================================
438
439    #[test]
440    fn test_gateway_port_accepts_valid_range() {
441        let config = GatewayConfig {
442            host: "localhost".to_string(),
443            port: 1,
444            workers: 1,
445        };
446        assert_eq!(config.port, 1);
447
448        let config = GatewayConfig {
449            host: "localhost".to_string(),
450            port: 65535,
451            workers: 1,
452        };
453        assert_eq!(config.port, 65535);
454    }
455
456    #[test]
457    fn test_workers_accepts_positive_values() {
458        let config = GatewayConfig {
459            host: "localhost".to_string(),
460            port: 8080,
461            workers: 1,
462        };
463        assert_eq!(config.workers, 1);
464
465        let config = GatewayConfig {
466            host: "localhost".to_string(),
467            port: 8080,
468            workers: 128,
469        };
470        assert_eq!(config.workers, 128);
471    }
472
473    #[test]
474    fn test_connection_pool_values() {
475        let config = DatabaseConfig {
476            url: "postgres://test".to_string(),
477            max_connections: 100,
478            min_connections: 5,
479        };
480        assert!(config.max_connections >= config.min_connections);
481    }
482}