use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use crate::config::{HooksConfig, McpConfig};
use crate::error::LorumError;
use crate::skills::SkillEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationIssue {
pub severity: Severity,
pub message: String,
pub path: Option<PathBuf>,
pub line: Option<usize>,
}
pub trait ConfigValidator: Send + Sync {
fn name(&self) -> &str;
fn validate_config(&self) -> Result<Vec<ValidationIssue>, LorumError>;
}
pub fn validate_syntax(path: &Path) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
if !path.exists() {
return issues;
}
if path.is_dir() {
issues.push(ValidationIssue {
severity: Severity::Error,
message: "expected file, found directory".into(),
path: Some(path.to_path_buf()),
line: None,
});
return issues;
}
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) => {
issues.push(ValidationIssue {
severity: Severity::Error,
message: format!("failed to read metadata: {e}"),
path: Some(path.to_path_buf()),
line: None,
});
return issues;
}
};
if metadata.len() == 0 {
issues.push(ValidationIssue {
severity: Severity::Warning,
message: "file is empty".into(),
path: Some(path.to_path_buf()),
line: None,
});
return issues;
}
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
issues.push(ValidationIssue {
severity: Severity::Error,
message: format!("failed to read file: {e}"),
path: Some(path.to_path_buf()),
line: None,
});
return issues;
}
};
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"json" => {
if let Err(e) = serde_json::from_str::<serde_json::Value>(&content) {
issues.push(ValidationIssue {
severity: Severity::Error,
message: format!("invalid JSON: {e}"),
path: Some(path.to_path_buf()),
line: None,
});
}
}
"toml" => {
if let Err(e) = toml::from_str::<toml::Value>(&content) {
issues.push(ValidationIssue {
severity: Severity::Error,
message: format!("invalid TOML: {e}"),
path: Some(path.to_path_buf()),
line: None,
});
}
}
"yaml" | "yml" => {
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
issues.push(ValidationIssue {
severity: Severity::Error,
message: format!("invalid YAML: {e}"),
path: Some(path.to_path_buf()),
line: None,
});
}
}
_ => {
issues.push(ValidationIssue {
severity: Severity::Warning,
message: format!("unknown file extension '{ext}', skipping syntax validation"),
path: Some(path.to_path_buf()),
line: None,
});
}
}
issues
}
pub fn validate_all_syntax(config_paths: &[PathBuf]) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
for path in config_paths {
issues.extend(validate_syntax(path));
}
issues
}
pub fn default_validate_config(
adapter: &dyn ToolAdapter,
) -> Result<Vec<ValidationIssue>, LorumError> {
let paths: Vec<PathBuf> = adapter.config_paths();
Ok(validate_all_syntax(&paths))
}
pub mod claude;
pub mod codex;
pub mod continue_dev;
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::new()),
Box::new(continue_dev::ContinueDevAdapter::new()),
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::new()),
]
});
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(claude::ClaudeRulesAdapter),
Box::new(cursor::CursorRulesAdapter),
Box::new(windsurf::WindsurfRulesAdapter),
Box::new(codex::CodexRulesAdapter),
Box::new(kimi::KimiRulesAdapter),
Box::new(opencode::OpenCodeRulesAdapter),
Box::new(trae::TraeRulesAdapter),
]
});
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>;
fn lorum_to_tool_event(&self, lorum_event: &str) -> Option<String>;
fn tool_to_lorum_event(&self, tool_event: &str) -> Option<String>;
}
static ALL_HOOKS_ADAPTERS: LazyLock<Vec<Box<dyn HooksAdapter>>> = LazyLock::new(|| {
vec![
Box::new(claude::ClaudeAdapter),
Box::new(kimi::KimiAdapter),
Box::new(cursor::CursorAdapter::new()),
Box::new(codex::CodexAdapter::new()),
Box::new(windsurf::WindsurfAdapter::new()),
]
});
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())
}
static ALL_CONFIG_VALIDATORS: LazyLock<Vec<Box<dyn ConfigValidator>>> = LazyLock::new(|| {
vec![
Box::new(claude::ClaudeAdapter) as Box<dyn ConfigValidator>,
Box::new(codex::CodexAdapter::new()) as Box<dyn ConfigValidator>,
Box::new(continue_dev::ContinueDevAdapter::new()) as Box<dyn ConfigValidator>,
Box::new(cursor::CursorAdapter::new()) as Box<dyn ConfigValidator>,
Box::new(proma::PromaAdapter) as Box<dyn ConfigValidator>,
Box::new(kimi::KimiAdapter) as Box<dyn ConfigValidator>,
Box::new(opencode::OpencodeAdapter::new()) as Box<dyn ConfigValidator>,
Box::new(trae::TraeAdapter::new()) as Box<dyn ConfigValidator>,
Box::new(windsurf::WindsurfAdapter::new()) as Box<dyn ConfigValidator>,
]
});
pub fn all_config_validators() -> &'static [Box<dyn ConfigValidator>] {
&ALL_CONFIG_VALIDATORS
}
pub fn find_config_validator(name: &str) -> Option<&'static dyn ConfigValidator> {
ALL_CONFIG_VALIDATORS
.iter()
.find(|v| v.name() == name)
.map(|v| v.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
}
pub fn kebab_to_camel(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut upper_next = false;
for (i, c) in s.chars().enumerate() {
if c == '-' {
upper_next = true;
} else if i == 0 {
for lc in c.to_lowercase() {
result.push(lc);
}
} 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 camel_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(), 9);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude-code"));
assert!(names.contains(&"codex"));
assert!(names.contains(&"continue"));
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("continue").unwrap().name(), "continue");
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_expected_count() {
let adapters = all_rules_adapters();
assert_eq!(adapters.len(), 7);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude-code"));
assert!(names.contains(&"cursor"));
assert!(names.contains(&"windsurf"));
assert!(names.contains(&"codex"));
assert!(names.contains(&"kimi"));
assert!(names.contains(&"opencode"));
assert!(names.contains(&"trae"));
}
#[test]
fn find_rules_adapter_finds_known() {
assert_eq!(
find_rules_adapter("claude-code").unwrap().name(),
"claude-code"
);
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");
assert_eq!(find_rules_adapter("kimi").unwrap().name(), "kimi");
assert_eq!(find_rules_adapter("opencode").unwrap().name(), "opencode");
assert_eq!(find_rules_adapter("trae").unwrap().name(), "trae");
}
#[test]
fn find_rules_adapter_returns_none_for_unknown() {
assert!(find_rules_adapter("nonexistent").is_none());
}
#[test]
fn all_hooks_adapters_returns_five() {
let adapters = all_hooks_adapters();
assert_eq!(adapters.len(), 5);
let names: Vec<_> = adapters.iter().map(|a| a.name()).collect();
assert!(names.contains(&"claude-code"));
assert!(names.contains(&"kimi"));
assert!(names.contains(&"cursor"));
assert!(names.contains(&"codex"));
assert!(names.contains(&"windsurf"));
}
#[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");
assert_eq!(find_hooks_adapter("cursor").unwrap().name(), "cursor");
assert_eq!(find_hooks_adapter("codex").unwrap().name(), "codex");
assert_eq!(find_hooks_adapter("windsurf").unwrap().name(), "windsurf");
}
#[test]
fn find_hooks_adapter_returns_none_for_unknown() {
assert!(find_hooks_adapter("nonexistent").is_none());
}
#[test]
fn test_hooks_event_mapping_claude() {
let adapter = claude::ClaudeAdapter;
assert_eq!(
adapter.lorum_to_tool_event("pre-tool-use"),
Some("PreToolUse".to_string())
);
assert_eq!(
adapter.lorum_to_tool_event("session-start"),
Some("SessionStart".to_string())
);
assert_eq!(
adapter.tool_to_lorum_event("PreToolUse"),
Some("pre-tool-use".to_string())
);
assert_eq!(
adapter.tool_to_lorum_event("SessionStart"),
Some("session-start".to_string())
);
}
#[test]
fn test_hooks_event_mapping_kimi() {
let adapter = kimi::KimiAdapter;
assert_eq!(
adapter.lorum_to_tool_event("post-tool-use"),
Some("PostToolUse".to_string())
);
assert_eq!(
adapter.tool_to_lorum_event("PostToolUse"),
Some("post-tool-use".to_string())
);
}
#[test]
fn test_hooks_event_mapping_roundtrip() {
let claude = claude::ClaudeAdapter;
let events = [
"pre-tool-use",
"post-tool-use",
"session-start",
"session-end",
];
for event in &events {
let tool = claude.lorum_to_tool_event(event).unwrap();
let back = claude.tool_to_lorum_event(&tool).unwrap();
assert_eq!(back, *event, "roundtrip failed for {event}");
}
}
#[test]
fn kebab_to_camel_and_camel_to_kebab_roundtrip() {
assert_eq!(kebab_to_camel("pre-tool-use"), "preToolUse");
assert_eq!(camel_to_kebab("preToolUse"), "pre-tool-use");
assert_eq!(
camel_to_kebab(&kebab_to_camel("session-start")),
"session-start"
);
assert_eq!(
kebab_to_camel(&camel_to_kebab("sessionStart")),
"sessionStart"
);
}
#[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");
}
#[test]
fn test_config_validator_blanket_impl_valid_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, r#"{"key": "value"}"#).unwrap();
let issues = validate_syntax(&path);
assert!(
issues.is_empty(),
"expected no issues for valid JSON, got: {:?}",
issues
);
}
#[test]
fn test_config_validator_blanket_impl_valid_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "key = \"value\"\n").unwrap();
let issues = validate_syntax(&path);
assert!(
issues.is_empty(),
"expected no issues for valid TOML, got: {:?}",
issues
);
}
#[test]
fn test_config_validator_blanket_impl_valid_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "key: value\n").unwrap();
let issues = validate_syntax(&path);
assert!(
issues.is_empty(),
"expected no issues for valid YAML, got: {:?}",
issues
);
}
#[test]
fn test_config_validator_blanket_impl_broken_json() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, r#"{"key": "value" "missing": "comma"}"#).unwrap();
let issues = validate_syntax(&path);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Error);
assert!(issues[0].message.contains("invalid JSON"));
}
#[test]
fn test_config_validator_blanket_impl_broken_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "key = \n").unwrap();
let issues = validate_syntax(&path);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Error);
assert!(issues[0].message.contains("invalid TOML"));
}
#[test]
fn test_config_validator_blanket_impl_broken_yaml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.yaml");
fs::write(&path, "key: [unclosed").unwrap();
let issues = validate_syntax(&path);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Error);
assert!(issues[0].message.contains("invalid YAML"));
}
#[test]
fn test_config_validator_blanket_impl_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
fs::write(&path, "").unwrap();
let issues = validate_syntax(&path);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Warning);
assert!(issues[0].message.contains("empty"));
}
#[test]
fn test_config_validator_blanket_impl_directory_not_file() {
let dir = tempfile::tempdir().unwrap();
let subdir = dir.path().join("config.json");
fs::create_dir(&subdir).unwrap();
let issues = validate_syntax(&subdir);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Error);
assert!(issues[0].message.contains("directory"));
}
#[test]
fn test_config_validator_blanket_impl_unknown_extension() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.txt");
fs::write(&path, "some text").unwrap();
let issues = validate_syntax(&path);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, Severity::Warning);
assert!(issues[0].message.contains("unknown file extension"));
}
#[test]
fn test_all_config_validators_returns_expected_count() {
let validators = all_config_validators();
assert_eq!(validators.len(), 9);
}
#[test]
fn test_find_config_validator_finds_known() {
assert_eq!(
find_config_validator("claude-code").unwrap().name(),
"claude-code"
);
assert_eq!(find_config_validator("codex").unwrap().name(), "codex");
assert_eq!(
find_config_validator("continue").unwrap().name(),
"continue"
);
assert_eq!(find_config_validator("cursor").unwrap().name(), "cursor");
assert_eq!(find_config_validator("proma").unwrap().name(), "proma");
assert_eq!(find_config_validator("kimi").unwrap().name(), "kimi");
assert_eq!(
find_config_validator("opencode").unwrap().name(),
"opencode"
);
assert_eq!(find_config_validator("trae").unwrap().name(), "trae");
assert_eq!(
find_config_validator("windsurf").unwrap().name(),
"windsurf"
);
}
#[test]
fn test_find_config_validator_returns_none_for_unknown() {
assert!(find_config_validator("nonexistent-tool").is_none());
}
#[test]
fn test_all_adapters_and_validators_are_consistent() {
let adapters = all_adapters();
let validators = all_config_validators();
assert_eq!(
adapters.len(),
validators.len(),
"ALL_ADAPTERS and ALL_CONFIG_VALIDATORS must have the same length"
);
for adapter in adapters {
assert!(
find_config_validator(adapter.name()).is_some(),
"adapter '{}' has no corresponding config validator",
adapter.name()
);
}
}
}