use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::error::LorumError;
use serde::de::DeserializeOwned;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum OutputFormat {
#[default]
Yaml,
Json,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct McpServer {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct McpConfig {
#[serde(default)]
pub servers: BTreeMap<String, McpServer>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct HookHandler {
pub matcher: String,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub handler_type: Option<String>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct HooksConfig {
#[serde(default, flatten)]
pub events: BTreeMap<String, Vec<HookHandler>>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct LorumConfig {
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub hooks: HooksConfig,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ProjectConfig {
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default)]
pub exclude: Vec<String>,
}
pub fn resolve_config_dir() -> Result<PathBuf, LorumError> {
let config_dir = if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg)
} else {
let home = dirs::home_dir().ok_or_else(|| LorumError::Other {
message: "cannot determine home directory".into(),
})?;
home.join(".config")
};
if !config_dir.is_absolute() {
return Err(LorumError::Other {
message: "config directory path must be absolute".into(),
});
}
let mut normalized = PathBuf::new();
for component in config_dir.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
other => normalized.push(other.as_os_str()),
}
}
Ok(normalized)
}
pub fn global_config_path() -> Result<PathBuf, LorumError> {
Ok(resolve_config_dir()?.join("lorum").join("config.yaml"))
}
fn load_yaml<T: DeserializeOwned>(path: &Path) -> Result<T, LorumError> {
let contents = std::fs::read_to_string(path)?;
serde_yaml::from_str(&contents).map_err(|e| LorumError::ConfigParse {
format: "yaml".into(),
path: path.to_path_buf(),
source: Box::new(e),
})
}
pub fn load_config(path: &Path) -> Result<LorumConfig, LorumError> {
if !path.exists() {
return Err(LorumError::ConfigNotFound {
path: path.to_path_buf(),
});
}
load_yaml(path)
}
pub fn save_config(path: &Path, config: &LorumConfig) -> Result<(), LorumError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| LorumError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
}
let yaml = serde_yaml::to_string(config).map_err(|e| LorumError::ConfigSerialize {
path: path.to_path_buf(),
source: Box::new(e),
})?;
std::fs::write(path, yaml).map_err(|e| LorumError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
let mut dir = start_dir;
loop {
let candidate = dir.join(".lorum").join("config.yaml");
if candidate.exists() {
return Some(candidate);
}
dir = dir.parent()?;
}
}
pub fn load_project_config(path: &Path) -> Result<Option<ProjectConfig>, LorumError> {
if !path.exists() {
return Ok(None);
}
load_yaml(path).map(Some)
}
pub fn merge_configs(global: &LorumConfig, project: Option<&ProjectConfig>) -> LorumConfig {
let Some(project) = project else {
return global.clone();
};
let mut servers = global.mcp.servers.clone();
for (name, server) in &project.mcp.servers {
servers.insert(name.clone(), server.clone());
}
for name in &project.exclude {
servers.remove(name);
}
let mut events = global.hooks.events.clone();
for (name, handlers) in &project.hooks.events {
events.insert(name.clone(), handlers.clone());
}
LorumConfig {
mcp: McpConfig { servers },
hooks: HooksConfig { events },
}
}
pub fn resolve_effective_config(
config_path: Option<&Path>,
search_start: &Path,
) -> Result<LorumConfig, LorumError> {
if let Some(path) = config_path {
return load_config(path);
}
let global = match load_config(&global_config_path()?) {
Ok(cfg) => cfg,
Err(LorumError::ConfigNotFound { .. }) => LorumConfig::default(),
Err(e) => return Err(e),
};
let project = find_project_config(search_start);
let project_config = project
.as_ref()
.map(|p| load_project_config(p))
.transpose()?
.flatten();
Ok(merge_configs(&global, project_config.as_ref()))
}
pub fn resolve_effective_config_from_cwd(
config_path: Option<&Path>,
) -> Result<LorumConfig, LorumError> {
let cwd = std::env::current_dir().map_err(|e| LorumError::Io { source: e })?;
resolve_effective_config(config_path, &cwd)
}
#[cfg(test)]
mod tests;