airsprotocols_mcpserver_filesystem/config/
loader.rs

1//! Configuration loading system for AIRS MCP-FS
2//!
3//! Provides enterprise-grade configuration management with:
4//! - Multi-format support (TOML, YAML, JSON)
5//! - Environment-specific configuration layering
6//! - Environment variable overrides (12-factor app compliance)
7//! - Configuration schema validation
8//! - Secure defaults and error handling
9
10// Layer 1: Standard library imports
11use std::env;
12use std::path::{Path, PathBuf};
13
14// Layer 2: Third-party crate imports
15use anyhow::{Context, Result};
16use config::{Config, Environment, File, FileFormat};
17use serde::{Deserialize, Serialize};
18
19// Layer 3: Internal module imports
20use super::settings::Settings;
21
22/// Configuration environment type
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ConfigEnvironment {
25    /// Development environment - permissive settings for local development
26    Development,
27    /// Staging environment - production-like settings for testing
28    Staging,
29    /// Production environment - secure settings for live deployment
30    Production,
31    /// Test environment - minimal settings for unit tests
32    Test,
33}
34
35impl ConfigEnvironment {
36    /// Detect environment from environment variables
37    pub fn detect() -> Self {
38        match env::var("AIRSPROTOCOLS_MCPSERVER_FS_ENV")
39            .or_else(|_| env::var("AIRSPROTOCOLS_MCP_FS_ENV")) // Backward compatibility
40            .or_else(|_| env::var("NODE_ENV"))
41            .or_else(|_| env::var("ENVIRONMENT"))
42            .as_deref()
43        {
44            Ok("development") | Ok("dev") => Self::Development,
45            Ok("staging") | Ok("stage") => Self::Staging,
46            Ok("production") | Ok("prod") => Self::Production,
47            Ok("test") => Self::Test,
48            _ => {
49                // Default based on compile-time environment
50                if cfg!(test) {
51                    Self::Test
52                } else if cfg!(debug_assertions) {
53                    Self::Development
54                } else {
55                    Self::Production
56                }
57            }
58        }
59    }
60
61    /// Get environment-specific configuration file name
62    pub fn config_filename(&self) -> &'static str {
63        match self {
64            Self::Development => "development.toml",
65            Self::Staging => "staging.toml",
66            Self::Production => "production.toml",
67            Self::Test => "test.toml",
68        }
69    }
70
71    /// Get environment name as string
72    pub fn as_str(&self) -> &'static str {
73        match self {
74            Self::Development => "development",
75            Self::Staging => "staging",
76            Self::Production => "production",
77            Self::Test => "test",
78        }
79    }
80}
81
82/// Configuration source information for debugging and logging
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ConfigurationSource {
85    /// Configuration files loaded in order
86    pub files: Vec<String>,
87    /// Environment variables applied
88    pub env_vars: Vec<String>,
89    /// Environment type detected
90    pub environment: String,
91    /// Whether defaults were used
92    pub uses_defaults: bool,
93}
94
95/// Configuration loader with environment-specific layering
96pub struct ConfigurationLoader {
97    /// Current environment
98    environment: ConfigEnvironment,
99    /// Base configuration directory
100    config_dir: PathBuf,
101    /// Environment variable prefix
102    env_prefix: String,
103}
104
105impl ConfigurationLoader {
106    /// Create new configuration loader with automatic environment detection
107    pub fn new() -> Self {
108        Self::with_environment(ConfigEnvironment::detect())
109    }
110
111    /// Create configuration loader for specific environment
112    pub fn with_environment(environment: ConfigEnvironment) -> Self {
113        let config_dir = Self::default_config_dir();
114        Self {
115            environment,
116            config_dir,
117            env_prefix: "AIRSPROTOCOLS_MCPSERVER_FS".to_string(),
118        }
119    }
120
121    /// Set custom configuration directory
122    pub fn with_config_dir<P: Into<PathBuf>>(mut self, config_dir: P) -> Self {
123        self.config_dir = config_dir.into();
124        self
125    }
126
127    /// Set custom environment variable prefix
128    pub fn with_env_prefix<S: Into<String>>(mut self, prefix: S) -> Self {
129        self.env_prefix = prefix.into();
130        self
131    }
132
133    /// Load configuration with environment-specific layering
134    pub fn load(&self) -> Result<(Settings, ConfigurationSource)> {
135        let mut builder = Config::builder();
136        let mut source_info = ConfigurationSource {
137            files: Vec::new(),
138            env_vars: Vec::new(),
139            environment: self.environment.as_str().to_string(),
140            uses_defaults: false,
141        };
142
143        // Layer 1: Built-in defaults (always start with this)
144        let default_settings = Settings::default();
145        builder = builder.add_source(
146            config::Config::try_from(&default_settings)
147                .context("Failed to convert default settings to config source")?,
148        );
149        source_info.uses_defaults = true;
150
151        // Layer 2: Base configuration file (config.toml)
152        let base_config_path = self.config_dir.join("config.toml");
153        if base_config_path.exists() {
154            builder = builder.add_source(
155                File::from(base_config_path.as_path())
156                    .format(FileFormat::Toml)
157                    .required(false),
158            );
159            source_info
160                .files
161                .push(base_config_path.display().to_string());
162        }
163
164        // Layer 3: Environment-specific configuration
165        let env_config_path = self.config_dir.join(self.environment.config_filename());
166        if env_config_path.exists() {
167            builder = builder.add_source(
168                File::from(env_config_path.as_path())
169                    .format(FileFormat::Toml)
170                    .required(false),
171            );
172            source_info
173                .files
174                .push(env_config_path.display().to_string());
175        }
176
177        // Layer 4: Local overrides (local.toml) - for development only
178        if matches!(self.environment, ConfigEnvironment::Development) {
179            let local_config_path = self.config_dir.join("local.toml");
180            if local_config_path.exists() {
181                builder = builder.add_source(
182                    File::from(local_config_path.as_path())
183                        .format(FileFormat::Toml)
184                        .required(false),
185                );
186                source_info
187                    .files
188                    .push(local_config_path.display().to_string());
189            }
190        }
191
192        // Layer 5: Environment variables (12-factor app compliance)
193        builder = builder.add_source(
194            Environment::with_prefix(&self.env_prefix)
195                .separator("__") // Use double underscore for nested keys
196                .prefix_separator("_"),
197        );
198
199        // Collect environment variables that were used
200        for (key, _value) in env::vars() {
201            if key.starts_with(&format!("{}_", self.env_prefix)) {
202                source_info.env_vars.push(key);
203            }
204        }
205
206        // Build final configuration
207        let config = builder.build().context("Failed to build configuration")?;
208
209        // Deserialize into Settings struct
210        let settings: Settings = config
211            .try_deserialize()
212            .context("Failed to deserialize configuration into Settings struct")?;
213
214        Ok((settings, source_info))
215    }
216
217    /// Load configuration from specific file path
218    pub fn load_from_file<P: AsRef<Path>>(file_path: P) -> Result<Settings> {
219        let path = file_path.as_ref();
220
221        // Determine file format from extension
222        let format = match path.extension().and_then(|s| s.to_str()) {
223            Some("toml") => FileFormat::Toml,
224            Some("yaml") | Some("yml") => FileFormat::Yaml,
225            Some("json") => FileFormat::Json,
226            _ => {
227                return Err(anyhow::anyhow!(
228                    "Unsupported configuration file format. Supported: .toml, .yaml, .yml, .json"
229                ))
230            }
231        };
232
233        let config = Config::builder()
234            .add_source(File::from(path).format(format))
235            .build()
236            .with_context(|| format!("Failed to load configuration from {}", path.display()))?;
237
238        let settings: Settings = config
239            .try_deserialize()
240            .with_context(|| format!("Failed to parse configuration file {}", path.display()))?;
241
242        Ok(settings)
243    }
244
245    /// Get default configuration directory
246    fn default_config_dir() -> PathBuf {
247        // Try environment variable first
248        if let Ok(config_dir) =
249            env::var("AIRSPROTOCOLS_MCPSERVER_FS_CONFIG_DIR").or_else(|_| env::var("AIRSPROTOCOLS_MCP_FS_CONFIG_DIR"))
250        // Backward compatibility
251        {
252            return PathBuf::from(config_dir);
253        }
254
255        // Default to config/ in current directory for now
256        // In production, this would be more sophisticated (e.g., /etc/airsprotocols-mcpserver-filesystem/)
257        PathBuf::from("config")
258    }
259
260    /// Validate configuration file without loading
261    pub fn validate_file<P: AsRef<Path>>(file_path: P) -> Result<Vec<String>> {
262        let settings = Self::load_from_file(file_path)?;
263
264        // Use existing validation system
265        let validation_result = settings
266            .validate()
267            .context("Failed to validate configuration")?;
268
269        let mut issues = Vec::new();
270        issues.extend(validation_result.errors);
271        issues.extend(validation_result.warnings);
272
273        Ok(issues)
274    }
275}
276
277impl Default for ConfigurationLoader {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283#[cfg(test)]
284#[allow(clippy::unwrap_used, clippy::uninlined_format_args)]
285mod tests {
286    use super::*;
287    use std::fs;
288    use tempfile::TempDir;
289
290    fn create_test_config_dir() -> TempDir {
291        let temp_dir = TempDir::new().unwrap();
292
293        // Create a basic config.toml
294        let config_content = r#"
295[server]
296name = "test-airsprotocols-mcpserver-filesystem"
297version = "0.1.0"
298
299[binary]
300max_file_size = 52428800  # 50MB for text files only
301binary_processing_disabled = true  # Security hardening - binary processing disabled
302
303[security.filesystem]
304allowed_paths = ["./test/**/*"]
305denied_paths = ["**/.env*"]
306
307[security.operations]
308read_allowed = true
309write_requires_policy = false
310delete_requires_explicit_allow = false
311create_dir_allowed = true
312
313[security.policies.test_policy]
314patterns = ["*.txt"]
315operations = ["read", "write"]
316risk_level = "low"
317description = "Test policy for txt files"
318"#;
319
320        let config_path = temp_dir.path().join("config.toml");
321        fs::write(&config_path, config_content).unwrap();
322
323        temp_dir
324    }
325
326    #[test]
327    fn test_environment_detection() {
328        // Test default detection
329        let env = ConfigEnvironment::detect();
330        assert!(matches!(
331            env,
332            ConfigEnvironment::Test | ConfigEnvironment::Development
333        ));
334    }
335
336    #[test]
337    fn test_environment_config_filenames() {
338        assert_eq!(
339            ConfigEnvironment::Development.config_filename(),
340            "development.toml"
341        );
342        assert_eq!(ConfigEnvironment::Staging.config_filename(), "staging.toml");
343        assert_eq!(
344            ConfigEnvironment::Production.config_filename(),
345            "production.toml"
346        );
347        assert_eq!(ConfigEnvironment::Test.config_filename(), "test.toml");
348    }
349
350    #[test]
351    fn test_configuration_loader_creation() {
352        let loader = ConfigurationLoader::new();
353        assert!(matches!(
354            loader.environment,
355            ConfigEnvironment::Test | ConfigEnvironment::Development
356        ));
357
358        let prod_loader = ConfigurationLoader::with_environment(ConfigEnvironment::Production);
359        assert!(matches!(
360            prod_loader.environment,
361            ConfigEnvironment::Production
362        ));
363    }
364
365    #[test]
366    fn test_load_from_file() {
367        let temp_dir = create_test_config_dir();
368        let config_path = temp_dir.path().join("config.toml");
369
370        let settings = ConfigurationLoader::load_from_file(&config_path).unwrap();
371        assert_eq!(settings.server.name, "test-airsprotocols-mcpserver-filesystem");
372        assert_eq!(settings.binary.max_file_size, 52428800);
373        assert!(settings.binary.binary_processing_disabled); // Security hardening
374    }
375
376    #[test]
377    fn test_load_with_config_dir() {
378        let temp_dir = create_test_config_dir();
379
380        let loader = ConfigurationLoader::with_environment(ConfigEnvironment::Test)
381            .with_config_dir(temp_dir.path());
382
383        let (settings, source_info) = loader.load().unwrap();
384
385        // Should have loaded from our test config
386        assert_eq!(settings.server.name, "test-airsprotocols-mcpserver-filesystem");
387        assert!(source_info.uses_defaults);
388        assert!(!source_info.files.is_empty());
389        assert_eq!(source_info.environment, "test");
390    }
391
392    #[test]
393    fn test_validate_file() {
394        let temp_dir = create_test_config_dir();
395        let config_path = temp_dir.path().join("config.toml");
396
397        let issues = ConfigurationLoader::validate_file(&config_path).unwrap();
398        // Validation should complete without panicking
399        // Issues may contain warnings or errors, which is acceptable for testing
400        println!("Validation issues found: {:?}", issues);
401
402        // The main goal is that validation completes successfully
403        // Individual issues will be addressed by the validation system
404    }
405
406    #[test]
407    fn test_unsupported_file_format() {
408        let temp_dir = TempDir::new().unwrap();
409        let invalid_path = temp_dir.path().join("config.txt");
410        fs::write(&invalid_path, "invalid content").unwrap();
411
412        let result = ConfigurationLoader::load_from_file(&invalid_path);
413        assert!(result.is_err());
414        assert!(result
415            .unwrap_err()
416            .to_string()
417            .contains("Unsupported configuration file format"));
418    }
419}