use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use crate::config::{HooksConfig, McpConfig};
use crate::error::LorumError;
use crate::skills::SkillEntry;
pub mod claude;
pub mod codex;
pub mod cursor;
pub mod json_utils;
pub mod kimi;
pub mod opencode;
pub mod proma;
pub mod toml_utils;
pub mod trae;
pub mod windsurf;
pub trait ToolAdapter: Send + Sync {
fn name(&self) -> &str;
fn config_paths(&self) -> Vec<PathBuf>;
fn read_mcp(&self) -> Result<McpConfig, LorumError>;
fn write_mcp(&self, config: &McpConfig) -> Result<(), LorumError>;
}
static ALL_ADAPTERS: LazyLock<Vec<Box<dyn ToolAdapter>>> = LazyLock::new(|| {
vec![
Box::new(claude::ClaudeAdapter),
Box::new(codex::CodexAdapter),
Box::new(cursor::CursorAdapter::new()),
Box::new(proma::PromaAdapter),
Box::new(kimi::KimiAdapter),
Box::new(opencode::OpencodeAdapter::new()),
Box::new(trae::TraeAdapter::new()),
Box::new(windsurf::WindsurfAdapter),
]
});
pub fn all_adapters() -> &'static [Box<dyn ToolAdapter>] {
&ALL_ADAPTERS
}
pub fn find_adapter(name: &str) -> Option<&'static dyn ToolAdapter> {
ALL_ADAPTERS
.iter()
.find(|a| a.name() == name)
.map(|a| a.as_ref())
}
pub trait RulesAdapter: Send + Sync {
fn name(&self) -> &str;
fn rules_path(&self, project_root: &Path) -> PathBuf;
fn read_rules(&self, project_root: &Path) -> Result<Option<String>, LorumError>;
fn write_rules(&self, project_root: &Path, content: &str) -> Result<(), LorumError>;
}
static ALL_RULES_ADAPTERS: LazyLock<Vec<Box<dyn RulesAdapter>>> = LazyLock::new(|| {
vec![
Box::new(cursor::CursorRulesAdapter),
Box::new(windsurf::WindsurfRulesAdapter),
Box::new(codex::CodexRulesAdapter),
]
});
pub fn all_rules_adapters() -> &'static [Box<dyn RulesAdapter>] {
&ALL_RULES_ADAPTERS
}
pub fn find_rules_adapter(name: &str) -> Option<&'static dyn RulesAdapter> {
ALL_RULES_ADAPTERS
.iter()
.find(|a| a.name() == name)
.map(|a| a.as_ref())
}
pub(crate) fn read_rules_file(path: &Path) -> Result<Option<String>, LorumError> {
match std::fs::read_to_string(path) {
Ok(content) => Ok(Some(content)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub(crate) fn write_rules_file(path: &Path, content: &str) -> 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,
})?;
}
std::fs::write(path, content).map_err(|e| LorumError::ConfigWrite {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub trait HooksAdapter: Send + Sync {
fn name(&self) -> &str;
fn config_paths(&self) -> Vec<PathBuf>;
fn read_hooks(&self) -> Result<HooksConfig, LorumError>;
fn write_hooks(&self, config: &HooksConfig) -> Result<(), LorumError>;
}
static ALL_HOOKS_ADAPTERS: LazyLock<Vec<Box<dyn HooksAdapter>>> =
LazyLock::new(|| vec![Box::new(claude::ClaudeAdapter), Box::new(kimi::KimiAdapter)]);
pub fn all_hooks_adapters() -> &'static [Box<dyn HooksAdapter>] {
&ALL_HOOKS_ADAPTERS
}
pub fn find_hooks_adapter(name: &str) -> Option<&'static dyn HooksAdapter> {
ALL_HOOKS_ADAPTERS
.iter()
.find(|a| a.name() == name)
.map(|a| a.as_ref())
}
pub trait SkillsAdapter: Send + Sync {
fn name(&self) -> &str;
fn skills_base_dir(&self) -> Option<PathBuf>;
fn read_skills(&self) -> Result<Vec<SkillEntry>, LorumError>;
fn write_skill(&self, name: &str, source_dir: &Path) -> Result<(), LorumError>;
fn remove_skill(&self, name: &str) -> Result<(), LorumError>;
}
static ALL_SKILLS_ADAPTERS: LazyLock<Vec<Box<dyn SkillsAdapter>>> = LazyLock::new(|| {
vec![
Box::new(claude::ClaudeSkillsAdapter),
Box::new(proma::PromaSkillsAdapter),
]
});
pub fn all_skills_adapters() -> &'static [Box<dyn SkillsAdapter>] {
&ALL_SKILLS_ADAPTERS
}
pub fn find_skills_adapter(name: &str) -> Option<&'static dyn SkillsAdapter> {
ALL_SKILLS_ADAPTERS
.iter()
.find(|a| a.name() == name)
.map(|a| a.as_ref())
}
pub fn all_adapter_tool_names() -> Vec<String> {
let mut names = std::collections::BTreeSet::new();
for a in all_adapters() {
names.insert(a.name().to_string());
}
for a in all_rules_adapters() {
names.insert(a.name().to_string());
}
for a in all_hooks_adapters() {
names.insert(a.name().to_string());
}
for a in all_skills_adapters() {
names.insert(a.name().to_string());
}
names.into_iter().collect()
}
pub fn kebab_to_pascal(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut upper_next = true;
for c in s.chars() {
if c == '-' {
upper_next = true;
} else if upper_next {
for uc in c.to_uppercase() {
result.push(uc);
}
upper_next = false;
} else {
for lc in c.to_lowercase() {
result.push(lc);
}
}
}
result
}
pub fn pascal_to_kebab(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 2);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('-');
}
result.extend(c.to_lowercase());
}
result
}
#[cfg(test)]
pub(crate) mod test_utils {
use crate::config::McpServer;
pub fn make_server(command: &str, args: &[&str], env: &[(&str, &str)]) -> McpServer {
McpServer {
command: command.into(),
args: args.iter().map(|s| (*s).into()).collect(),
env: env
.iter()
.map(|(k, v)| ((*k).into(), (*v).into()))
.collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn all_adapters_returns_known_adapters() {
let adapters = all_adapters();
assert_eq!(adapters.len(), 8);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude-code"));
assert!(names.contains(&"codex"));
assert!(names.contains(&"cursor"));
assert!(names.contains(&"proma"));
assert!(names.contains(&"kimi"));
assert!(names.contains(&"opencode"));
assert!(names.contains(&"trae"));
assert!(names.contains(&"windsurf"));
}
#[test]
fn find_adapter_finds_known() {
assert_eq!(find_adapter("claude-code").unwrap().name(), "claude-code");
assert_eq!(find_adapter("codex").unwrap().name(), "codex");
assert_eq!(find_adapter("cursor").unwrap().name(), "cursor");
assert_eq!(find_adapter("proma").unwrap().name(), "proma");
assert_eq!(find_adapter("kimi").unwrap().name(), "kimi");
assert_eq!(find_adapter("opencode").unwrap().name(), "opencode");
assert_eq!(find_adapter("trae").unwrap().name(), "trae");
assert_eq!(find_adapter("windsurf").unwrap().name(), "windsurf");
}
#[test]
fn find_adapter_returns_none_for_unknown() {
assert!(find_adapter("nonexistent-tool").is_none());
}
#[test]
fn find_adapter_returns_static_ref() {
let a = find_adapter("claude-code");
let b = find_adapter("claude-code");
assert!(a.is_some());
assert_eq!(a.unwrap().name(), b.unwrap().name());
}
#[test]
fn all_rules_adapters_returns_three() {
let adapters = all_rules_adapters();
assert_eq!(adapters.len(), 3);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"cursor"));
assert!(names.contains(&"windsurf"));
assert!(names.contains(&"codex"));
}
#[test]
fn find_rules_adapter_finds_known() {
assert_eq!(find_rules_adapter("cursor").unwrap().name(), "cursor");
assert_eq!(find_rules_adapter("windsurf").unwrap().name(), "windsurf");
assert_eq!(find_rules_adapter("codex").unwrap().name(), "codex");
}
#[test]
fn find_rules_adapter_returns_none_for_unknown() {
assert!(find_rules_adapter("nonexistent").is_none());
}
#[test]
fn all_hooks_adapters_returns_two() {
let adapters = all_hooks_adapters();
assert_eq!(adapters.len(), 2);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude-code"));
assert!(names.contains(&"kimi"));
}
#[test]
fn find_hooks_adapter_finds_known() {
assert_eq!(
find_hooks_adapter("claude-code").unwrap().name(),
"claude-code"
);
assert_eq!(find_hooks_adapter("kimi").unwrap().name(), "kimi");
}
#[test]
fn find_hooks_adapter_returns_none_for_unknown() {
assert!(find_hooks_adapter("nonexistent").is_none());
}
#[test]
fn all_skills_adapters_returns_two() {
let adapters = all_skills_adapters();
assert_eq!(adapters.len(), 2);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude-code"));
assert!(names.contains(&"proma"));
}
#[test]
fn find_skills_adapter_finds_known() {
assert_eq!(
find_skills_adapter("claude-code").unwrap().name(),
"claude-code"
);
assert_eq!(find_skills_adapter("proma").unwrap().name(), "proma");
}
#[test]
fn find_skills_adapter_returns_none_for_unknown() {
assert!(find_skills_adapter("nonexistent").is_none());
}
#[test]
fn read_rules_file_reads_existing_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rules.md");
fs::write(&path, "# Rules\n").unwrap();
let result = read_rules_file(&path).unwrap();
assert_eq!(result, Some("# Rules\n".to_string()));
}
#[test]
fn read_rules_file_returns_none_for_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.md");
let result = read_rules_file(&path).unwrap();
assert_eq!(result, None);
}
#[test]
fn write_rules_file_creates_file_and_parents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("rules.md");
assert!(!path.exists());
write_rules_file(&path, "# New Rules\n").unwrap();
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content, "# New Rules\n");
}
}