use crate::error::{Result, ThingsError};
use crate::mcp_config::McpServerConfig;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
pub struct ConfigLoader {
base_config: McpServerConfig,
config_paths: Vec<PathBuf>,
load_from_env: bool,
validate: bool,
}
impl ConfigLoader {
#[must_use]
pub fn new() -> Self {
Self {
base_config: McpServerConfig::default(),
config_paths: Self::get_default_config_paths(),
load_from_env: true,
validate: true,
}
}
#[must_use]
pub fn with_base_config(mut self, config: McpServerConfig) -> Self {
self.base_config = config;
self
}
#[must_use]
pub fn add_config_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.config_paths.push(path.as_ref().to_path_buf());
self
}
#[must_use]
pub fn with_config_paths<P: AsRef<Path>>(mut self, paths: Vec<P>) -> Self {
self.config_paths = paths
.into_iter()
.map(|p| p.as_ref().to_path_buf())
.collect();
self
}
#[must_use]
pub fn without_env_loading(mut self) -> Self {
self.load_from_env = false;
self
}
#[must_use]
pub fn with_env_loading(mut self, enabled: bool) -> Self {
self.load_from_env = enabled;
self
}
#[must_use]
pub fn with_validation(mut self, enabled: bool) -> Self {
self.validate = enabled;
self
}
pub fn load(&self) -> Result<McpServerConfig> {
let mut config = self.base_config.clone();
info!("Starting configuration loading process");
for path in &self.config_paths {
if path.exists() {
debug!("Loading configuration from file: {}", path.display());
match McpServerConfig::from_file(path) {
Ok(file_config) => {
config.merge_with(&file_config);
info!("Successfully loaded configuration from: {}", path.display());
}
Err(e) => {
warn!(
"Failed to load configuration from {}: {}",
path.display(),
e
);
}
}
} else {
debug!("Configuration file not found: {}", path.display());
}
}
if self.load_from_env {
debug!("Loading configuration from environment variables");
match McpServerConfig::from_env() {
Ok(env_config) => {
config.merge_with(&env_config);
info!("Successfully loaded configuration from environment variables");
}
Err(e) => {
warn!(
"Failed to load configuration from environment variables: {}",
e
);
}
}
}
if self.validate {
debug!("Validating final configuration");
config.validate()?;
info!("Configuration validation passed");
}
info!("Configuration loading completed successfully");
Ok(config)
}
#[must_use]
pub fn get_default_config_paths() -> Vec<PathBuf> {
vec![
PathBuf::from("mcp-config.json"),
PathBuf::from("mcp-config.yaml"),
PathBuf::from("mcp-config.yml"),
Self::get_user_config_dir().join("mcp-config.json"),
Self::get_user_config_dir().join("mcp-config.yaml"),
Self::get_user_config_dir().join("mcp-config.yml"),
Self::get_system_config_dir().join("mcp-config.json"),
Self::get_system_config_dir().join("mcp-config.yaml"),
Self::get_system_config_dir().join("mcp-config.yml"),
]
}
#[must_use]
pub fn get_user_config_dir() -> PathBuf {
if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".config").join("things3-mcp")
} else if let Ok(userprofile) = std::env::var("USERPROFILE") {
PathBuf::from(userprofile)
.join("AppData")
.join("Roaming")
.join("things3-mcp")
} else {
PathBuf::from("~/.config/things3-mcp")
}
}
#[must_use]
pub fn get_system_config_dir() -> PathBuf {
if cfg!(target_os = "macos") {
PathBuf::from("/Library/Application Support/things3-mcp")
} else if cfg!(target_os = "windows") {
PathBuf::from("C:\\ProgramData\\things3-mcp")
} else {
PathBuf::from("/etc/things3-mcp")
}
}
pub fn create_sample_config<P: AsRef<Path>>(path: P, format: &str) -> Result<()> {
let config = McpServerConfig::default();
config.to_file(path, format)?;
Ok(())
}
pub fn create_all_sample_configs() -> Result<()> {
let config = McpServerConfig::default();
let user_config_dir = Self::get_user_config_dir();
std::fs::create_dir_all(&user_config_dir).map_err(|e| {
ThingsError::Io(std::io::Error::other(format!(
"Failed to create user config directory: {e}"
)))
})?;
let sample_files = vec![
(user_config_dir.join("mcp-config.json"), "json"),
(user_config_dir.join("mcp-config.yaml"), "yaml"),
(PathBuf::from("mcp-config.json"), "json"),
(PathBuf::from("mcp-config.yaml"), "yaml"),
];
for (path, format) in sample_files {
config.to_file(&path, format)?;
info!("Created sample configuration file: {}", path.display());
}
Ok(())
}
}
impl Default for ConfigLoader {
fn default() -> Self {
Self::new()
}
}
pub fn load_config() -> Result<McpServerConfig> {
ConfigLoader::new().load()
}
pub fn load_config_with_paths<P: AsRef<Path>>(config_paths: Vec<P>) -> Result<McpServerConfig> {
ConfigLoader::new().with_config_paths(config_paths).load()
}
pub fn load_config_from_env() -> Result<McpServerConfig> {
McpServerConfig::from_env()
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::Mutex;
use tempfile::TempDir;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_config_loader_default() {
let loader = ConfigLoader::new();
assert!(loader.load_from_env);
assert!(loader.validate);
assert!(!loader.config_paths.is_empty());
}
#[test]
fn test_config_loader_with_base_config() {
let _lock = ENV_MUTEX.lock().unwrap();
std::env::remove_var("MCP_SERVER_NAME");
let mut base_config = McpServerConfig::default();
base_config.server.name = "test-server".to_string();
let loader = ConfigLoader::new()
.with_base_config(base_config.clone())
.with_config_paths::<String>(vec![])
.without_env_loading();
assert!(!loader.load_from_env);
let loaded_config = loader.load().unwrap();
assert_eq!(loaded_config.server.name, "test-server");
}
#[test]
fn test_config_loader_with_custom_paths() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("test-config.json");
let mut test_config = McpServerConfig::default();
test_config.server.name = "file-server".to_string();
test_config.to_file(&config_file, "json").unwrap();
let loader = ConfigLoader::new()
.with_config_paths(vec![&config_file])
.with_env_loading(false);
let loaded_config = loader.load().unwrap();
assert_eq!(loaded_config.server.name, "file-server");
}
#[test]
fn test_config_loader_precedence() {
let _lock = ENV_MUTEX.lock().unwrap();
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("test-config.json");
let mut file_config = McpServerConfig::default();
file_config.server.name = "file-server".to_string();
file_config.to_file(&config_file, "json").unwrap();
std::env::set_var("MCP_SERVER_NAME", "env-server");
let loader = ConfigLoader::new()
.with_config_paths(vec![&config_file])
.with_config_paths::<String>(vec![]);
let loaded_config = loader.load().unwrap();
assert_eq!(loaded_config.server.name, "env-server");
std::env::remove_var("MCP_SERVER_NAME");
}
#[test]
fn test_get_default_config_paths() {
let paths = ConfigLoader::get_default_config_paths();
assert!(!paths.is_empty());
assert!(paths
.iter()
.any(|p| p.file_name().unwrap() == "mcp-config.json"));
assert!(paths
.iter()
.any(|p| p.file_name().unwrap() == "mcp-config.yaml"));
}
#[test]
fn test_get_user_config_dir() {
let user_dir = ConfigLoader::get_user_config_dir();
assert!(user_dir.to_string_lossy().contains("things3-mcp"));
}
#[test]
fn test_get_system_config_dir() {
let system_dir = ConfigLoader::get_system_config_dir();
assert!(system_dir.to_string_lossy().contains("things3-mcp"));
}
#[test]
fn test_create_sample_config() {
let temp_dir = TempDir::new().unwrap();
let json_file = temp_dir.path().join("sample.json");
let yaml_file = temp_dir.path().join("sample.yaml");
ConfigLoader::create_sample_config(&json_file, "json").unwrap();
ConfigLoader::create_sample_config(&yaml_file, "yaml").unwrap();
assert!(json_file.exists());
assert!(yaml_file.exists());
}
#[test]
fn test_load_config() {
let config = load_config().unwrap();
assert!(!config.server.name.is_empty());
}
#[test]
fn test_load_config_from_env() {
let _lock = ENV_MUTEX.lock().unwrap();
std::env::set_var("MCP_SERVER_NAME", "env-test");
let config = load_config_from_env().unwrap();
assert_eq!(config.server.name, "env-test");
std::env::remove_var("MCP_SERVER_NAME");
}
#[test]
fn test_config_loader_with_validation_disabled() {
let loader = ConfigLoader::new().with_validation(false);
let config = loader.load().unwrap();
assert!(!config.server.name.is_empty());
}
#[test]
fn test_config_loader_with_env_loading_disabled() {
let loader = ConfigLoader::new().with_env_loading(false);
let config = loader.load().unwrap();
assert!(!config.server.name.is_empty());
}
#[test]
fn test_config_loader_invalid_json_file() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("invalid.json");
std::fs::write(&config_file, "{ invalid json }").unwrap();
let loader = ConfigLoader::new()
.with_config_paths(vec![&config_file])
.with_env_loading(false);
let config = loader.load().unwrap();
assert!(!config.server.name.is_empty());
}
#[test]
fn test_config_loader_invalid_yaml_file() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("invalid.yaml");
std::fs::write(&config_file, "invalid: yaml: content: [").unwrap();
let loader = ConfigLoader::new()
.with_config_paths(vec![&config_file])
.with_env_loading(false);
let config = loader.load().unwrap();
assert!(!config.server.name.is_empty());
}
#[test]
fn test_config_loader_file_permission_error() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("permission.json");
let mut config = McpServerConfig::default();
config.server.name = "test".to_string();
config.to_file(&config_file, "json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&config_file).unwrap().permissions();
perms.set_mode(0o000); std::fs::set_permissions(&config_file, perms).unwrap();
}
let loader = ConfigLoader::new()
.with_config_paths(vec![&config_file])
.with_env_loading(false);
let config = loader.load().unwrap();
assert!(!config.server.name.is_empty());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&config_file).unwrap().permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&config_file, perms).unwrap();
}
}
#[test]
fn test_config_loader_multiple_files_precedence() {
let temp_dir = TempDir::new().unwrap();
let file1 = temp_dir.path().join("config1.json");
let file2 = temp_dir.path().join("config2.json");
let mut config1 = McpServerConfig::default();
config1.server.name = "config1".to_string();
config1.to_file(&file1, "json").unwrap();
let mut config2 = McpServerConfig::default();
config2.server.name = "config2".to_string();
config2.to_file(&file2, "json").unwrap();
let loader = ConfigLoader::new()
.with_config_paths(vec![&file1, &file2])
.with_env_loading(false);
let config = loader.load().unwrap();
assert_eq!(config.server.name, "config2");
}
#[test]
fn test_config_loader_empty_config_paths() {
let loader = ConfigLoader::new()
.with_config_paths::<String>(vec![])
.with_env_loading(false);
let config = loader.load().unwrap();
assert!(!config.server.name.is_empty());
}
#[test]
fn test_config_loader_validation_error() {
let mut invalid_config = McpServerConfig::default();
invalid_config.server.name = String::new();
let loader = ConfigLoader::new()
.with_base_config(invalid_config)
.with_config_paths::<String>(vec![])
.with_env_loading(false);
let result = loader.load();
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error, ThingsError::Configuration { .. }));
}
#[test]
fn test_config_loader_without_validation() {
let _lock = ENV_MUTEX.lock().unwrap();
std::env::remove_var("MCP_SERVER_NAME");
let mut invalid_config = McpServerConfig::default();
invalid_config.server.name = String::new();
let loader = ConfigLoader::new()
.with_base_config(invalid_config)
.with_config_paths::<String>(vec![])
.with_env_loading(false)
.with_validation(false);
let config = loader.load().unwrap();
assert_eq!(config.server.name, "");
}
#[test]
fn test_config_loader_env_variable_edge_cases() {
let _lock = ENV_MUTEX.lock().unwrap();
std::env::remove_var("MCP_SERVER_NAME");
std::env::set_var("MCP_SERVER_NAME", "");
let config = load_config_from_env().unwrap();
assert_eq!(config.server.name, "");
std::env::remove_var("MCP_SERVER_NAME");
let long_name = "a".repeat(1000);
std::env::set_var("MCP_SERVER_NAME", &long_name);
let config = load_config_from_env().unwrap();
assert_eq!(config.server.name, long_name);
std::env::remove_var("MCP_SERVER_NAME");
std::env::set_var("MCP_SERVER_NAME", "test-server-123_!@#$%^&*()");
let config = load_config_from_env().unwrap();
assert_eq!(config.server.name, "test-server-123_!@#$%^&*()");
std::env::remove_var("MCP_SERVER_NAME");
}
#[test]
#[serial]
fn test_config_loader_create_all_sample_configs() {
let temp_dir = TempDir::new().unwrap();
let original_dir = std::env::current_dir().unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", temp_dir.path());
std::env::set_current_dir(temp_dir.path()).unwrap();
let result = ConfigLoader::create_all_sample_configs();
let cwd_json_exists = PathBuf::from("mcp-config.json").exists();
let cwd_yaml_exists = PathBuf::from("mcp-config.yaml").exists();
std::env::set_current_dir(original_dir).unwrap();
if let Some(v) = original_home {
std::env::set_var("HOME", v);
} else {
std::env::remove_var("HOME");
}
assert!(result.is_ok());
assert!(cwd_json_exists);
assert!(cwd_yaml_exists);
}
#[test]
fn test_config_loader_create_sample_config_json() {
let temp_dir = TempDir::new().unwrap();
let json_file = temp_dir.path().join("sample.json");
let result = ConfigLoader::create_sample_config(&json_file, "json");
assert!(result.is_ok());
assert!(json_file.exists());
let content = std::fs::read_to_string(&json_file).unwrap();
let _: serde_json::Value = serde_json::from_str(&content).unwrap();
}
#[test]
fn test_config_loader_create_sample_config_yaml() {
let temp_dir = TempDir::new().unwrap();
let yaml_file = temp_dir.path().join("sample.yaml");
let result = ConfigLoader::create_sample_config(&yaml_file, "yaml");
assert!(result.is_ok());
assert!(yaml_file.exists());
let content = std::fs::read_to_string(&yaml_file).unwrap();
let _: serde_yaml::Value = serde_yaml::from_str(&content).unwrap();
}
#[test]
fn test_config_loader_create_sample_config_invalid_format() {
let temp_dir = TempDir::new().unwrap();
let file = temp_dir.path().join("sample.txt");
let result = ConfigLoader::create_sample_config(&file, "invalid");
assert!(result.is_err());
}
#[test]
fn test_config_loader_directory_creation_error() {
let invalid_path = PathBuf::from("/root/nonexistent/things3-mcp");
let result = ConfigLoader::create_sample_config(&invalid_path, "json");
assert!(result.is_err());
}
}