things3_core/
config_loader.rs1use crate::error::{Result, ThingsError};
7use crate::mcp_config::McpServerConfig;
8use std::path::{Path, PathBuf};
9use tracing::{debug, info, warn};
10
11pub struct ConfigLoader {
13 base_config: McpServerConfig,
15 config_paths: Vec<PathBuf>,
17 load_from_env: bool,
19 validate: bool,
21}
22
23impl ConfigLoader {
24 #[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 #[must_use]
37 pub fn with_base_config(mut self, config: McpServerConfig) -> Self {
38 self.base_config = config;
39 self
40 }
41
42 #[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 #[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 #[must_use]
61 pub fn without_env_loading(mut self) -> Self {
62 self.load_from_env = false;
63 self
64 }
65
66 #[must_use]
68 pub fn with_env_loading(mut self, enabled: bool) -> Self {
69 self.load_from_env = enabled;
70 self
71 }
72
73 #[must_use]
75 pub fn with_validation(mut self, enabled: bool) -> Self {
76 self.validate = enabled;
77 self
78 }
79
80 pub fn load(&self) -> Result<McpServerConfig> {
85 let mut config = self.base_config.clone();
86 info!("Starting configuration loading process");
87
88 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 }
105 }
106 } else {
107 debug!("Configuration file not found: {}", path.display());
108 }
109 }
110
111 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 }
126 }
127 }
128
129 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 #[must_use]
142 pub fn get_default_config_paths() -> Vec<PathBuf> {
143 vec![
144 PathBuf::from("mcp-config.json"),
146 PathBuf::from("mcp-config.yaml"),
147 PathBuf::from("mcp-config.yml"),
148 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 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 #[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 PathBuf::from(userprofile)
167 .join("AppData")
168 .join("Roaming")
169 .join("things3-mcp")
170 } else {
171 PathBuf::from("~/.config/things3-mcp")
173 }
174 }
175
176 #[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 PathBuf::from("/etc/things3-mcp")
186 }
187 }
188
189 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 pub fn create_all_sample_configs() -> Result<()> {
208 let config = McpServerConfig::default();
209
210 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 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
241pub fn load_config() -> Result<McpServerConfig> {
246 ConfigLoader::new().load()
247}
248
249pub 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
260pub 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 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 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 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 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 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 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![]); let loaded_config = loader.load().unwrap();
346 assert_eq!(loaded_config.server.name, "env-server");
348
349 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 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 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 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 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 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 let mut config = McpServerConfig::default();
462 config.server.name = "test".to_string();
463 config.to_file(&config_file, "json").unwrap();
464
465 #[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); 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 let config = loader.load().unwrap();
480 assert!(!config.server.name.is_empty());
481
482 #[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 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 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 let config = loader.load().unwrap();
524 assert!(!config.server.name.is_empty());
525 }
526
527 #[test]
528 fn test_config_loader_validation_error() {
529 let mut invalid_config = McpServerConfig::default();
531 invalid_config.server.name = String::new(); let loader = ConfigLoader::new()
534 .with_base_config(invalid_config)
535 .with_config_paths::<String>(vec![])
536 .with_env_loading(false);
537
538 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 std::env::remove_var("MCP_SERVER_NAME");
551
552 let mut invalid_config = McpServerConfig::default();
554 invalid_config.server.name = String::new(); 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 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 std::env::remove_var("MCP_SERVER_NAME");
573
574 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 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 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 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 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 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 let invalid_path = PathBuf::from("/root/nonexistent/things3-mcp");
663
664 let result = ConfigLoader::create_sample_config(&invalid_path, "json");
666 assert!(result.is_err());
667 }
668}