Skip to main content

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 serial_test::serial;
272    use std::sync::Mutex;
273    use tempfile::TempDir;
274
275    // Global mutex to synchronize environment variable access across tests
276    static ENV_MUTEX: Mutex<()> = Mutex::new(());
277
278    #[test]
279    fn test_config_loader_default() {
280        let loader = ConfigLoader::new();
281        assert!(loader.load_from_env);
282        assert!(loader.validate);
283        assert!(!loader.config_paths.is_empty());
284    }
285
286    #[test]
287    fn test_config_loader_with_base_config() {
288        let _lock = ENV_MUTEX.lock().unwrap();
289
290        // Clear any existing environment variables
291        std::env::remove_var("MCP_SERVER_NAME");
292
293        let mut base_config = McpServerConfig::default();
294        base_config.server.name = "test-server".to_string();
295
296        let loader = ConfigLoader::new()
297            .with_base_config(base_config.clone())
298            .with_config_paths::<String>(vec![])
299            .without_env_loading();
300
301        // Debug: Check if load_from_env is actually false
302        assert!(!loader.load_from_env);
303
304        let loaded_config = loader.load().unwrap();
305        assert_eq!(loaded_config.server.name, "test-server");
306    }
307
308    #[test]
309    fn test_config_loader_with_custom_paths() {
310        let temp_dir = TempDir::new().unwrap();
311        let config_file = temp_dir.path().join("test-config.json");
312
313        // Create a test configuration file
314        let mut test_config = McpServerConfig::default();
315        test_config.server.name = "file-server".to_string();
316        test_config.to_file(&config_file, "json").unwrap();
317
318        let loader = ConfigLoader::new()
319            .with_config_paths(vec![&config_file])
320            .with_env_loading(false);
321
322        let loaded_config = loader.load().unwrap();
323        assert_eq!(loaded_config.server.name, "file-server");
324    }
325
326    #[test]
327    fn test_config_loader_precedence() {
328        let _lock = ENV_MUTEX.lock().unwrap();
329
330        let temp_dir = TempDir::new().unwrap();
331        let config_file = temp_dir.path().join("test-config.json");
332
333        // Create a test configuration file
334        let mut file_config = McpServerConfig::default();
335        file_config.server.name = "file-server".to_string();
336        file_config.to_file(&config_file, "json").unwrap();
337
338        // Set environment variable
339        std::env::set_var("MCP_SERVER_NAME", "env-server");
340
341        let loader = ConfigLoader::new()
342            .with_config_paths(vec![&config_file])
343            .with_config_paths::<String>(vec![]); // Clear default paths
344
345        let loaded_config = loader.load().unwrap();
346        // Environment should take precedence
347        assert_eq!(loaded_config.server.name, "env-server");
348
349        // Clean up
350        std::env::remove_var("MCP_SERVER_NAME");
351    }
352
353    #[test]
354    fn test_get_default_config_paths() {
355        let paths = ConfigLoader::get_default_config_paths();
356        assert!(!paths.is_empty());
357        assert!(paths
358            .iter()
359            .any(|p| p.file_name().unwrap() == "mcp-config.json"));
360        assert!(paths
361            .iter()
362            .any(|p| p.file_name().unwrap() == "mcp-config.yaml"));
363    }
364
365    #[test]
366    fn test_get_user_config_dir() {
367        let user_dir = ConfigLoader::get_user_config_dir();
368        assert!(user_dir.to_string_lossy().contains("things3-mcp"));
369    }
370
371    #[test]
372    fn test_get_system_config_dir() {
373        let system_dir = ConfigLoader::get_system_config_dir();
374        assert!(system_dir.to_string_lossy().contains("things3-mcp"));
375    }
376
377    #[test]
378    fn test_create_sample_config() {
379        let temp_dir = TempDir::new().unwrap();
380        let json_file = temp_dir.path().join("sample.json");
381        let yaml_file = temp_dir.path().join("sample.yaml");
382
383        ConfigLoader::create_sample_config(&json_file, "json").unwrap();
384        ConfigLoader::create_sample_config(&yaml_file, "yaml").unwrap();
385
386        assert!(json_file.exists());
387        assert!(yaml_file.exists());
388    }
389
390    #[test]
391    fn test_load_config() {
392        let config = load_config().unwrap();
393        assert!(!config.server.name.is_empty());
394    }
395
396    #[test]
397    fn test_load_config_from_env() {
398        let _lock = ENV_MUTEX.lock().unwrap();
399
400        std::env::set_var("MCP_SERVER_NAME", "env-test");
401        let config = load_config_from_env().unwrap();
402        assert_eq!(config.server.name, "env-test");
403        std::env::remove_var("MCP_SERVER_NAME");
404    }
405
406    #[test]
407    fn test_config_loader_with_validation_disabled() {
408        let loader = ConfigLoader::new().with_validation(false);
409        let config = loader.load().unwrap();
410        assert!(!config.server.name.is_empty());
411    }
412
413    #[test]
414    fn test_config_loader_with_env_loading_disabled() {
415        let loader = ConfigLoader::new().with_env_loading(false);
416        let config = loader.load().unwrap();
417        // Should still load from files and defaults
418        assert!(!config.server.name.is_empty());
419    }
420
421    #[test]
422    fn test_config_loader_invalid_json_file() {
423        let temp_dir = TempDir::new().unwrap();
424        let config_file = temp_dir.path().join("invalid.json");
425
426        // Write invalid JSON
427        std::fs::write(&config_file, "{ invalid json }").unwrap();
428
429        let loader = ConfigLoader::new()
430            .with_config_paths(vec![&config_file])
431            .with_env_loading(false);
432
433        // Should handle invalid JSON gracefully and continue with defaults
434        let config = loader.load().unwrap();
435        assert!(!config.server.name.is_empty());
436    }
437
438    #[test]
439    fn test_config_loader_invalid_yaml_file() {
440        let temp_dir = TempDir::new().unwrap();
441        let config_file = temp_dir.path().join("invalid.yaml");
442
443        // Write invalid YAML
444        std::fs::write(&config_file, "invalid: yaml: content: [").unwrap();
445
446        let loader = ConfigLoader::new()
447            .with_config_paths(vec![&config_file])
448            .with_env_loading(false);
449
450        // Should handle invalid YAML gracefully and continue with defaults
451        let config = loader.load().unwrap();
452        assert!(!config.server.name.is_empty());
453    }
454
455    #[test]
456    fn test_config_loader_file_permission_error() {
457        let temp_dir = TempDir::new().unwrap();
458        let config_file = temp_dir.path().join("permission.json");
459
460        // Create file first
461        let mut config = McpServerConfig::default();
462        config.server.name = "test".to_string();
463        config.to_file(&config_file, "json").unwrap();
464
465        // Remove read permission (Unix only)
466        #[cfg(unix)]
467        {
468            use std::os::unix::fs::PermissionsExt;
469            let mut perms = std::fs::metadata(&config_file).unwrap().permissions();
470            perms.set_mode(0o000); // No permissions
471            std::fs::set_permissions(&config_file, perms).unwrap();
472        }
473
474        let loader = ConfigLoader::new()
475            .with_config_paths(vec![&config_file])
476            .with_env_loading(false);
477
478        // Should handle permission error gracefully and continue with defaults
479        let config = loader.load().unwrap();
480        assert!(!config.server.name.is_empty());
481
482        // Restore permissions for cleanup
483        #[cfg(unix)]
484        {
485            use std::os::unix::fs::PermissionsExt;
486            let mut perms = std::fs::metadata(&config_file).unwrap().permissions();
487            perms.set_mode(0o644);
488            std::fs::set_permissions(&config_file, perms).unwrap();
489        }
490    }
491
492    #[test]
493    fn test_config_loader_multiple_files_precedence() {
494        let temp_dir = TempDir::new().unwrap();
495        let file1 = temp_dir.path().join("config1.json");
496        let file2 = temp_dir.path().join("config2.json");
497
498        // Create two config files with different values
499        let mut config1 = McpServerConfig::default();
500        config1.server.name = "config1".to_string();
501        config1.to_file(&file1, "json").unwrap();
502
503        let mut config2 = McpServerConfig::default();
504        config2.server.name = "config2".to_string();
505        config2.to_file(&file2, "json").unwrap();
506
507        // Load with both files - later files should take precedence
508        let loader = ConfigLoader::new()
509            .with_config_paths(vec![&file1, &file2])
510            .with_env_loading(false);
511
512        let config = loader.load().unwrap();
513        assert_eq!(config.server.name, "config2");
514    }
515
516    #[test]
517    fn test_config_loader_empty_config_paths() {
518        let loader = ConfigLoader::new()
519            .with_config_paths::<String>(vec![])
520            .with_env_loading(false);
521
522        // Should load with defaults only
523        let config = loader.load().unwrap();
524        assert!(!config.server.name.is_empty());
525    }
526
527    #[test]
528    fn test_config_loader_validation_error() {
529        // Test validation by creating a config with invalid values directly
530        let mut invalid_config = McpServerConfig::default();
531        invalid_config.server.name = String::new(); // This should fail validation
532
533        let loader = ConfigLoader::new()
534            .with_base_config(invalid_config)
535            .with_config_paths::<String>(vec![])
536            .with_env_loading(false);
537
538        // Should fail validation
539        let result = loader.load();
540        assert!(result.is_err());
541        let error = result.unwrap_err();
542        assert!(matches!(error, ThingsError::Configuration { .. }));
543    }
544
545    #[test]
546    fn test_config_loader_without_validation() {
547        let _lock = ENV_MUTEX.lock().unwrap();
548
549        // Clear any existing environment variables first
550        std::env::remove_var("MCP_SERVER_NAME");
551
552        // Create a config with invalid values directly
553        let mut invalid_config = McpServerConfig::default();
554        invalid_config.server.name = String::new(); // This should fail validation
555
556        let loader = ConfigLoader::new()
557            .with_base_config(invalid_config)
558            .with_config_paths::<String>(vec![])
559            .with_env_loading(false)
560            .with_validation(false);
561
562        // Should succeed without validation
563        let config = loader.load().unwrap();
564        assert_eq!(config.server.name, "");
565    }
566
567    #[test]
568    fn test_config_loader_env_variable_edge_cases() {
569        let _lock = ENV_MUTEX.lock().unwrap();
570
571        // Clear any existing environment variables first
572        std::env::remove_var("MCP_SERVER_NAME");
573
574        // Test empty environment variable
575        std::env::set_var("MCP_SERVER_NAME", "");
576        let config = load_config_from_env().unwrap();
577        assert_eq!(config.server.name, "");
578        std::env::remove_var("MCP_SERVER_NAME");
579
580        // Test very long environment variable
581        let long_name = "a".repeat(1000);
582        std::env::set_var("MCP_SERVER_NAME", &long_name);
583        let config = load_config_from_env().unwrap();
584        assert_eq!(config.server.name, long_name);
585        std::env::remove_var("MCP_SERVER_NAME");
586
587        // Test special characters
588        std::env::set_var("MCP_SERVER_NAME", "test-server-123_!@#$%^&*()");
589        let config = load_config_from_env().unwrap();
590        assert_eq!(config.server.name, "test-server-123_!@#$%^&*()");
591        std::env::remove_var("MCP_SERVER_NAME");
592    }
593
594    #[test]
595    #[serial]
596    fn test_config_loader_create_all_sample_configs() {
597        let temp_dir = TempDir::new().unwrap();
598        let original_dir = std::env::current_dir().unwrap();
599        // create_all_sample_configs reads HOME; pin it to the temp dir so leaked
600        // values from other serial tests (e.g. "/nonexistent/home") don't fail the write.
601        let original_home = std::env::var("HOME").ok();
602        std::env::set_var("HOME", temp_dir.path());
603
604        std::env::set_current_dir(temp_dir.path()).unwrap();
605
606        let result = ConfigLoader::create_all_sample_configs();
607        let cwd_json_exists = PathBuf::from("mcp-config.json").exists();
608        let cwd_yaml_exists = PathBuf::from("mcp-config.yaml").exists();
609
610        std::env::set_current_dir(original_dir).unwrap();
611        if let Some(v) = original_home {
612            std::env::set_var("HOME", v);
613        } else {
614            std::env::remove_var("HOME");
615        }
616
617        assert!(result.is_ok());
618        assert!(cwd_json_exists);
619        assert!(cwd_yaml_exists);
620    }
621
622    #[test]
623    fn test_config_loader_create_sample_config_json() {
624        let temp_dir = TempDir::new().unwrap();
625        let json_file = temp_dir.path().join("sample.json");
626
627        let result = ConfigLoader::create_sample_config(&json_file, "json");
628        assert!(result.is_ok());
629        assert!(json_file.exists());
630
631        // Verify it's valid JSON
632        let content = std::fs::read_to_string(&json_file).unwrap();
633        let _: serde_json::Value = serde_json::from_str(&content).unwrap();
634    }
635
636    #[test]
637    fn test_config_loader_create_sample_config_yaml() {
638        let temp_dir = TempDir::new().unwrap();
639        let yaml_file = temp_dir.path().join("sample.yaml");
640
641        let result = ConfigLoader::create_sample_config(&yaml_file, "yaml");
642        assert!(result.is_ok());
643        assert!(yaml_file.exists());
644
645        // Verify it's valid YAML
646        let content = std::fs::read_to_string(&yaml_file).unwrap();
647        let _: serde_yaml::Value = serde_yaml::from_str(&content).unwrap();
648    }
649
650    #[test]
651    fn test_config_loader_create_sample_config_invalid_format() {
652        let temp_dir = TempDir::new().unwrap();
653        let file = temp_dir.path().join("sample.txt");
654
655        let result = ConfigLoader::create_sample_config(&file, "invalid");
656        assert!(result.is_err());
657    }
658
659    #[test]
660    fn test_config_loader_directory_creation_error() {
661        // Test with a path that should fail directory creation
662        let invalid_path = PathBuf::from("/root/nonexistent/things3-mcp");
663
664        // This should fail on most systems due to permissions
665        let result = ConfigLoader::create_sample_config(&invalid_path, "json");
666        assert!(result.is_err());
667    }
668}