Skip to main content

crates_docs/config/
mod.rs

1//! Configuration module
2//!
3//! Provides application configuration management, supports loading from files, environment variables, and default values.
4//!
5//! # Configuration Source Priority
6//!
7//! 1. Environment variables (highest priority)
8//! 2. Configuration file
9//! 3. Default values (lowest priority)
10//!
11//! # Supported Configuration Formats
12//!
13//! - TOML configuration file
14//! - Environment variables (prefix `CRATES_DOCS_`)
15//!
16//! # Examples
17//!
18//! ```rust,no_run
19//! use crates_docs::config::AppConfig;
20//!
21//! // Load configuration from file
22//! let config = AppConfig::from_file("config.toml").expect("Failed to load config");
23//!
24//! // Load configuration from environment variables
25//! let config = AppConfig::from_env().expect("Failed to load config from env");
26//!
27//! // Use default configuration
28//! let config = AppConfig::default();
29//! ```
30
31use crate::cache::CacheConfig;
32use crate::server::auth::{AuthConfig, OAuthConfig};
33use rust_mcp_sdk::schema::{Icon, IconTheme};
34use serde::{Deserialize, Serialize};
35use std::fs;
36use std::path::Path;
37
38/// Application configuration
39///
40/// Contains server, cache, authentication, logging, and performance configuration.
41///
42/// # Fields
43///
44/// - `server`: Server configuration
45/// - `cache`: Cache configuration
46/// - `auth`: Authentication configuration (OAuth and API Key)
47/// - `logging`: Logging configuration
48/// - `performance`: Performance configuration
49///
50/// # Hot Reload Support
51///
52/// The following configuration items support hot reload (runtime update without restart):
53/// - `logging` section: All fields
54/// - `auth` section: All fields (including API Key and OAuth)
55/// - `cache` section: TTL-related fields (`default_ttl`, `crate_docs_ttl_secs`, `item_docs_ttl_secs`, `search_results_ttl_secs`)
56/// - `performance` section: `rate_limit_per_second`, `concurrent_request_limit`, `enable_metrics`, `enable_response_compression`
57///
58/// The following configuration items **do not** support hot reload (require server restart):
59/// - `server` section: All fields (host, port, `transport_mode`, `max_connections`, etc.)
60/// - `cache` section: `cache_type`, `memory_size`, `redis_url` (cache initialization parameters)
61/// - `performance` section: `http_client_*`, `cache_max_size`, `cache_default_ttl_secs`, `metrics_port`
62#[derive(Debug, Clone, Deserialize, Serialize, Default)]
63pub struct AppConfig {
64    /// Server configuration
65    pub server: ServerConfig,
66
67    /// Cache configuration
68    pub cache: CacheConfig,
69
70    /// Authentication configuration (OAuth and API Key)
71    #[serde(default)]
72    pub auth: AuthConfig,
73
74    /// OAuth configuration (backwards compatible, prefer using auth.oauth)
75    #[serde(default)]
76    pub oauth: OAuthConfig,
77
78    /// Logging configuration
79    pub logging: LoggingConfig,
80
81    /// Performance configuration
82    pub performance: PerformanceConfig,
83}
84
85/// Server configuration
86///
87/// # Hot Reload Support
88///
89/// ⚠️ **Does not support hot reload** - Server configuration changes require server restart to take effect.
90///
91/// Reason: These configurations involve server listening socket, transport layer initialization and other core parameters,
92/// runtime changes may cause connection interruption or state inconsistency.
93#[derive(Debug, Clone, Deserialize, Serialize)]
94pub struct ServerConfig {
95    /// Server name
96    pub name: String,
97
98    /// Server version
99    #[serde(default = "default_version")]
100    pub version: String,
101
102    /// Server description
103    pub description: Option<String>,
104
105    /// Server icons
106    #[serde(default = "default_icons")]
107    pub icons: Vec<Icon>,
108
109    /// Website URL
110    pub website_url: Option<String>,
111
112    /// Host address
113    pub host: String,
114
115    /// Port
116    pub port: u16,
117
118    /// Transport mode
119    pub transport_mode: String,
120
121    /// Enable SSE support
122    pub enable_sse: bool,
123
124    /// Enable OAuth authentication
125    pub enable_oauth: bool,
126
127    /// Maximum concurrent connections
128    pub max_connections: usize,
129
130    /// Request timeout (seconds)
131    pub request_timeout_secs: u64,
132
133    /// Response timeout (seconds)
134    pub response_timeout_secs: u64,
135
136    /// Allowed hosts for CORS (e.g., `["localhost", "127.0.0.1"]`)
137    pub allowed_hosts: Vec<String>,
138
139    /// Allowed origins for CORS (e.g., `["http://localhost:*"]`)
140    /// Use `"*"` only in development, specify exact origins in production
141    pub allowed_origins: Vec<String>,
142}
143
144/// Default server version from Cargo.toml
145fn default_version() -> String {
146    crate::VERSION.to_string()
147}
148
149/// Default icons for the server
150fn default_icons() -> Vec<Icon> {
151    vec![
152        Icon {
153            src: "https://docs.rs/static/favicon-32x32.png".to_string(),
154            mime_type: Some("image/png".to_string()),
155            sizes: vec!["32x32".to_string()],
156            theme: Some(IconTheme::Light),
157        },
158        Icon {
159            src: "https://docs.rs/static/favicon-32x32.png".to_string(),
160            mime_type: Some("image/png".to_string()),
161            sizes: vec!["32x32".to_string()],
162            theme: Some(IconTheme::Dark),
163        },
164    ]
165}
166
167/// Logging configuration
168///
169/// # Hot Reload Support
170///
171/// ✅ **Supports hot reload** - All logging configuration items can be dynamically updated at runtime.
172///
173/// Hot reload supported fields:
174/// - `level`: Log level (trace/debug/info/warn/error)
175/// - `file_path`: Log file path
176/// - `enable_console`: Console logging toggle
177/// - `enable_file`: File logging toggle
178/// - `max_file_size_mb`: Maximum log file size
179/// - `max_files`: Number of log files to retain
180///
181/// Note: After file logging path changes, new logs will be written to the new file, but old file handles will not be automatically closed.
182#[derive(Debug, Clone, Deserialize, Serialize)]
183pub struct LoggingConfig {
184    /// Log level
185    pub level: String,
186
187    /// Log file path
188    pub file_path: Option<String>,
189
190    /// Whether to enable console logging
191    pub enable_console: bool,
192
193    /// Whether to enable file logging
194    pub enable_file: bool,
195
196    /// Maximum log file size (MB)
197    pub max_file_size_mb: u64,
198
199    /// Number of log files to retain
200    pub max_files: usize,
201}
202
203/// Performance configuration
204///
205/// # Hot Reload Support
206///
207/// ## Hot reload supported fields ✅
208///
209/// The following fields can be dynamically updated at runtime:
210/// - `rate_limit_per_second`: Request rate limit (requests per second)
211/// - `concurrent_request_limit`: Concurrent request limit
212/// - `enable_metrics`: Prometheus metrics collection toggle
213/// - `enable_response_compression`: Response compression toggle
214///
215/// ## Hot reload not supported fields ❌
216///
217/// The following fields require server restart to take effect:
218/// - `http_client_*`: HTTP client configuration (pool size, timeouts, etc.)
219/// - `cache_max_size`: Cache maximum size
220/// - `cache_default_ttl_secs`: Cache default TTL
221/// - `metrics_port`: Metrics server port
222///
223/// Reason: These configurations involve underlying connection pool, cache instance initialization parameters.
224#[derive(Debug, Clone, Deserialize, Serialize)]
225pub struct PerformanceConfig {
226    /// HTTP client connection pool size
227    pub http_client_pool_size: usize,
228
229    /// HTTP client pool idle timeout (seconds)
230    pub http_client_pool_idle_timeout_secs: u64,
231
232    /// HTTP client connection timeout (seconds)
233    pub http_client_connect_timeout_secs: u64,
234
235    /// HTTP client request timeout (seconds)
236    pub http_client_timeout_secs: u64,
237
238    /// HTTP client read timeout (seconds)
239    pub http_client_read_timeout_secs: u64,
240
241    /// HTTP client max retry attempts
242    pub http_client_max_retries: u32,
243
244    /// HTTP client retry initial delay (milliseconds)
245    pub http_client_retry_initial_delay_ms: u64,
246
247    /// HTTP client retry max delay (milliseconds)
248    pub http_client_retry_max_delay_ms: u64,
249
250    /// Maximum cache size (number of entries)
251    pub cache_max_size: usize,
252
253    /// Default cache TTL (seconds)
254    pub cache_default_ttl_secs: u64,
255
256    /// Request rate limit (requests per second)
257    pub rate_limit_per_second: u32,
258
259    /// Concurrent request limit
260    pub concurrent_request_limit: usize,
261
262    /// Enable response compression
263    pub enable_response_compression: bool,
264
265    /// Enable Prometheus metrics
266    pub enable_metrics: bool,
267
268    /// Metrics endpoint port (0 = use server port)
269    pub metrics_port: u16,
270}
271
272impl Default for ServerConfig {
273    fn default() -> Self {
274        Self {
275            name: "crates-docs".to_string(),
276            version: crate::VERSION.to_string(),
277            description: Some(
278                "High-performance Rust crate documentation query MCP server".to_string(),
279            ),
280            icons: default_icons(),
281            website_url: Some("https://github.com/KingingWang/crates-docs".to_string()),
282            host: "127.0.0.1".to_string(),
283            port: 8080,
284            transport_mode: "hybrid".to_string(),
285            enable_sse: true,
286            enable_oauth: false,
287            max_connections: 100,
288            request_timeout_secs: 30,
289            response_timeout_secs: 60,
290            // Secure defaults: only allow localhost by default
291            allowed_hosts: vec!["localhost".to_string(), "127.0.0.1".to_string()],
292            allowed_origins: vec!["http://localhost:*".to_string()],
293        }
294    }
295}
296
297impl Default for LoggingConfig {
298    fn default() -> Self {
299        Self {
300            level: "info".to_string(),
301            file_path: Some("./logs/crates-docs.log".to_string()),
302            enable_console: true,
303            enable_file: false, // Default: console output only
304            max_file_size_mb: 100,
305            max_files: 10,
306        }
307    }
308}
309
310impl Default for PerformanceConfig {
311    fn default() -> Self {
312        Self {
313            http_client_pool_size: 10,
314            http_client_pool_idle_timeout_secs: 90,
315            http_client_connect_timeout_secs: 10,
316            http_client_timeout_secs: 30,
317            http_client_read_timeout_secs: 30,
318            http_client_max_retries: 3,
319            http_client_retry_initial_delay_ms: 100,
320            http_client_retry_max_delay_ms: 10000,
321            cache_max_size: 1000,
322            cache_default_ttl_secs: 3600,
323            rate_limit_per_second: 100,
324            concurrent_request_limit: 50,
325            enable_response_compression: true,
326            enable_metrics: true,
327            metrics_port: 0,
328        }
329    }
330}
331
332impl AppConfig {
333    /// Load configuration from file
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if file does not exist, cannot be read, or format is invalid
338    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, crate::error::Error> {
339        let content = fs::read_to_string(path).map_err(|e| {
340            crate::error::Error::config("file", format!("Failed to read config file: {e}"))
341        })?;
342
343        let config: Self = toml::from_str(&content).map_err(|e| {
344            crate::error::Error::parse("config", None, format!("Failed to parse config file: {e}"))
345        })?;
346
347        config.validate()?;
348        Ok(config)
349    }
350
351    /// Save configuration to file
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if configuration cannot be serialized, directory cannot be created, or file cannot be written
356    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), crate::error::Error> {
357        let content = toml::to_string_pretty(self).map_err(|e| {
358            crate::error::Error::config(
359                "serialization",
360                format!("Failed to serialize configuration: {e}"),
361            )
362        })?;
363
364        // Ensure directory exists
365        if let Some(parent) = path.as_ref().parent() {
366            fs::create_dir_all(parent).map_err(|e| {
367                crate::error::Error::config("directory", format!("Failed to create directory: {e}"))
368            })?;
369        }
370
371        fs::write(path, content).map_err(|e| {
372            crate::error::Error::config("file", format!("Failed to write config file: {e}"))
373        })?;
374
375        Ok(())
376    }
377
378    /// Validate configuration
379    ///
380    /// # Errors
381    ///
382    /// Returns an error if configuration is invalid (e.g., empty hostname, invalid port, etc.)
383    pub fn validate(&self) -> Result<(), crate::error::Error> {
384        // Validate server configuration
385        if self.server.host.is_empty() {
386            return Err(crate::error::Error::config("host", "cannot be empty"));
387        }
388
389        if self.server.port == 0 {
390            return Err(crate::error::Error::config("port", "cannot be 0"));
391        }
392
393        if self.server.max_connections == 0 {
394            return Err(crate::error::Error::config(
395                "max_connections",
396                "cannot be 0",
397            ));
398        }
399
400        // Validate transport mode
401        let valid_modes = ["stdio", "http", "sse", "hybrid"];
402        if !valid_modes.contains(&self.server.transport_mode.as_str()) {
403            return Err(crate::error::Error::config(
404                "transport_mode",
405                format!(
406                    "Invalid transport mode: {}, valid values: {:?}",
407                    self.server.transport_mode, valid_modes
408                ),
409            ));
410        }
411
412        // Validate log level
413        let valid_levels = ["trace", "debug", "info", "warn", "error"];
414
415        if !valid_levels.contains(&self.logging.level.as_str()) {
416            return Err(crate::error::Error::config(
417                "log_level",
418                format!(
419                    "Invalid log level: {}, valid values: {:?}",
420                    self.logging.level, valid_levels
421                ),
422            ));
423        }
424
425        // Validate performance configuration
426        if self.performance.http_client_pool_size == 0 {
427            return Err(crate::error::Error::config(
428                "http_client_pool_size",
429                "cannot be 0",
430            ));
431        }
432
433        if self.performance.http_client_pool_idle_timeout_secs == 0 {
434            return Err(crate::error::Error::config(
435                "http_client_pool_idle_timeout_secs",
436                "cannot be 0",
437            ));
438        }
439
440        if self.performance.http_client_connect_timeout_secs == 0 {
441            return Err(crate::error::Error::config(
442                "http_client_connect_timeout_secs",
443                "cannot be 0",
444            ));
445        }
446
447        if self.performance.http_client_timeout_secs == 0 {
448            return Err(crate::error::Error::config(
449                "http_client_timeout_secs",
450                "cannot be 0",
451            ));
452        }
453
454        if self.performance.cache_max_size == 0 {
455            return Err(crate::error::Error::config("cache_max_size", "cannot be 0"));
456        }
457
458        // Validate OAuth configuration
459        if self.server.enable_oauth {
460            self.oauth.validate()?;
461        }
462
463        Ok(())
464    }
465
466    /// Load configuration from environment variables
467    ///
468    /// # Errors
469    ///
470    /// Returns an error if environment variable format is invalid or configuration validation fails
471    pub fn from_env() -> Result<Self, crate::error::Error> {
472        let mut config = Self::default();
473
474        // Override configuration from environment variables
475        if let Ok(name) = std::env::var("CRATES_DOCS_NAME") {
476            config.server.name = name;
477        }
478
479        if let Ok(host) = std::env::var("CRATES_DOCS_HOST") {
480            config.server.host = host;
481        }
482
483        if let Ok(port) = std::env::var("CRATES_DOCS_PORT") {
484            config.server.port = port
485                .parse()
486                .map_err(|e| crate::error::Error::config("port", format!("Invalid port: {e}")))?;
487        }
488
489        if let Ok(mode) = std::env::var("CRATES_DOCS_TRANSPORT_MODE") {
490            config.server.transport_mode = mode;
491        }
492
493        if let Ok(level) = std::env::var("CRATES_DOCS_LOG_LEVEL") {
494            config.logging.level = level;
495        }
496
497        if let Ok(enable_console) = std::env::var("CRATES_DOCS_ENABLE_CONSOLE") {
498            config.logging.enable_console = enable_console.parse().unwrap_or(false);
499        }
500
501        if let Ok(enable_file) = std::env::var("CRATES_DOCS_ENABLE_FILE") {
502            config.logging.enable_file = enable_file.parse().unwrap_or(false);
503        }
504
505        #[cfg(feature = "api-key")]
506        {
507            if let Ok(enabled) = std::env::var("CRATES_DOCS_API_KEY_ENABLED") {
508                config.auth.api_key.enabled = enabled.parse().unwrap_or(false);
509            }
510
511            if let Ok(keys) = std::env::var("CRATES_DOCS_API_KEYS") {
512                config.auth.api_key.keys = keys
513                    .split(',')
514                    .map(str::trim)
515                    .filter(|s| !s.is_empty())
516                    .map(ToOwned::to_owned)
517                    .collect();
518            }
519
520            if let Ok(header_name) = std::env::var("CRATES_DOCS_API_KEY_HEADER") {
521                config.auth.api_key.header_name = header_name;
522            }
523
524            if let Ok(query_param_name) = std::env::var("CRATES_DOCS_API_KEY_QUERY_PARAM_NAME") {
525                config.auth.api_key.query_param_name = query_param_name;
526            }
527
528            if let Ok(allow_query_param) = std::env::var("CRATES_DOCS_API_KEY_ALLOW_QUERY") {
529                config.auth.api_key.allow_query_param = allow_query_param.parse().unwrap_or(false);
530            }
531
532            if let Ok(key_prefix) = std::env::var("CRATES_DOCS_API_KEY_PREFIX") {
533                config.auth.api_key.key_prefix = key_prefix;
534            }
535        }
536
537        config.validate()?;
538        Ok(config)
539    }
540
541    /// Merge configuration (environment variables take precedence over file configuration)
542    #[must_use]
543    pub fn merge(file_config: Option<Self>, env_config: Option<Self>) -> Self {
544        let mut config = Self::default();
545
546        // First apply file configuration
547        if let Some(file) = file_config {
548            config = file;
549        }
550
551        // Then apply environment variable configuration (overrides file configuration)
552        if let Some(env) = env_config {
553            // Merge server configuration
554            if env.server.name != "crates-docs" {
555                config.server.name = env.server.name;
556            }
557            if env.server.host != "127.0.0.1" {
558                config.server.host = env.server.host;
559            }
560            if env.server.port != 8080 {
561                config.server.port = env.server.port;
562            }
563            if env.server.transport_mode != "hybrid" {
564                config.server.transport_mode = env.server.transport_mode;
565            }
566
567            // Merge logging configuration
568            if env.logging.level != "info" {
569                config.logging.level = env.logging.level;
570            }
571
572            #[cfg(feature = "api-key")]
573            {
574                let default_api_key = crate::server::auth::ApiKeyConfig::default();
575
576                if env.auth.api_key.enabled != default_api_key.enabled {
577                    config.auth.api_key.enabled = env.auth.api_key.enabled;
578                }
579
580                if env.auth.api_key.keys != default_api_key.keys {
581                    config.auth.api_key.keys = env.auth.api_key.keys;
582                }
583
584                if env.auth.api_key.header_name != default_api_key.header_name {
585                    config.auth.api_key.header_name = env.auth.api_key.header_name;
586                }
587
588                if env.auth.api_key.query_param_name != default_api_key.query_param_name {
589                    config.auth.api_key.query_param_name = env.auth.api_key.query_param_name;
590                }
591
592                if env.auth.api_key.allow_query_param != default_api_key.allow_query_param {
593                    config.auth.api_key.allow_query_param = env.auth.api_key.allow_query_param;
594                }
595
596                if env.auth.api_key.key_prefix != default_api_key.key_prefix {
597                    config.auth.api_key.key_prefix = env.auth.api_key.key_prefix;
598                }
599            }
600        }
601
602        config
603    }
604}