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// HTTP Client defaults
39
40/// Default HTTP client connection pool size (10 connections)
41const DEFAULT_HTTP_CLIENT_POOL_SIZE: usize = 10;
42/// Default HTTP client pool idle timeout in seconds (90 seconds)
43const DEFAULT_HTTP_CLIENT_POOL_IDLE_TIMEOUT_SECS: u64 = 90;
44/// Default HTTP client connection timeout in seconds (10 seconds)
45const DEFAULT_HTTP_CLIENT_CONNECT_TIMEOUT_SECS: u64 = 10;
46/// Default HTTP client request timeout in seconds (30 seconds)
47const DEFAULT_HTTP_CLIENT_TIMEOUT_SECS: u64 = 30;
48/// Default HTTP client read timeout in seconds (30 seconds)
49const DEFAULT_HTTP_CLIENT_READ_TIMEOUT_SECS: u64 = 30;
50/// Default HTTP client max retry attempts (3 retries)
51const DEFAULT_HTTP_CLIENT_MAX_RETRIES: u32 = 3;
52/// Default HTTP client retry initial delay in milliseconds (100ms)
53const DEFAULT_HTTP_CLIENT_RETRY_INITIAL_DELAY_MS: u64 = 100;
54/// Default HTTP client retry max delay in milliseconds (10 seconds)
55const DEFAULT_HTTP_CLIENT_RETRY_MAX_DELAY_MS: u64 = 10_000;
56
57// Server defaults
58
59/// Default server port (8080)
60const DEFAULT_SERVER_PORT: u16 = 8080;
61/// Default server max concurrent connections (100 connections)
62const DEFAULT_SERVER_MAX_CONNECTIONS: usize = 100;
63/// Default request timeout in seconds (30 seconds)
64const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
65/// Default response timeout in seconds (60 seconds)
66const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 60;
67
68// Cache/Rate limit defaults
69
70/// Default cache max size in number of entries (1000 entries)
71const DEFAULT_CACHE_MAX_SIZE: usize = 1000;
72/// Default cache TTL in seconds (1 hour = 3600 seconds)
73const DEFAULT_CACHE_DEFAULT_TTL_SECS: u64 = 3600;
74/// Default rate limit per second (100 requests)
75const DEFAULT_RATE_LIMIT_PER_SECOND: u32 = 100;
76/// Default concurrent request limit (50 requests)
77const DEFAULT_CONCURRENT_REQUEST_LIMIT: usize = 50;
78
79// File upload defaults
80
81/// Default max log file size in MB (100 MB)
82const DEFAULT_MAX_FILE_SIZE_MB: u64 = 100;
83/// Default number of log files to retain (10 files)
84const DEFAULT_MAX_FILES: usize = 10;
85
86/// Application configuration
87///
88/// Contains server, cache, authentication, logging, and performance configuration.
89///
90/// # Fields
91///
92/// - `server`: Server configuration
93/// - `cache`: Cache configuration
94/// - `auth`: Authentication configuration (OAuth and API Key)
95/// - `logging`: Logging configuration
96/// - `performance`: Performance configuration
97///
98/// # Hot Reload Support
99///
100/// The following configuration items support hot reload (runtime update without restart):
101/// - `logging` section: All fields
102/// - `auth` section: All fields (including API Key and OAuth)
103/// - `cache` section: TTL-related fields (`default_ttl`, `crate_docs_ttl_secs`, `item_docs_ttl_secs`, `search_results_ttl_secs`)
104/// - `performance` section: `rate_limit_per_second`, `concurrent_request_limit`, `enable_metrics`, `enable_response_compression`
105///
106/// The following configuration items **do not** support hot reload (require server restart):
107/// - `server` section: All fields (host, port, `transport_mode`, `max_connections`, etc.)
108/// - `cache` section: `cache_type`, `memory_size`, `redis_url` (cache initialization parameters)
109/// - `performance` section: `http_client_*`, `cache_max_size`, `cache_default_ttl_secs`, `metrics_port`
110#[derive(Debug, Clone, Deserialize, Serialize, Default)]
111pub struct AppConfig {
112    /// Server configuration
113    #[serde(default)]
114    pub server: ServerConfig,
115
116    /// Cache configuration
117    #[serde(default)]
118    pub cache: CacheConfig,
119
120    /// Authentication configuration (OAuth and API Key)
121    #[serde(default)]
122    pub auth: AuthConfig,
123
124    /// OAuth configuration (backwards compatible, prefer using auth.oauth)
125    #[serde(default)]
126    pub oauth: OAuthConfig,
127
128    /// Logging configuration
129    #[serde(default)]
130    pub logging: LoggingConfig,
131
132    /// Performance configuration
133    #[serde(default)]
134    pub performance: PerformanceConfig,
135}
136
137/// Server configuration
138///
139/// # Hot Reload Support
140///
141/// ⚠️ **Does not support hot reload** - Server configuration changes require server restart to take effect.
142///
143/// Reason: These configurations involve server listening socket, transport layer initialization and other core parameters,
144/// runtime changes may cause connection interruption or state inconsistency.
145#[derive(Debug, Clone, Deserialize, Serialize)]
146pub struct ServerConfig {
147    /// Server name
148    #[serde(default = "default_server_name")]
149    pub name: String,
150
151    /// Server version
152    #[serde(default = "default_version")]
153    pub version: String,
154
155    /// Server description
156    #[serde(default = "default_server_description")]
157    pub description: Option<String>,
158
159    /// Server icons
160    #[serde(default = "default_icons")]
161    pub icons: Vec<Icon>,
162
163    /// Website URL
164    #[serde(default = "default_server_website_url")]
165    pub website_url: Option<String>,
166
167    /// Host address
168    #[serde(default = "default_server_host")]
169    pub host: String,
170
171    /// Port
172    #[serde(default = "default_server_port")]
173    pub port: u16,
174
175    /// Transport mode
176    #[serde(default = "default_server_transport_mode")]
177    pub transport_mode: String,
178
179    /// Enable SSE support
180    #[serde(default = "default_server_enable_sse")]
181    pub enable_sse: bool,
182
183    /// Enable OAuth authentication
184    #[serde(default = "default_server_enable_oauth")]
185    pub enable_oauth: bool,
186
187    /// Maximum concurrent connections
188    #[serde(default = "default_server_max_connections")]
189    pub max_connections: usize,
190
191    /// Request timeout (seconds)
192    #[serde(default = "default_server_request_timeout_secs")]
193    pub request_timeout_secs: u64,
194
195    /// Response timeout (seconds)
196    #[serde(default = "default_server_response_timeout_secs")]
197    pub response_timeout_secs: u64,
198
199    /// Allowed `Host` header values for DNS-rebinding protection.
200    ///
201    /// Only enforced when `dns_rebinding_protection` is `true`. Matching is
202    /// exact and case-insensitive (no wildcards); entries must include the
203    /// port clients connect to, e.g. `["127.0.0.1:8080", "localhost:8080"]`.
204    /// Has no effect while `dns_rebinding_protection` is `false`.
205    #[serde(default = "default_server_allowed_hosts")]
206    pub allowed_hosts: Vec<String>,
207
208    /// Allowed `Origin` header values for DNS-rebinding protection.
209    ///
210    /// Only enforced when `dns_rebinding_protection` is `true`. Matching is
211    /// exact and case-insensitive (no wildcards); list full origins including
212    /// scheme and port, e.g. `["http://localhost:8080"]`. The `*` wildcard is
213    /// NOT supported. Has no effect while `dns_rebinding_protection` is
214    /// `false`.
215    #[serde(default = "default_server_allowed_origins")]
216    pub allowed_origins: Vec<String>,
217
218    /// Enable DNS rebinding protection for HTTP/SSE transports.
219    ///
220    /// When `true`, the server validates the `Host` and `Origin` request
221    /// headers against `allowed_hosts` / `allowed_origins` and rejects
222    /// mismatches with HTTP 403. Defaults to `false` for backwards
223    /// compatibility (matching the underlying SDK).
224    ///
225    /// Note: matching is exact and case-insensitive (no wildcards). When
226    /// enabling this, `allowed_hosts` must contain the exact `host:port`
227    /// values clients send (e.g. `"127.0.0.1:8080"`), and `allowed_origins`
228    /// must list exact origins (e.g. `"http://localhost:8080"`); the example
229    /// defaults with a `*` wildcard will not match.
230    #[serde(default = "default_server_dns_rebinding_protection")]
231    pub dns_rebinding_protection: bool,
232}
233
234/// Default server version from Cargo.toml
235fn default_version() -> String {
236    crate::VERSION.to_string()
237}
238
239/// Default icons for the server
240fn default_icons() -> Vec<Icon> {
241    vec![
242        Icon {
243            src: "https://docs.rs/static/favicon-32x32.png".to_string(),
244            mime_type: Some("image/png".to_string()),
245            sizes: vec!["32x32".to_string()],
246            theme: Some(IconTheme::Light),
247        },
248        Icon {
249            src: "https://docs.rs/static/favicon-32x32.png".to_string(),
250            mime_type: Some("image/png".to_string()),
251            sizes: vec!["32x32".to_string()],
252            theme: Some(IconTheme::Dark),
253        },
254    ]
255}
256
257// --- Per-field default helpers (single source of truth: the struct Default impls) ---
258fn default_server_name() -> String {
259    ServerConfig::default().name
260}
261
262fn default_server_description() -> Option<String> {
263    ServerConfig::default().description
264}
265
266fn default_server_website_url() -> Option<String> {
267    ServerConfig::default().website_url
268}
269
270fn default_server_host() -> String {
271    ServerConfig::default().host
272}
273
274fn default_server_port() -> u16 {
275    ServerConfig::default().port
276}
277
278fn default_server_transport_mode() -> String {
279    ServerConfig::default().transport_mode
280}
281
282fn default_server_enable_sse() -> bool {
283    ServerConfig::default().enable_sse
284}
285
286fn default_server_enable_oauth() -> bool {
287    ServerConfig::default().enable_oauth
288}
289
290fn default_server_max_connections() -> usize {
291    ServerConfig::default().max_connections
292}
293
294fn default_server_request_timeout_secs() -> u64 {
295    ServerConfig::default().request_timeout_secs
296}
297
298fn default_server_response_timeout_secs() -> u64 {
299    ServerConfig::default().response_timeout_secs
300}
301
302fn default_server_allowed_hosts() -> Vec<String> {
303    ServerConfig::default().allowed_hosts
304}
305
306fn default_server_allowed_origins() -> Vec<String> {
307    ServerConfig::default().allowed_origins
308}
309
310fn default_server_dns_rebinding_protection() -> bool {
311    ServerConfig::default().dns_rebinding_protection
312}
313fn default_logging_level() -> String {
314    LoggingConfig::default().level
315}
316
317fn default_logging_file_path() -> Option<String> {
318    LoggingConfig::default().file_path
319}
320
321fn default_logging_enable_console() -> bool {
322    LoggingConfig::default().enable_console
323}
324
325fn default_logging_enable_file() -> bool {
326    LoggingConfig::default().enable_file
327}
328
329fn default_logging_max_file_size_mb() -> u64 {
330    LoggingConfig::default().max_file_size_mb
331}
332
333fn default_logging_max_files() -> usize {
334    LoggingConfig::default().max_files
335}
336fn default_perf_http_client_pool_size() -> usize {
337    PerformanceConfig::default().http_client_pool_size
338}
339
340fn default_perf_http_client_pool_idle_timeout_secs() -> u64 {
341    PerformanceConfig::default().http_client_pool_idle_timeout_secs
342}
343
344fn default_perf_http_client_connect_timeout_secs() -> u64 {
345    PerformanceConfig::default().http_client_connect_timeout_secs
346}
347
348fn default_perf_http_client_timeout_secs() -> u64 {
349    PerformanceConfig::default().http_client_timeout_secs
350}
351
352fn default_perf_http_client_read_timeout_secs() -> u64 {
353    PerformanceConfig::default().http_client_read_timeout_secs
354}
355
356fn default_perf_http_client_max_retries() -> u32 {
357    PerformanceConfig::default().http_client_max_retries
358}
359
360fn default_perf_http_client_retry_initial_delay_ms() -> u64 {
361    PerformanceConfig::default().http_client_retry_initial_delay_ms
362}
363
364fn default_perf_http_client_retry_max_delay_ms() -> u64 {
365    PerformanceConfig::default().http_client_retry_max_delay_ms
366}
367
368fn default_perf_cache_max_size() -> usize {
369    PerformanceConfig::default().cache_max_size
370}
371
372fn default_perf_cache_default_ttl_secs() -> u64 {
373    PerformanceConfig::default().cache_default_ttl_secs
374}
375
376fn default_perf_rate_limit_per_second() -> u32 {
377    PerformanceConfig::default().rate_limit_per_second
378}
379
380fn default_perf_concurrent_request_limit() -> usize {
381    PerformanceConfig::default().concurrent_request_limit
382}
383
384fn default_perf_enable_response_compression() -> bool {
385    PerformanceConfig::default().enable_response_compression
386}
387
388fn default_perf_enable_metrics() -> bool {
389    PerformanceConfig::default().enable_metrics
390}
391
392fn default_perf_metrics_port() -> u16 {
393    PerformanceConfig::default().metrics_port
394}
395
396/// Logging configuration
397///
398/// # Hot Reload Support
399///
400/// ✅ **Supports hot reload** - All logging configuration items can be dynamically updated at runtime.
401///
402/// Hot reload supported fields:
403/// - `level`: Log level (trace/debug/info/warn/error)
404/// - `file_path`: Log file path
405/// - `enable_console`: Console logging toggle
406/// - `enable_file`: File logging toggle
407/// - `max_file_size_mb`: Maximum log file size
408/// - `max_files`: Number of log files to retain
409///
410/// Note: After file logging path changes, new logs will be written to the new file, but old file handles will not be automatically closed.
411#[derive(Debug, Clone, Deserialize, Serialize)]
412pub struct LoggingConfig {
413    /// Log level
414    #[serde(default = "default_logging_level")]
415    pub level: String,
416
417    /// Log file path
418    #[serde(default = "default_logging_file_path")]
419    pub file_path: Option<String>,
420
421    /// Whether to enable console logging
422    #[serde(default = "default_logging_enable_console")]
423    pub enable_console: bool,
424
425    /// Whether to enable file logging
426    #[serde(default = "default_logging_enable_file")]
427    pub enable_file: bool,
428
429    /// Maximum log file size (MB)
430    #[serde(default = "default_logging_max_file_size_mb")]
431    pub max_file_size_mb: u64,
432
433    /// Number of log files to retain
434    #[serde(default = "default_logging_max_files")]
435    pub max_files: usize,
436}
437
438/// Performance configuration
439///
440/// # Hot Reload Support
441///
442/// ## Hot reload supported fields ✅
443///
444/// The following fields can be dynamically updated at runtime:
445/// - `rate_limit_per_second`: Request rate limit (requests per second)
446/// - `concurrent_request_limit`: Concurrent request limit
447/// - `enable_metrics`: Prometheus metrics collection toggle
448/// - `enable_response_compression`: Response compression toggle
449///
450/// ## Hot reload not supported fields ❌
451///
452/// The following fields require server restart to take effect:
453/// - `http_client_*`: HTTP client configuration (pool size, timeouts, etc.)
454/// - `cache_max_size`: Cache maximum size
455/// - `cache_default_ttl_secs`: Cache default TTL
456/// - `metrics_port`: Metrics server port
457///
458/// Reason: These configurations involve underlying connection pool, cache instance initialization parameters.
459#[derive(Debug, Clone, Deserialize, Serialize)]
460pub struct PerformanceConfig {
461    /// HTTP client connection pool size
462    #[serde(default = "default_perf_http_client_pool_size")]
463    pub http_client_pool_size: usize,
464
465    /// HTTP client pool idle timeout (seconds)
466    #[serde(default = "default_perf_http_client_pool_idle_timeout_secs")]
467    pub http_client_pool_idle_timeout_secs: u64,
468
469    /// HTTP client connection timeout (seconds)
470    #[serde(default = "default_perf_http_client_connect_timeout_secs")]
471    pub http_client_connect_timeout_secs: u64,
472
473    /// HTTP client request timeout (seconds)
474    #[serde(default = "default_perf_http_client_timeout_secs")]
475    pub http_client_timeout_secs: u64,
476
477    /// HTTP client read timeout (seconds)
478    #[serde(default = "default_perf_http_client_read_timeout_secs")]
479    pub http_client_read_timeout_secs: u64,
480
481    /// HTTP client max retry attempts
482    #[serde(default = "default_perf_http_client_max_retries")]
483    pub http_client_max_retries: u32,
484
485    /// HTTP client retry initial delay (milliseconds)
486    #[serde(default = "default_perf_http_client_retry_initial_delay_ms")]
487    pub http_client_retry_initial_delay_ms: u64,
488
489    /// HTTP client retry max delay (milliseconds)
490    #[serde(default = "default_perf_http_client_retry_max_delay_ms")]
491    pub http_client_retry_max_delay_ms: u64,
492
493    /// Maximum cache size (number of entries)
494    #[serde(default = "default_perf_cache_max_size")]
495    pub cache_max_size: usize,
496
497    /// Default cache TTL (seconds)
498    #[serde(default = "default_perf_cache_default_ttl_secs")]
499    pub cache_default_ttl_secs: u64,
500
501    /// Request rate limit (requests per second)
502    #[serde(default = "default_perf_rate_limit_per_second")]
503    pub rate_limit_per_second: u32,
504
505    /// Concurrent request limit
506    #[serde(default = "default_perf_concurrent_request_limit")]
507    pub concurrent_request_limit: usize,
508
509    /// Enable response compression
510    #[serde(default = "default_perf_enable_response_compression")]
511    pub enable_response_compression: bool,
512
513    /// Enable Prometheus metrics.
514    ///
515    /// Defaults to `false`: the metrics subsystem is not yet wired into the
516    /// request pipeline, so enabling it currently has no effect (a startup
517    /// warning is logged when set).
518    #[serde(default = "default_perf_enable_metrics")]
519    pub enable_metrics: bool,
520
521    /// Metrics endpoint port (0 = use server port)
522    #[serde(default = "default_perf_metrics_port")]
523    pub metrics_port: u16,
524}
525
526impl Default for ServerConfig {
527    fn default() -> Self {
528        Self {
529            name: "crates-docs".to_string(),
530            version: crate::VERSION.to_string(),
531            description: Some(
532                "High-performance Rust crate documentation query MCP server".to_string(),
533            ),
534            icons: default_icons(),
535            website_url: Some("https://github.com/KingingWang/crates-docs".to_string()),
536            host: "127.0.0.1".to_string(),
537            port: DEFAULT_SERVER_PORT,
538            transport_mode: "hybrid".to_string(),
539            enable_sse: true,
540            enable_oauth: false,
541            max_connections: DEFAULT_SERVER_MAX_CONNECTIONS,
542            request_timeout_secs: DEFAULT_REQUEST_TIMEOUT_SECS,
543            response_timeout_secs: DEFAULT_RESPONSE_TIMEOUT_SECS,
544            // Secure defaults: only allow localhost by default
545            allowed_hosts: vec!["localhost".to_string(), "127.0.0.1".to_string()],
546            allowed_origins: vec!["http://localhost:*".to_string()],
547            // Off by default: the exact-match allowlists above (with a `*`
548            // wildcard and no ports) would otherwise 403 normal requests.
549            dns_rebinding_protection: false,
550        }
551    }
552}
553
554impl Default for LoggingConfig {
555    fn default() -> Self {
556        Self {
557            level: "info".to_string(),
558            file_path: Some("./logs/crates-docs.log".to_string()),
559            enable_console: true,
560            enable_file: false, // Default: console output only
561            max_file_size_mb: DEFAULT_MAX_FILE_SIZE_MB,
562            max_files: DEFAULT_MAX_FILES,
563        }
564    }
565}
566
567impl Default for PerformanceConfig {
568    fn default() -> Self {
569        Self {
570            http_client_pool_size: DEFAULT_HTTP_CLIENT_POOL_SIZE,
571            http_client_pool_idle_timeout_secs: DEFAULT_HTTP_CLIENT_POOL_IDLE_TIMEOUT_SECS,
572            http_client_connect_timeout_secs: DEFAULT_HTTP_CLIENT_CONNECT_TIMEOUT_SECS,
573            http_client_timeout_secs: DEFAULT_HTTP_CLIENT_TIMEOUT_SECS,
574            http_client_read_timeout_secs: DEFAULT_HTTP_CLIENT_READ_TIMEOUT_SECS,
575            http_client_max_retries: DEFAULT_HTTP_CLIENT_MAX_RETRIES,
576            http_client_retry_initial_delay_ms: DEFAULT_HTTP_CLIENT_RETRY_INITIAL_DELAY_MS,
577            http_client_retry_max_delay_ms: DEFAULT_HTTP_CLIENT_RETRY_MAX_DELAY_MS,
578            cache_max_size: DEFAULT_CACHE_MAX_SIZE,
579            cache_default_ttl_secs: DEFAULT_CACHE_DEFAULT_TTL_SECS,
580            rate_limit_per_second: DEFAULT_RATE_LIMIT_PER_SECOND,
581            concurrent_request_limit: DEFAULT_CONCURRENT_REQUEST_LIMIT,
582            enable_response_compression: true,
583            enable_metrics: false,
584            metrics_port: 0,
585        }
586    }
587}
588
589/// Environment variable configuration for server
590///
591/// All fields are `Option<T>` to distinguish between "not set from environment"
592/// and "explicitly set from environment".
593///
594/// # Semantics
595///
596/// - `None` - The environment variable was not set; use the config file or default value
597/// - `Some(value)` - The environment variable was explicitly set to `value`
598///
599/// # Example
600///
601/// ```rust,ignore
602/// // CRATES_DOCS_HOST not set
603/// let config = EnvServerConfig::from_env(); // host == None, use default
604///
605/// // CRATES_DOCS_HOST=127.0.0.1
606/// let config = EnvServerConfig::from_env(); // host == Some("127.0.0.1")
607/// ```
608#[derive(Debug, Clone, Default)]
609pub struct EnvServerConfig {
610    /// Server name
611    pub name: Option<String>,
612    /// Host address
613    pub host: Option<String>,
614    /// Port
615    pub port: Option<u16>,
616    /// Transport mode
617    pub transport_mode: Option<String>,
618}
619
620/// Environment variable configuration for logging
621///
622/// All fields are `Option<T>` to distinguish between "not set from environment"
623/// and "explicitly set from environment".
624///
625/// # Semantics
626///
627/// - `None` - The environment variable was not set; use the config file or default value
628/// - `Some(value)` - The environment variable was explicitly set to `value`
629#[derive(Debug, Clone, Default)]
630pub struct EnvLoggingConfig {
631    /// Log level
632    pub level: Option<String>,
633    /// Whether to enable console logging
634    pub enable_console: Option<bool>,
635    /// Whether to enable file logging
636    pub enable_file: Option<bool>,
637}
638
639/// Environment variable configuration for API key (when feature enabled)
640///
641/// All fields are `Option<T>` to distinguish between "not set from environment"
642/// and "explicitly set from environment".
643///
644/// # Semantics
645///
646/// - `None` - The environment variable was not set; use the config file or default value
647/// - `Some(value)` - The environment variable was explicitly set to `value`
648#[cfg(feature = "api-key")]
649#[derive(Debug, Clone, Default)]
650pub struct EnvApiKeyConfig {
651    /// Whether API key authentication is enabled
652    pub enabled: Option<bool>,
653    /// List of valid API keys
654    pub keys: Option<Vec<String>>,
655    /// Header name for API key
656    pub header_name: Option<String>,
657    /// Query parameter name for API key
658    pub query_param_name: Option<String>,
659    /// Whether to allow API key in query parameters
660    pub allow_query_param: Option<bool>,
661    /// API key prefix
662    pub key_prefix: Option<String>,
663}
664
665/// Environment variable configuration
666///
667/// Uses `Option<T>` for all fields to properly distinguish between
668/// "not set" and "explicitly set to default value".
669#[derive(Debug, Clone, Default)]
670pub struct EnvAppConfig {
671    /// Server configuration from environment
672    pub server: EnvServerConfig,
673    /// Logging configuration from environment
674    pub logging: EnvLoggingConfig,
675    /// API key configuration from environment
676    #[cfg(feature = "api-key")]
677    pub auth_api_key: EnvApiKeyConfig,
678}
679
680impl AppConfig {
681    /// Load configuration from file
682    ///
683    /// # Errors
684    ///
685    /// Returns an error if file does not exist, cannot be read, or format is invalid
686    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, crate::error::Error> {
687        let content = fs::read_to_string(path).map_err(|e| {
688            crate::error::Error::config("file", format!("Failed to read config file: {e}"))
689        })?;
690
691        let config: Self = toml::from_str(&content).map_err(|e| {
692            crate::error::Error::parse("config", None, format!("Failed to parse config file: {e}"))
693        })?;
694
695        config.validate()?;
696        Ok(config)
697    }
698
699    /// Save configuration to file
700    ///
701    /// # Errors
702    ///
703    /// Returns an error if configuration cannot be serialized, directory cannot be created, or file cannot be written
704    pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), crate::error::Error> {
705        let content = toml::to_string_pretty(self).map_err(|e| {
706            crate::error::Error::config(
707                "serialization",
708                format!("Failed to serialize configuration: {e}"),
709            )
710        })?;
711
712        // Ensure directory exists
713        if let Some(parent) = path.as_ref().parent() {
714            fs::create_dir_all(parent).map_err(|e| {
715                crate::error::Error::config("directory", format!("Failed to create directory: {e}"))
716            })?;
717        }
718
719        fs::write(path, content).map_err(|e| {
720            crate::error::Error::config("file", format!("Failed to write config file: {e}"))
721        })?;
722
723        Ok(())
724    }
725
726    /// Validate configuration
727    ///
728    /// # Errors
729    ///
730    /// Returns an error if configuration is invalid (e.g., empty hostname, invalid port, etc.)
731    pub fn validate(&self) -> Result<(), crate::error::Error> {
732        // Validate server configuration
733        if self.server.host.is_empty() {
734            return Err(crate::error::Error::config("host", "cannot be empty"));
735        }
736
737        if self.server.port == 0 {
738            return Err(crate::error::Error::config("port", "cannot be 0"));
739        }
740
741        if self.server.max_connections == 0 {
742            return Err(crate::error::Error::config(
743                "max_connections",
744                "cannot be 0",
745            ));
746        }
747
748        // Validate transport mode. Match case-insensitively to stay consistent
749        // with the dispatcher (`run_server_by_mode`) and `TransportMode::from_str`,
750        // which both lowercase the value; otherwise `--mode HTTP` would be
751        // rejected here even though it would dispatch fine.
752        let valid_modes = ["stdio", "http", "sse", "hybrid"];
753        if !valid_modes.contains(&self.server.transport_mode.to_lowercase().as_str()) {
754            return Err(crate::error::Error::config(
755                "transport_mode",
756                format!(
757                    "Invalid transport mode: {}, valid values: {:?}",
758                    self.server.transport_mode, valid_modes
759                ),
760            ));
761        }
762
763        // Validate log level
764        let valid_levels = ["trace", "debug", "info", "warn", "error"];
765
766        if !valid_levels.contains(&self.logging.level.as_str()) {
767            return Err(crate::error::Error::config(
768                "log_level",
769                format!(
770                    "Invalid log level: {}, valid values: {:?}",
771                    self.logging.level, valid_levels
772                ),
773            ));
774        }
775
776        // Validate performance configuration
777        if self.performance.http_client_pool_size == 0 {
778            return Err(crate::error::Error::config(
779                "http_client_pool_size",
780                "cannot be 0",
781            ));
782        }
783
784        if self.performance.http_client_pool_idle_timeout_secs == 0 {
785            return Err(crate::error::Error::config(
786                "http_client_pool_idle_timeout_secs",
787                "cannot be 0",
788            ));
789        }
790
791        if self.performance.http_client_connect_timeout_secs == 0 {
792            return Err(crate::error::Error::config(
793                "http_client_connect_timeout_secs",
794                "cannot be 0",
795            ));
796        }
797
798        if self.performance.http_client_timeout_secs == 0 {
799            return Err(crate::error::Error::config(
800                "http_client_timeout_secs",
801                "cannot be 0",
802            ));
803        }
804
805        // A read timeout of 0 makes reqwest's per-read deadline elapse
806        // immediately, so every response body read fails. Reject it like the
807        // other HTTP-client timeouts above (it was previously the only one not
808        // checked, letting `http_client_read_timeout_secs = 0` silently break
809        // all fetches).
810        if self.performance.http_client_read_timeout_secs == 0 {
811            return Err(crate::error::Error::config(
812                "http_client_read_timeout_secs",
813                "cannot be 0",
814            ));
815        }
816
817        if self.performance.cache_max_size == 0 {
818            return Err(crate::error::Error::config("cache_max_size", "cannot be 0"));
819        }
820
821        // Validate cache configuration.
822        //
823        // Note: the live in-memory cache is sized from `cache.memory_size`
824        // (see `create_cache`), NOT `performance.cache_max_size`. A
825        // `memory_size` of 0 builds a zero-capacity cache that evicts every
826        // entry immediately, silently disabling caching, so reject it here.
827        let valid_cache_types = ["memory", "redis"];
828        if !valid_cache_types.contains(&self.cache.cache_type.as_str()) {
829            return Err(crate::error::Error::config(
830                "cache.cache_type",
831                format!(
832                    "Invalid cache type: {}, valid values: {:?}",
833                    self.cache.cache_type, valid_cache_types
834                ),
835            ));
836        }
837        if self.cache.cache_type == "memory" && self.cache.memory_size == Some(0) {
838            return Err(crate::error::Error::config(
839                "cache.memory_size",
840                "cannot be 0 (this would disable the cache); omit it to use the default",
841            ));
842        }
843
844        // Validate OAuth configuration
845        if self.server.enable_oauth {
846            self.oauth.validate()?;
847        }
848
849        // Validate the unified auth configuration (OAuth + API key). Each
850        // sub-validator short-circuits when its section is disabled, so this is
851        // safe to call unconditionally and catches misconfigured API key
852        // settings (e.g. empty header_name/key_prefix) that were previously
853        // never validated.
854        self.auth.validate()?;
855
856        Ok(())
857    }
858
859    /// Load configuration from environment variables
860    ///
861    /// Returns an `EnvAppConfig` where all fields are `Option<T>`, allowing
862    /// the caller to distinguish between "not set" and "explicitly set".
863    ///
864    /// # Errors
865    ///
866    /// Returns an error if environment variable format is invalid (e.g., non-numeric port)
867    pub fn from_env() -> Result<EnvAppConfig, crate::error::Error> {
868        let mut config = EnvAppConfig::default();
869
870        // Load server configuration from environment variables
871        if let Ok(name) = std::env::var("CRATES_DOCS_NAME") {
872            config.server.name = Some(name);
873        }
874
875        if let Ok(host) = std::env::var("CRATES_DOCS_HOST") {
876            config.server.host = Some(host);
877        }
878
879        if let Ok(port) = std::env::var("CRATES_DOCS_PORT") {
880            config.server.port =
881                Some(port.parse().map_err(|e| {
882                    crate::error::Error::config("port", format!("Invalid port: {e}"))
883                })?);
884        }
885
886        if let Ok(mode) = std::env::var("CRATES_DOCS_TRANSPORT_MODE") {
887            config.server.transport_mode = Some(mode);
888        }
889
890        // Load logging configuration from environment variables
891        if let Ok(level) = std::env::var("CRATES_DOCS_LOG_LEVEL") {
892            config.logging.level = Some(level);
893        }
894
895        if let Ok(enable_console) = std::env::var("CRATES_DOCS_ENABLE_CONSOLE") {
896            config.logging.enable_console = enable_console.parse().ok();
897        }
898
899        if let Ok(enable_file) = std::env::var("CRATES_DOCS_ENABLE_FILE") {
900            config.logging.enable_file = enable_file.parse().ok();
901        }
902
903        #[cfg(feature = "api-key")]
904        {
905            if let Ok(enabled) = std::env::var("CRATES_DOCS_API_KEY_ENABLED") {
906                config.auth_api_key.enabled = enabled.parse().ok();
907            }
908
909            if let Ok(keys) = std::env::var("CRATES_DOCS_API_KEYS") {
910                config.auth_api_key.keys = Some(
911                    keys.split(',')
912                        .map(str::trim)
913                        .filter(|s| !s.is_empty())
914                        .map(ToOwned::to_owned)
915                        .collect(),
916                );
917            }
918
919            if let Ok(header_name) = std::env::var("CRATES_DOCS_API_KEY_HEADER") {
920                config.auth_api_key.header_name = Some(header_name);
921            }
922
923            if let Ok(query_param_name) = std::env::var("CRATES_DOCS_API_KEY_QUERY_PARAM_NAME") {
924                config.auth_api_key.query_param_name = Some(query_param_name);
925            }
926
927            if let Ok(allow_query_param) = std::env::var("CRATES_DOCS_API_KEY_ALLOW_QUERY") {
928                config.auth_api_key.allow_query_param = allow_query_param.parse().ok();
929            }
930
931            if let Ok(key_prefix) = std::env::var("CRATES_DOCS_API_KEY_PREFIX") {
932                config.auth_api_key.key_prefix = Some(key_prefix);
933            }
934        }
935
936        Ok(config)
937    }
938
939    /// Merge configuration (environment variables take precedence over file configuration)
940    ///
941    /// Uses `Option<T>` semantics from `EnvAppConfig` to determine which values
942    /// were explicitly set via environment variables. This eliminates fragile
943    /// hardcoded default comparisons.
944    #[must_use]
945    pub fn merge(file_config: Option<Self>, env_config: Option<EnvAppConfig>) -> Self {
946        let mut config = Self::default();
947
948        // First apply file configuration
949        if let Some(file) = file_config {
950            config = file;
951        }
952
953        // Then apply environment variable configuration (overrides file configuration)
954        // Uses Option::is_some() to check if value was explicitly set
955        if let Some(env) = env_config {
956            // Merge server configuration - only override if explicitly set
957            if let Some(name) = env.server.name {
958                config.server.name = name;
959            }
960            if let Some(host) = env.server.host {
961                config.server.host = host;
962            }
963            if let Some(port) = env.server.port {
964                config.server.port = port;
965            }
966            if let Some(transport_mode) = env.server.transport_mode {
967                config.server.transport_mode = transport_mode;
968            }
969
970            // Merge logging configuration - only override if explicitly set
971            if let Some(level) = env.logging.level {
972                config.logging.level = level;
973            }
974            if let Some(enable_console) = env.logging.enable_console {
975                config.logging.enable_console = enable_console;
976            }
977            if let Some(enable_file) = env.logging.enable_file {
978                config.logging.enable_file = enable_file;
979            }
980
981            #[cfg(feature = "api-key")]
982            {
983                if let Some(enabled) = env.auth_api_key.enabled {
984                    config.auth.api_key.enabled = enabled;
985                }
986                if let Some(keys) = env.auth_api_key.keys {
987                    config.auth.api_key.keys = keys;
988                }
989                if let Some(header_name) = env.auth_api_key.header_name {
990                    config.auth.api_key.header_name = header_name;
991                }
992                if let Some(query_param_name) = env.auth_api_key.query_param_name {
993                    config.auth.api_key.query_param_name = query_param_name;
994                }
995                if let Some(allow_query_param) = env.auth_api_key.allow_query_param {
996                    config.auth.api_key.allow_query_param = allow_query_param;
997                }
998                if let Some(key_prefix) = env.auth_api_key.key_prefix {
999                    config.auth.api_key.key_prefix = key_prefix;
1000                }
1001            }
1002        }
1003
1004        config
1005    }
1006}