#[cfg(test)]
use crate::agent::AgentRole;
use crate::error::ApiError;
use crate::logging::LoggingConfig;
#[cfg(test)]
use crate::provider::CompletionOptions;
#[cfg(test)]
use crate::provider::ModelProvider;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[cfg(test)]
use std::sync::Mutex;
pub use crate::agent::AgentConfig;
pub use crate::provider::{ProviderConfig, ProviderType};
mod facade;
mod merge;
mod paths;
mod sources;
mod workspace;
pub use facade::ConfigLoader;
pub use workspace::StorageConfig;
pub mod xdg {
pub use super::paths::xdg_root::*;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MerkleConfig {
pub workspace_root: Option<PathBuf>,
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
#[serde(default)]
pub agents: HashMap<String, AgentConfig>,
#[serde(default)]
pub system: SystemConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemConfig {
#[serde(default = "default_workspace_root")]
pub default_workspace_root: PathBuf,
#[serde(default)]
pub storage: StorageConfig,
}
fn default_workspace_root() -> PathBuf {
PathBuf::from(".")
}
impl Default for SystemConfig {
fn default() -> Self {
Self {
default_workspace_root: default_workspace_root(),
storage: StorageConfig::default(),
}
}
}
impl Default for MerkleConfig {
fn default() -> Self {
Self {
workspace_root: None,
providers: HashMap::new(),
agents: HashMap::new(),
system: SystemConfig::default(),
logging: LoggingConfig::default(),
}
}
}
#[derive(Debug, Clone)]
pub enum ValidationError {
Provider(String, String),
Agent(String, String),
System(String),
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::Provider(name, msg) => {
write!(f, "Provider '{}': {}", name, msg)
}
ValidationError::Agent(name, msg) => {
write!(f, "Agent '{}': {}", name, msg)
}
ValidationError::System(msg) => {
write!(f, "System: {}", msg)
}
}
}
}
impl std::error::Error for ValidationError {}
impl SystemConfig {
pub fn validate(&self) -> Result<(), String> {
if self.storage.store_path.as_os_str().is_empty() {
return Err("Store path cannot be empty".to_string());
}
if self.storage.frames_path.as_os_str().is_empty() {
return Err("Frames path cannot be empty".to_string());
}
if self.storage.artifacts_path.as_os_str().is_empty() {
return Err("Artifacts path cannot be empty".to_string());
}
Ok(())
}
}
impl MerkleConfig {
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut errors = Vec::new();
for (name, provider) in &self.providers {
if let Err(e) = provider.validate() {
errors.push(ValidationError::Provider(name.clone(), e));
}
}
for (name, agent) in &self.agents {
if let Err(e) = agent.validate(&self.providers) {
errors.push(ValidationError::Agent(name.clone(), e));
}
}
if let Err(e) = self.system.validate() {
errors.push(ValidationError::System(e));
}
let mut agent_ids = HashMap::new();
for (name, agent) in &self.agents {
if let Some(existing) = agent_ids.insert(&agent.agent_id, name) {
errors.push(ValidationError::Agent(
name.clone(),
format!(
"Duplicate agent_id '{}' (also defined in '{}')",
agent.agent_id, existing
),
));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
pub struct ConfigManager {
config: Arc<RwLock<MerkleConfig>>,
}
impl ConfigManager {
pub fn new(config: MerkleConfig) -> Self {
Self {
config: Arc::new(RwLock::new(config)),
}
}
pub fn reload(&self, workspace_root: &Path) -> Result<(), ApiError> {
let new_config = ConfigLoader::load(workspace_root)
.map_err(|e| ApiError::ConfigError(format!("Failed to load config: {}", e)))?;
new_config.validate().map_err(|errors| {
let error_msgs: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
ApiError::ConfigError(format!(
"Configuration validation failed:\n{}",
error_msgs.join("\n")
))
})?;
*self.config.write().unwrap() = new_config;
Ok(())
}
pub fn get(&self) -> MerkleConfig {
self.config.read().unwrap().clone()
}
pub fn get_mut(&mut self) -> &mut MerkleConfig {
unimplemented!("Use reload() for configuration updates")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_default_config() {
let config = MerkleConfig::default();
assert!(config.providers.is_empty());
assert!(config.agents.is_empty());
assert_eq!(config.system.default_workspace_root, PathBuf::from("."));
}
#[test]
fn test_provider_config_validation() {
let mut provider = ProviderConfig {
provider_name: Some("test-openai".to_string()),
provider_type: ProviderType::OpenAI,
model: "gpt-4".to_string(),
api_key: Some("test-key".to_string()),
endpoint: None,
default_options: CompletionOptions::default(),
};
assert!(provider.validate().is_ok());
provider.model = "".to_string();
assert!(provider.validate().is_err());
provider.model = "gpt-4".to_string();
provider.endpoint = Some("not-a-url".to_string());
assert!(provider.validate().is_err());
}
#[test]
fn test_agent_config_validation() {
let mut providers = HashMap::new();
providers.insert(
"test-provider".to_string(),
ProviderConfig {
provider_name: Some("test-provider".to_string()),
provider_type: ProviderType::Ollama,
model: "llama2".to_string(),
api_key: None,
endpoint: None,
default_options: CompletionOptions::default(),
},
);
let agent = AgentConfig {
agent_id: "test-agent".to_string(),
role: AgentRole::Writer,
system_prompt: Some("Test prompt".to_string()),
system_prompt_path: None,
metadata: Default::default(),
};
assert!(agent.validate(&providers).is_ok());
let agent_bad = AgentConfig {
agent_id: "test-agent-2".to_string(),
role: AgentRole::Writer,
system_prompt: None,
system_prompt_path: None,
metadata: Default::default(),
};
assert!(agent_bad.validate(&providers).is_err());
let agent_reader = AgentConfig {
agent_id: "test-agent-3".to_string(),
role: AgentRole::Reader,
system_prompt: None,
system_prompt_path: None,
metadata: Default::default(),
};
assert!(agent_reader.validate(&providers).is_ok());
}
#[test]
fn test_config_validation() {
let mut config = MerkleConfig::default();
config.providers.insert(
"test-provider".to_string(),
ProviderConfig {
provider_name: Some("test-provider".to_string()),
provider_type: ProviderType::Ollama,
model: "llama2".to_string(),
api_key: None,
endpoint: None,
default_options: CompletionOptions::default(),
},
);
config.agents.insert(
"test-agent".to_string(),
AgentConfig {
agent_id: "test-agent".to_string(),
role: AgentRole::Writer,
system_prompt: Some("Test".to_string()),
system_prompt_path: None,
metadata: Default::default(),
},
);
assert!(config.validate().is_ok());
config.agents.insert(
"test-agent-2".to_string(),
AgentConfig {
agent_id: "test-agent".to_string(), role: AgentRole::Reader,
system_prompt: None,
system_prompt_path: None,
metadata: Default::default(),
},
);
assert!(config.validate().is_err());
}
#[test]
fn test_provider_to_model_provider() {
let provider_config = ProviderConfig {
provider_name: Some("test-ollama".to_string()),
provider_type: ProviderType::Ollama,
model: "llama2".to_string(),
api_key: None,
endpoint: Some("http://localhost:11434".to_string()),
default_options: CompletionOptions::default(),
};
let model_provider = provider_config.to_model_provider().unwrap();
match model_provider {
ModelProvider::Ollama { model, base_url } => {
assert_eq!(model, "llama2");
assert_eq!(base_url, Some("http://localhost:11434".to_string()));
}
_ => panic!("Wrong provider type"),
}
}
#[test]
fn test_config_loader_default() {
let config = ConfigLoader::default();
assert!(config.providers.is_empty());
assert!(config.agents.is_empty());
}
#[test]
fn test_load_from_toml_file() {
let temp_dir = TempDir::new().unwrap();
let config_file = temp_dir.path().join("test_config.toml");
std::fs::write(
&config_file,
r#"
[system]
default_workspace_root = "."
[system.storage]
store_path = ".meld/store"
frames_path = ".meld/frames"
[providers.test-ollama]
provider_type = "ollama"
model = "llama2"
endpoint = "http://localhost:11434"
[agents.test-agent]
agent_id = "test-agent"
role = "Writer"
system_prompt = "Test prompt"
provider_name = "test-ollama"
"#,
)
.unwrap();
let config = ConfigLoader::load_from_file(&config_file).unwrap();
assert_eq!(config.providers.len(), 1);
assert_eq!(config.agents.len(), 1);
let provider = config.providers.get("test-ollama").unwrap();
assert_eq!(provider.model, "llama2");
let agent = config.agents.get("test-agent").unwrap();
assert_eq!(agent.agent_id, "test-agent");
assert_eq!(agent.system_prompt.as_ref().unwrap(), "Test prompt");
}
#[test]
fn test_xdg_config_path() {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let original_home = std::env::var("HOME").ok();
let test_home = "/test/home";
std::env::set_var("HOME", test_home);
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
}
#[cfg(test)]
static HOME_MUTEX: Mutex<()> = Mutex::new(());
#[test]
fn test_load_with_xdg_config() {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let original_home = std::env::var("HOME").ok();
let workspace_config_dir = workspace_root.join("config");
let workspace_config_file = workspace_config_dir.join("config.toml");
let mock_home = temp_dir.path().join("mock_home");
std::fs::create_dir_all(&mock_home).unwrap();
let mock_home_str = mock_home
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
std::env::set_var("HOME", &mock_home_str);
let xdg_config_dir = mock_home.join(".config").join("meld");
std::fs::create_dir_all(&xdg_config_dir).unwrap();
let xdg_config_file = xdg_config_dir.join("config.toml");
std::fs::write(
&xdg_config_file,
r#"
[system]
default_workspace_root = "."
[system.storage]
store_path = ".meld/store"
frames_path = ".meld/frames"
[providers.xdg-provider]
provider_type = "ollama"
model = "xdg-model"
endpoint = "http://localhost:11434"
"#,
)
.unwrap();
assert!(xdg_config_file.exists(), "XDG config file should exist");
let xdg_path = ConfigLoader::xdg_config_path();
assert!(xdg_path.is_some(), "XDG config path should be found");
assert_eq!(
xdg_path.unwrap(),
xdg_config_file,
"XDG config path should match"
);
let config = ConfigLoader::load(workspace_root).unwrap();
assert!(config.providers.contains_key("xdg-provider"),
"Config should contain xdg-provider. Found providers: {:?}. XDG config file exists: {}, workspace config exists: {}",
config.providers.keys().collect::<Vec<_>>(),
xdg_config_file.exists(),
workspace_config_file.exists());
let provider = config.providers.get("xdg-provider").unwrap();
assert_eq!(provider.model, "xdg-model");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
}
#[test]
fn test_workspace_config_overrides_xdg_config() {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let original_home = std::env::var("HOME").ok();
let mock_home = temp_dir.path().join("mock_home_override");
std::fs::create_dir_all(&mock_home).unwrap();
let mock_home_str = mock_home
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
std::env::set_var("HOME", &mock_home_str);
let xdg_config_dir = mock_home.join(".config").join("meld");
std::fs::create_dir_all(&xdg_config_dir).unwrap();
let xdg_config_file = xdg_config_dir.join("config.toml");
std::fs::write(
&xdg_config_file,
r#"
[system]
default_workspace_root = "."
[system.storage]
store_path = ".meld/store"
frames_path = ".meld/frames"
[providers.xdg-provider]
provider_type = "ollama"
model = "xdg-model"
endpoint = "http://localhost:11434"
"#,
)
.unwrap();
let workspace_config_dir = workspace_root.join("config");
std::fs::create_dir_all(&workspace_config_dir).unwrap();
let workspace_config_file = workspace_config_dir.join("config.toml");
std::fs::write(
&workspace_config_file,
r#"
[providers.xdg-provider]
provider_type = "ollama"
model = "workspace-model"
endpoint = "http://localhost:11434"
"#,
)
.unwrap();
let config = ConfigLoader::load(workspace_root).unwrap();
assert!(config.providers.contains_key("xdg-provider"));
let provider = config.providers.get("xdg-provider").unwrap();
assert_eq!(provider.model, "workspace-model");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
}
#[test]
fn test_load_without_xdg_config() {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let original_home = std::env::var("HOME").ok();
let mock_home = temp_dir.path().join("mock_home_no_config");
std::fs::create_dir_all(&mock_home).unwrap();
let mock_home_str = mock_home
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
std::env::set_var("HOME", &mock_home_str);
let xdg_config_file = mock_home.join(".config").join("meld").join("config.toml");
assert!(
!xdg_config_file.exists(),
"XDG config file should not exist"
);
let config = ConfigLoader::load(workspace_root).unwrap();
assert_eq!(
config.providers.len(),
0,
"Should have no providers when XDG config doesn't exist. Found: {:?}",
config.providers.keys().collect::<Vec<_>>()
);
assert_eq!(config.agents.len(), 0);
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
}
#[test]
fn test_load_without_home_env() {
let _guard = HOME_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let temp_dir = TempDir::new().unwrap();
let workspace_root = temp_dir.path();
let original_home = std::env::var("HOME").ok();
std::env::remove_var("HOME");
assert!(
ConfigLoader::xdg_config_path().is_none(),
"XDG config path should be None when HOME is not set"
);
let config = ConfigLoader::load(workspace_root).unwrap();
assert_eq!(
config.providers.len(),
0,
"Should have no providers when HOME is not set. Found: {:?}",
config.providers.keys().collect::<Vec<_>>()
);
assert_eq!(config.agents.len(), 0);
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
}
}