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 std::sync::Mutex;
272 use tempfile::TempDir;
273
274 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 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 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 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 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 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![]); let loaded_config = loader.load().unwrap();
345 assert_eq!(loaded_config.server.name, "env-server");
347
348 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 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 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 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 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 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 let mut config = McpServerConfig::default();
461 config.server.name = "test".to_string();
462 config.to_file(&config_file, "json").unwrap();
463
464 #[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); 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 let config = loader.load().unwrap();
479 assert!(!config.server.name.is_empty());
480
481 #[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 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 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 let config = loader.load().unwrap();
523 assert!(!config.server.name.is_empty());
524 }
525
526 #[test]
527 fn test_config_loader_validation_error() {
528 let mut invalid_config = McpServerConfig::default();
530 invalid_config.server.name = String::new(); let loader = ConfigLoader::new()
533 .with_base_config(invalid_config)
534 .with_config_paths::<String>(vec![])
535 .with_env_loading(false);
536
537 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 std::env::remove_var("MCP_SERVER_NAME");
550
551 let mut invalid_config = McpServerConfig::default();
553 invalid_config.server.name = String::new(); 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 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 std::env::remove_var("MCP_SERVER_NAME");
572
573 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 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 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 std::env::set_current_dir(temp_dir.path()).unwrap();
600
601 let result = ConfigLoader::create_all_sample_configs();
603 assert!(result.is_ok());
604
605 assert!(PathBuf::from("mcp-config.json").exists());
607 assert!(PathBuf::from("mcp-config.yaml").exists());
608
609 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 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 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 let invalid_path = PathBuf::from("/root/nonexistent/things3-mcp");
654
655 let result = ConfigLoader::create_sample_config(&invalid_path, "json");
657 assert!(result.is_err());
658 }
659}