things3_core/
config_loader.rs

1//! Configuration Loader
2//!
3//! This module provides utilities for loading configuration from multiple sources
4//! with proper precedence and validation.
5
6use crate::error::{Result, ThingsError};
7use crate::mcp_config::McpServerConfig;
8use std::path::{Path, PathBuf};
9use tracing::{debug, info, warn};
10
11/// Configuration loader that handles multiple sources with precedence
12pub struct ConfigLoader {
13    /// Base configuration
14    base_config: McpServerConfig,
15    /// Configuration file paths to try in order
16    config_paths: Vec<PathBuf>,
17    /// Whether to load from environment variables
18    load_from_env: bool,
19    /// Whether to validate the final configuration
20    validate: bool,
21}
22
23impl ConfigLoader {
24    /// Create a new configuration loader
25    #[must_use]
26    pub fn new() -> Self {
27        Self {
28            base_config: McpServerConfig::default(),
29            config_paths: Self::get_default_config_paths(),
30            load_from_env: true,
31            validate: true,
32        }
33    }
34
35    /// Set the base configuration
36    #[must_use]
37    pub fn with_base_config(mut self, config: McpServerConfig) -> Self {
38        self.base_config = config;
39        self
40    }
41
42    /// Add a configuration file path
43    #[must_use]
44    pub fn add_config_path<P: AsRef<Path>>(mut self, path: P) -> Self {
45        self.config_paths.push(path.as_ref().to_path_buf());
46        self
47    }
48
49    /// Set configuration file paths
50    #[must_use]
51    pub fn with_config_paths<P: AsRef<Path>>(mut self, paths: Vec<P>) -> Self {
52        self.config_paths = paths
53            .into_iter()
54            .map(|p| p.as_ref().to_path_buf())
55            .collect();
56        self
57    }
58
59    /// Disable loading from environment variables
60    #[must_use]
61    pub fn without_env_loading(mut self) -> Self {
62        self.load_from_env = false;
63        self
64    }
65
66    /// Enable or disable loading from environment variables
67    #[must_use]
68    pub fn with_env_loading(mut self, enabled: bool) -> Self {
69        self.load_from_env = enabled;
70        self
71    }
72
73    /// Enable or disable configuration validation
74    #[must_use]
75    pub fn with_validation(mut self, enabled: bool) -> Self {
76        self.validate = enabled;
77        self
78    }
79
80    /// Load configuration from all sources
81    ///
82    /// # Errors
83    /// Returns an error if configuration cannot be loaded or is invalid
84    pub fn load(&self) -> Result<McpServerConfig> {
85        let mut config = self.base_config.clone();
86        info!("Starting configuration loading process");
87
88        // Load from configuration files in order
89        for path in &self.config_paths {
90            if path.exists() {
91                debug!("Loading configuration from file: {}", path.display());
92                match McpServerConfig::from_file(path) {
93                    Ok(file_config) => {
94                        config.merge_with(&file_config);
95                        info!("Successfully loaded configuration from: {}", path.display());
96                    }
97                    Err(e) => {
98                        warn!(
99                            "Failed to load configuration from {}: {}",
100                            path.display(),
101                            e
102                        );
103                        // Continue with other sources
104                    }
105                }
106            } else {
107                debug!("Configuration file not found: {}", path.display());
108            }
109        }
110
111        // Load from environment variables (highest precedence)
112        if self.load_from_env {
113            debug!("Loading configuration from environment variables");
114            match McpServerConfig::from_env() {
115                Ok(env_config) => {
116                    config.merge_with(&env_config);
117                    info!("Successfully loaded configuration from environment variables");
118                }
119                Err(e) => {
120                    warn!(
121                        "Failed to load configuration from environment variables: {}",
122                        e
123                    );
124                    // Continue with current config
125                }
126            }
127        }
128
129        // Validate the final configuration
130        if self.validate {
131            debug!("Validating final configuration");
132            config.validate()?;
133            info!("Configuration validation passed");
134        }
135
136        info!("Configuration loading completed successfully");
137        Ok(config)
138    }
139
140    /// Get the default configuration file paths to try
141    #[must_use]
142    pub fn get_default_config_paths() -> Vec<PathBuf> {
143        vec![
144            // Current directory
145            PathBuf::from("mcp-config.json"),
146            PathBuf::from("mcp-config.yaml"),
147            PathBuf::from("mcp-config.yml"),
148            // User config directory
149            Self::get_user_config_dir().join("mcp-config.json"),
150            Self::get_user_config_dir().join("mcp-config.yaml"),
151            Self::get_user_config_dir().join("mcp-config.yml"),
152            // System config directory
153            Self::get_system_config_dir().join("mcp-config.json"),
154            Self::get_system_config_dir().join("mcp-config.yaml"),
155            Self::get_system_config_dir().join("mcp-config.yml"),
156        ]
157    }
158
159    /// Get the user configuration directory
160    #[must_use]
161    pub fn get_user_config_dir() -> PathBuf {
162        if let Ok(home) = std::env::var("HOME") {
163            PathBuf::from(home).join(".config").join("things3-mcp")
164        } else if let Ok(userprofile) = std::env::var("USERPROFILE") {
165            // Windows
166            PathBuf::from(userprofile)
167                .join("AppData")
168                .join("Roaming")
169                .join("things3-mcp")
170        } else {
171            // Fallback
172            PathBuf::from("~/.config/things3-mcp")
173        }
174    }
175
176    /// Get the system configuration directory
177    #[must_use]
178    pub fn get_system_config_dir() -> PathBuf {
179        if cfg!(target_os = "macos") {
180            PathBuf::from("/Library/Application Support/things3-mcp")
181        } else if cfg!(target_os = "windows") {
182            PathBuf::from("C:\\ProgramData\\things3-mcp")
183        } else {
184            // Linux and others
185            PathBuf::from("/etc/things3-mcp")
186        }
187    }
188
189    /// Create a sample configuration file
190    ///
191    /// # Arguments
192    /// * `path` - Path to create the sample configuration file
193    /// * `format` - Format to use ("json" or "yaml")
194    ///
195    /// # Errors
196    /// Returns an error if the file cannot be created
197    pub fn create_sample_config<P: AsRef<Path>>(path: P, format: &str) -> Result<()> {
198        let config = McpServerConfig::default();
199        config.to_file(path, format)?;
200        Ok(())
201    }
202
203    /// Create all default configuration files with sample content
204    ///
205    /// # Errors
206    /// Returns an error if any file cannot be created
207    pub fn create_all_sample_configs() -> Result<()> {
208        let config = McpServerConfig::default();
209
210        // Create user config directory
211        let user_config_dir = Self::get_user_config_dir();
212        std::fs::create_dir_all(&user_config_dir).map_err(|e| {
213            ThingsError::Io(std::io::Error::other(format!(
214                "Failed to create user config directory: {e}"
215            )))
216        })?;
217
218        // Create sample files
219        let sample_files = vec![
220            (user_config_dir.join("mcp-config.json"), "json"),
221            (user_config_dir.join("mcp-config.yaml"), "yaml"),
222            (PathBuf::from("mcp-config.json"), "json"),
223            (PathBuf::from("mcp-config.yaml"), "yaml"),
224        ];
225
226        for (path, format) in sample_files {
227            config.to_file(&path, format)?;
228            info!("Created sample configuration file: {}", path.display());
229        }
230
231        Ok(())
232    }
233}
234
235impl Default for ConfigLoader {
236    fn default() -> Self {
237        Self::new()
238    }
239}
240
241/// Quick configuration loader that uses sensible defaults
242///
243/// # Errors
244/// Returns an error if configuration cannot be loaded
245pub fn load_config() -> Result<McpServerConfig> {
246    ConfigLoader::new().load()
247}
248
249/// Load configuration with custom paths
250///
251/// # Arguments
252/// * `config_paths` - Paths to configuration files to try
253///
254/// # Errors
255/// Returns an error if configuration cannot be loaded
256pub fn load_config_with_paths<P: AsRef<Path>>(config_paths: Vec<P>) -> Result<McpServerConfig> {
257    ConfigLoader::new().with_config_paths(config_paths).load()
258}
259
260/// Load configuration from environment variables only
261///
262/// # Errors
263/// Returns an error if configuration cannot be loaded
264pub fn load_config_from_env() -> Result<McpServerConfig> {
265    McpServerConfig::from_env()
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use std::sync::Mutex;
272    use tempfile::TempDir;
273
274    // Global mutex to synchronize environment variable access across tests
275    static ENV_MUTEX: Mutex<()> = Mutex::new(());
276
277    #[test]
278    fn test_config_loader_default() {
279        let loader = ConfigLoader::new();
280        assert!(loader.load_from_env);
281        assert!(loader.validate);
282        assert!(!loader.config_paths.is_empty());
283    }
284
285    #[test]
286    fn test_config_loader_with_base_config() {
287        let _lock = ENV_MUTEX.lock().unwrap();
288
289        // Clear any existing environment variables
290        std::env::remove_var("MCP_SERVER_NAME");
291
292        let mut base_config = McpServerConfig::default();
293        base_config.server.name = "test-server".to_string();
294
295        let loader = ConfigLoader::new()
296            .with_base_config(base_config.clone())
297            .with_config_paths::<String>(vec![])
298            .without_env_loading();
299
300        // Debug: Check if load_from_env is actually false
301        assert!(!loader.load_from_env);
302
303        let loaded_config = loader.load().unwrap();
304        assert_eq!(loaded_config.server.name, "test-server");
305    }
306
307    #[test]
308    fn test_config_loader_with_custom_paths() {
309        let temp_dir = TempDir::new().unwrap();
310        let config_file = temp_dir.path().join("test-config.json");
311
312        // Create a test configuration file
313        let mut test_config = McpServerConfig::default();
314        test_config.server.name = "file-server".to_string();
315        test_config.to_file(&config_file, "json").unwrap();
316
317        let loader = ConfigLoader::new()
318            .with_config_paths(vec![&config_file])
319            .with_env_loading(false);
320
321        let loaded_config = loader.load().unwrap();
322        assert_eq!(loaded_config.server.name, "file-server");
323    }
324
325    #[test]
326    fn test_config_loader_precedence() {
327        let _lock = ENV_MUTEX.lock().unwrap();
328
329        let temp_dir = TempDir::new().unwrap();
330        let config_file = temp_dir.path().join("test-config.json");
331
332        // Create a test configuration file
333        let mut file_config = McpServerConfig::default();
334        file_config.server.name = "file-server".to_string();
335        file_config.to_file(&config_file, "json").unwrap();
336
337        // Set environment variable
338        std::env::set_var("MCP_SERVER_NAME", "env-server");
339
340        let loader = ConfigLoader::new()
341            .with_config_paths(vec![&config_file])
342            .with_config_paths::<String>(vec![]); // Clear default paths
343
344        let loaded_config = loader.load().unwrap();
345        // Environment should take precedence
346        assert_eq!(loaded_config.server.name, "env-server");
347
348        // Clean up
349        std::env::remove_var("MCP_SERVER_NAME");
350    }
351
352    #[test]
353    fn test_get_default_config_paths() {
354        let paths = ConfigLoader::get_default_config_paths();
355        assert!(!paths.is_empty());
356        assert!(paths
357            .iter()
358            .any(|p| p.file_name().unwrap() == "mcp-config.json"));
359        assert!(paths
360            .iter()
361            .any(|p| p.file_name().unwrap() == "mcp-config.yaml"));
362    }
363
364    #[test]
365    fn test_get_user_config_dir() {
366        let user_dir = ConfigLoader::get_user_config_dir();
367        assert!(user_dir.to_string_lossy().contains("things3-mcp"));
368    }
369
370    #[test]
371    fn test_get_system_config_dir() {
372        let system_dir = ConfigLoader::get_system_config_dir();
373        assert!(system_dir.to_string_lossy().contains("things3-mcp"));
374    }
375
376    #[test]
377    fn test_create_sample_config() {
378        let temp_dir = TempDir::new().unwrap();
379        let json_file = temp_dir.path().join("sample.json");
380        let yaml_file = temp_dir.path().join("sample.yaml");
381
382        ConfigLoader::create_sample_config(&json_file, "json").unwrap();
383        ConfigLoader::create_sample_config(&yaml_file, "yaml").unwrap();
384
385        assert!(json_file.exists());
386        assert!(yaml_file.exists());
387    }
388
389    #[test]
390    fn test_load_config() {
391        let config = load_config().unwrap();
392        assert!(!config.server.name.is_empty());
393    }
394
395    #[test]
396    fn test_load_config_from_env() {
397        let _lock = ENV_MUTEX.lock().unwrap();
398
399        std::env::set_var("MCP_SERVER_NAME", "env-test");
400        let config = load_config_from_env().unwrap();
401        assert_eq!(config.server.name, "env-test");
402        std::env::remove_var("MCP_SERVER_NAME");
403    }
404
405    #[test]
406    fn test_config_loader_with_validation_disabled() {
407        let loader = ConfigLoader::new().with_validation(false);
408        let config = loader.load().unwrap();
409        assert!(!config.server.name.is_empty());
410    }
411
412    #[test]
413    fn test_config_loader_with_env_loading_disabled() {
414        let loader = ConfigLoader::new().with_env_loading(false);
415        let config = loader.load().unwrap();
416        // Should still load from files and defaults
417        assert!(!config.server.name.is_empty());
418    }
419
420    #[test]
421    fn test_config_loader_invalid_json_file() {
422        let temp_dir = TempDir::new().unwrap();
423        let config_file = temp_dir.path().join("invalid.json");
424
425        // Write invalid JSON
426        std::fs::write(&config_file, "{ invalid json }").unwrap();
427
428        let loader = ConfigLoader::new()
429            .with_config_paths(vec![&config_file])
430            .with_env_loading(false);
431
432        // Should handle invalid JSON gracefully and continue with defaults
433        let config = loader.load().unwrap();
434        assert!(!config.server.name.is_empty());
435    }
436
437    #[test]
438    fn test_config_loader_invalid_yaml_file() {
439        let temp_dir = TempDir::new().unwrap();
440        let config_file = temp_dir.path().join("invalid.yaml");
441
442        // Write invalid YAML
443        std::fs::write(&config_file, "invalid: yaml: content: [").unwrap();
444
445        let loader = ConfigLoader::new()
446            .with_config_paths(vec![&config_file])
447            .with_env_loading(false);
448
449        // Should handle invalid YAML gracefully and continue with defaults
450        let config = loader.load().unwrap();
451        assert!(!config.server.name.is_empty());
452    }
453
454    #[test]
455    fn test_config_loader_file_permission_error() {
456        let temp_dir = TempDir::new().unwrap();
457        let config_file = temp_dir.path().join("permission.json");
458
459        // Create file first
460        let mut config = McpServerConfig::default();
461        config.server.name = "test".to_string();
462        config.to_file(&config_file, "json").unwrap();
463
464        // Remove read permission (Unix only)
465        #[cfg(unix)]
466        {
467            use std::os::unix::fs::PermissionsExt;
468            let mut perms = std::fs::metadata(&config_file).unwrap().permissions();
469            perms.set_mode(0o000); // No permissions
470            std::fs::set_permissions(&config_file, perms).unwrap();
471        }
472
473        let loader = ConfigLoader::new()
474            .with_config_paths(vec![&config_file])
475            .with_env_loading(false);
476
477        // Should handle permission error gracefully and continue with defaults
478        let config = loader.load().unwrap();
479        assert!(!config.server.name.is_empty());
480
481        // Restore permissions for cleanup
482        #[cfg(unix)]
483        {
484            use std::os::unix::fs::PermissionsExt;
485            let mut perms = std::fs::metadata(&config_file).unwrap().permissions();
486            perms.set_mode(0o644);
487            std::fs::set_permissions(&config_file, perms).unwrap();
488        }
489    }
490
491    #[test]
492    fn test_config_loader_multiple_files_precedence() {
493        let temp_dir = TempDir::new().unwrap();
494        let file1 = temp_dir.path().join("config1.json");
495        let file2 = temp_dir.path().join("config2.json");
496
497        // Create two config files with different values
498        let mut config1 = McpServerConfig::default();
499        config1.server.name = "config1".to_string();
500        config1.to_file(&file1, "json").unwrap();
501
502        let mut config2 = McpServerConfig::default();
503        config2.server.name = "config2".to_string();
504        config2.to_file(&file2, "json").unwrap();
505
506        // Load with both files - later files should take precedence
507        let loader = ConfigLoader::new()
508            .with_config_paths(vec![&file1, &file2])
509            .with_env_loading(false);
510
511        let config = loader.load().unwrap();
512        assert_eq!(config.server.name, "config2");
513    }
514
515    #[test]
516    fn test_config_loader_empty_config_paths() {
517        let loader = ConfigLoader::new()
518            .with_config_paths::<String>(vec![])
519            .with_env_loading(false);
520
521        // Should load with defaults only
522        let config = loader.load().unwrap();
523        assert!(!config.server.name.is_empty());
524    }
525
526    #[test]
527    fn test_config_loader_validation_error() {
528        // Test validation by creating a config with invalid values directly
529        let mut invalid_config = McpServerConfig::default();
530        invalid_config.server.name = String::new(); // This should fail validation
531
532        let loader = ConfigLoader::new()
533            .with_base_config(invalid_config)
534            .with_config_paths::<String>(vec![])
535            .with_env_loading(false);
536
537        // Should fail validation
538        let result = loader.load();
539        assert!(result.is_err());
540        let error = result.unwrap_err();
541        assert!(matches!(error, ThingsError::Configuration { .. }));
542    }
543
544    #[test]
545    fn test_config_loader_without_validation() {
546        let _lock = ENV_MUTEX.lock().unwrap();
547
548        // Clear any existing environment variables first
549        std::env::remove_var("MCP_SERVER_NAME");
550
551        // Create a config with invalid values directly
552        let mut invalid_config = McpServerConfig::default();
553        invalid_config.server.name = String::new(); // This should fail validation
554
555        let loader = ConfigLoader::new()
556            .with_base_config(invalid_config)
557            .with_config_paths::<String>(vec![])
558            .with_env_loading(false)
559            .with_validation(false);
560
561        // Should succeed without validation
562        let config = loader.load().unwrap();
563        assert_eq!(config.server.name, "");
564    }
565
566    #[test]
567    fn test_config_loader_env_variable_edge_cases() {
568        let _lock = ENV_MUTEX.lock().unwrap();
569
570        // Clear any existing environment variables first
571        std::env::remove_var("MCP_SERVER_NAME");
572
573        // Test empty environment variable
574        std::env::set_var("MCP_SERVER_NAME", "");
575        let config = load_config_from_env().unwrap();
576        assert_eq!(config.server.name, "");
577        std::env::remove_var("MCP_SERVER_NAME");
578
579        // Test very long environment variable
580        let long_name = "a".repeat(1000);
581        std::env::set_var("MCP_SERVER_NAME", &long_name);
582        let config = load_config_from_env().unwrap();
583        assert_eq!(config.server.name, long_name);
584        std::env::remove_var("MCP_SERVER_NAME");
585
586        // Test special characters
587        std::env::set_var("MCP_SERVER_NAME", "test-server-123_!@#$%^&*()");
588        let config = load_config_from_env().unwrap();
589        assert_eq!(config.server.name, "test-server-123_!@#$%^&*()");
590        std::env::remove_var("MCP_SERVER_NAME");
591    }
592
593    #[test]
594    fn test_config_loader_create_all_sample_configs() {
595        let temp_dir = TempDir::new().unwrap();
596        let original_dir = std::env::current_dir().unwrap();
597
598        // Change to temp directory
599        std::env::set_current_dir(temp_dir.path()).unwrap();
600
601        // Create sample configs
602        let result = ConfigLoader::create_all_sample_configs();
603        assert!(result.is_ok());
604
605        // Check that files were created
606        assert!(PathBuf::from("mcp-config.json").exists());
607        assert!(PathBuf::from("mcp-config.yaml").exists());
608
609        // Restore original directory
610        std::env::set_current_dir(original_dir).unwrap();
611    }
612
613    #[test]
614    fn test_config_loader_create_sample_config_json() {
615        let temp_dir = TempDir::new().unwrap();
616        let json_file = temp_dir.path().join("sample.json");
617
618        let result = ConfigLoader::create_sample_config(&json_file, "json");
619        assert!(result.is_ok());
620        assert!(json_file.exists());
621
622        // Verify it's valid JSON
623        let content = std::fs::read_to_string(&json_file).unwrap();
624        let _: serde_json::Value = serde_json::from_str(&content).unwrap();
625    }
626
627    #[test]
628    fn test_config_loader_create_sample_config_yaml() {
629        let temp_dir = TempDir::new().unwrap();
630        let yaml_file = temp_dir.path().join("sample.yaml");
631
632        let result = ConfigLoader::create_sample_config(&yaml_file, "yaml");
633        assert!(result.is_ok());
634        assert!(yaml_file.exists());
635
636        // Verify it's valid YAML
637        let content = std::fs::read_to_string(&yaml_file).unwrap();
638        let _: serde_yaml::Value = serde_yaml::from_str(&content).unwrap();
639    }
640
641    #[test]
642    fn test_config_loader_create_sample_config_invalid_format() {
643        let temp_dir = TempDir::new().unwrap();
644        let file = temp_dir.path().join("sample.txt");
645
646        let result = ConfigLoader::create_sample_config(&file, "invalid");
647        assert!(result.is_err());
648    }
649
650    #[test]
651    fn test_config_loader_directory_creation_error() {
652        // Test with a path that should fail directory creation
653        let invalid_path = PathBuf::from("/root/nonexistent/things3-mcp");
654
655        // This should fail on most systems due to permissions
656        let result = ConfigLoader::create_sample_config(&invalid_path, "json");
657        assert!(result.is_err());
658    }
659}