Skip to main content

crate_checker/
config.rs

1//! Configuration management for the crate checker application
2
3use crate::{DEFAULT_API_URL, DEFAULT_SERVER_PORT, DEFAULT_TIMEOUT_SECS, DEFAULT_USER_AGENT};
4use config::{Config, ConfigError, Environment, File};
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7use tracing::info;
8
9/// Main application configuration
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AppConfig {
12    /// Server configuration
13    pub server: ServerConfig,
14
15    /// Cache configuration
16    pub cache: CacheConfig,
17
18    /// Logging configuration
19    pub logging: LoggingConfig,
20
21    /// Rate limiting configuration
22    pub rate_limiting: RateLimitConfig,
23
24    /// Crates.io API configuration
25    pub crates_io: CratesIoConfig,
26}
27
28/// Server configuration
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ServerConfig {
31    /// Port to bind to
32    #[serde(default = "default_port")]
33    pub port: u16,
34
35    /// Host to bind to
36    #[serde(default = "default_host")]
37    pub host: String,
38
39    /// Number of worker threads
40    #[serde(default = "default_workers")]
41    pub workers: usize,
42
43    /// Request timeout in seconds
44    #[serde(default = "default_request_timeout")]
45    pub request_timeout: u64,
46
47    /// Enable CORS
48    #[serde(default = "default_enable_cors")]
49    pub enable_cors: bool,
50
51    /// Enable request tracing
52    #[serde(default = "default_enable_tracing")]
53    pub enable_tracing: bool,
54}
55
56/// Cache configuration
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CacheConfig {
59    /// Enable caching
60    #[serde(default = "default_cache_enabled")]
61    pub enabled: bool,
62
63    /// TTL for cache entries in seconds
64    #[serde(default = "default_cache_ttl")]
65    pub ttl_seconds: u64,
66
67    /// Maximum number of cache entries
68    #[serde(default = "default_cache_max_entries")]
69    pub max_entries: usize,
70}
71
72/// Logging configuration
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct LoggingConfig {
75    /// Log level (trace, debug, info, warn, error)
76    #[serde(default = "default_log_level")]
77    pub level: String,
78
79    /// Log format (json, pretty, compact)
80    #[serde(default = "default_log_format")]
81    pub format: String,
82
83    /// Optional log file path
84    pub file: Option<String>,
85
86    /// Enable structured logging
87    #[serde(default = "default_structured_logging")]
88    pub structured: bool,
89}
90
91/// Rate limiting configuration
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct RateLimitConfig {
94    /// Requests per minute limit
95    #[serde(default = "default_requests_per_minute")]
96    pub requests_per_minute: u32,
97
98    /// Burst size for rate limiting
99    #[serde(default = "default_burst_size")]
100    pub burst_size: u32,
101
102    /// Enable rate limiting
103    #[serde(default = "default_rate_limiting_enabled")]
104    pub enabled: bool,
105}
106
107/// Crates.io API configuration
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CratesIoConfig {
110    /// API base URL
111    #[serde(default = "default_api_url")]
112    pub api_url: String,
113
114    /// User agent for requests
115    #[serde(default = "default_user_agent")]
116    pub user_agent: String,
117
118    /// Request timeout in seconds
119    #[serde(default = "default_api_timeout")]
120    pub timeout_seconds: u64,
121
122    /// Maximum concurrent requests
123    #[serde(default = "default_max_concurrent")]
124    pub max_concurrent: usize,
125
126    /// Retry attempts for failed requests
127    #[serde(default = "default_retry_attempts")]
128    pub retry_attempts: u32,
129}
130
131// Default value functions
132fn default_port() -> u16 {
133    DEFAULT_SERVER_PORT
134}
135fn default_host() -> String {
136    "0.0.0.0".to_string()
137}
138fn default_workers() -> usize {
139    num_cpus::get()
140}
141fn default_request_timeout() -> u64 {
142    30
143}
144fn default_enable_cors() -> bool {
145    true
146}
147fn default_enable_tracing() -> bool {
148    true
149}
150
151fn default_cache_enabled() -> bool {
152    true
153}
154fn default_cache_ttl() -> u64 {
155    300
156}
157fn default_cache_max_entries() -> usize {
158    1000
159}
160
161fn default_log_level() -> String {
162    "info".to_string()
163}
164fn default_log_format() -> String {
165    "pretty".to_string()
166}
167fn default_structured_logging() -> bool {
168    false
169}
170
171fn default_requests_per_minute() -> u32 {
172    100
173}
174fn default_burst_size() -> u32 {
175    20
176}
177fn default_rate_limiting_enabled() -> bool {
178    false
179}
180
181fn default_api_url() -> String {
182    DEFAULT_API_URL.to_string()
183}
184fn default_user_agent() -> String {
185    DEFAULT_USER_AGENT.to_string()
186}
187fn default_api_timeout() -> u64 {
188    DEFAULT_TIMEOUT_SECS
189}
190fn default_max_concurrent() -> usize {
191    10
192}
193fn default_retry_attempts() -> u32 {
194    3
195}
196
197impl Default for AppConfig {
198    fn default() -> Self {
199        Self {
200            server: ServerConfig::default(),
201            cache: CacheConfig::default(),
202            logging: LoggingConfig::default(),
203            rate_limiting: RateLimitConfig::default(),
204            crates_io: CratesIoConfig::default(),
205        }
206    }
207}
208
209impl Default for ServerConfig {
210    fn default() -> Self {
211        Self {
212            port: default_port(),
213            host: default_host(),
214            workers: default_workers(),
215            request_timeout: default_request_timeout(),
216            enable_cors: default_enable_cors(),
217            enable_tracing: default_enable_tracing(),
218        }
219    }
220}
221
222impl Default for CacheConfig {
223    fn default() -> Self {
224        Self {
225            enabled: default_cache_enabled(),
226            ttl_seconds: default_cache_ttl(),
227            max_entries: default_cache_max_entries(),
228        }
229    }
230}
231
232impl Default for LoggingConfig {
233    fn default() -> Self {
234        Self {
235            level: default_log_level(),
236            format: default_log_format(),
237            file: None,
238            structured: default_structured_logging(),
239        }
240    }
241}
242
243impl Default for RateLimitConfig {
244    fn default() -> Self {
245        Self {
246            requests_per_minute: default_requests_per_minute(),
247            burst_size: default_burst_size(),
248            enabled: default_rate_limiting_enabled(),
249        }
250    }
251}
252
253impl Default for CratesIoConfig {
254    fn default() -> Self {
255        Self {
256            api_url: default_api_url(),
257            user_agent: default_user_agent(),
258            timeout_seconds: default_api_timeout(),
259            max_concurrent: default_max_concurrent(),
260            retry_attempts: default_retry_attempts(),
261        }
262    }
263}
264
265impl AppConfig {
266    /// Load configuration from file, environment, and defaults
267    pub fn load() -> Result<Self, ConfigError> {
268        Self::load_from_file(None::<std::path::PathBuf>)
269    }
270
271    /// Load configuration from a specific file
272    pub fn load_from_file<P: AsRef<Path>>(config_file: Option<P>) -> Result<Self, ConfigError> {
273        let mut builder = Config::builder();
274
275        // Start with defaults
276        builder = builder.add_source(Config::try_from(&AppConfig::default())?);
277
278        // Add config file if provided
279        if let Some(path) = config_file {
280            let path = path.as_ref();
281            if path.exists() {
282                info!("Loading configuration from: {}", path.display());
283                builder = builder.add_source(File::from(path));
284            }
285        }
286
287        // Add environment variables with CRATE_CHECKER prefix
288        builder = builder.add_source(
289            Environment::with_prefix("CRATE_CHECKER")
290                .separator("__")
291                .try_parsing(true),
292        );
293
294        builder.build()?.try_deserialize()
295    }
296
297    /// Validate the configuration
298    pub fn validate(&self) -> Result<(), String> {
299        if self.server.port == 0 {
300            return Err("Server port cannot be 0".to_string());
301        }
302
303        if self.server.workers == 0 {
304            return Err("Server workers cannot be 0".to_string());
305        }
306
307        if self.server.request_timeout == 0 {
308            return Err("Request timeout cannot be 0".to_string());
309        }
310
311        if self.cache.enabled && self.cache.max_entries == 0 {
312            return Err("Cache max entries cannot be 0 when caching is enabled".to_string());
313        }
314
315        if !["trace", "debug", "info", "warn", "error"].contains(&self.logging.level.as_str()) {
316            return Err(format!("Invalid log level: {}", self.logging.level));
317        }
318
319        if !["json", "pretty", "compact"].contains(&self.logging.format.as_str()) {
320            return Err(format!("Invalid log format: {}", self.logging.format));
321        }
322
323        if self.crates_io.timeout_seconds == 0 {
324            return Err("API timeout cannot be 0".to_string());
325        }
326
327        if self.crates_io.max_concurrent == 0 {
328            return Err("Max concurrent requests cannot be 0".to_string());
329        }
330
331        Ok(())
332    }
333
334    /// Create a sample configuration file
335    pub fn create_sample_config() -> String {
336        toml::to_string_pretty(&AppConfig::default())
337            .unwrap_or_else(|_| "# Failed to generate sample config".to_string())
338    }
339
340    /// Get the bind address for the server
341    pub fn bind_address(&self) -> String {
342        format!("{}:{}", self.server.host, self.server.port)
343    }
344}
345
346/// Environment-specific configuration overrides
347#[derive(Debug, Clone)]
348pub struct EnvironmentConfig {
349    pub is_development: bool,
350    pub is_production: bool,
351    pub is_test: bool,
352}
353
354impl EnvironmentConfig {
355    pub fn detect() -> Self {
356        let env = std::env::var("RUST_ENV")
357            .or_else(|_| std::env::var("ENVIRONMENT"))
358            .unwrap_or_else(|_| "development".to_string())
359            .to_lowercase();
360
361        Self {
362            is_development: env == "development" || env == "dev",
363            is_production: env == "production" || env == "prod",
364            is_test: env == "test" || env == "testing",
365        }
366    }
367
368    /// Apply environment-specific overrides to the configuration
369    pub fn apply_overrides(&self, config: &mut AppConfig) {
370        if self.is_development {
371            config.logging.level = "debug".to_string();
372            config.cache.enabled = false;
373            config.rate_limiting.enabled = false;
374        } else if self.is_production {
375            config.logging.level = "info".to_string();
376            config.logging.structured = true;
377            config.cache.enabled = true;
378            config.rate_limiting.enabled = true;
379        } else if self.is_test {
380            config.logging.level = "warn".to_string();
381            config.cache.enabled = false;
382            config.rate_limiting.enabled = false;
383        }
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use std::io::Write;
391    use tempfile::NamedTempFile;
392
393    #[test]
394    fn test_default_config() {
395        let config = AppConfig::default();
396        assert_eq!(config.server.port, DEFAULT_SERVER_PORT);
397        assert_eq!(config.crates_io.api_url, DEFAULT_API_URL);
398        assert!(config.validate().is_ok());
399    }
400
401    #[test]
402    fn test_load_from_file() {
403        let mut temp_file = NamedTempFile::new().unwrap();
404        let temp_path = temp_file.path().with_extension("toml");
405
406        writeln!(
407            temp_file,
408            r#"
409[server]
410port = 8080
411host = "127.0.0.1"
412
413[logging]
414level = "debug"
415"#
416        )
417        .unwrap();
418
419        // Copy the temp file to a .toml file
420        std::fs::copy(temp_file.path(), &temp_path).unwrap();
421
422        let config = AppConfig::load_from_file(Some(&temp_path)).unwrap();
423        assert_eq!(config.server.port, 8080);
424        assert_eq!(config.server.host, "127.0.0.1");
425        assert_eq!(config.logging.level, "debug");
426
427        // Clean up
428        std::fs::remove_file(&temp_path).ok();
429    }
430
431    #[test]
432    fn test_environment_overrides() {
433        let env_config = EnvironmentConfig {
434            is_development: true,
435            is_production: false,
436            is_test: false,
437        };
438
439        let mut config = AppConfig::default();
440        env_config.apply_overrides(&mut config);
441
442        assert_eq!(config.logging.level, "debug");
443        assert!(!config.cache.enabled);
444        assert!(!config.rate_limiting.enabled);
445    }
446
447    #[test]
448    fn test_bind_address() {
449        let config = AppConfig::default();
450        assert!(config
451            .bind_address()
452            .contains(&config.server.port.to_string()));
453    }
454
455    #[test]
456    fn test_create_sample_config() {
457        let sample = AppConfig::create_sample_config();
458        assert!(sample.contains("[server]"));
459        assert!(sample.contains("[logging]"));
460        assert!(sample.contains("[cache]"));
461    }
462}