pub mod agents;
pub mod claude;
pub mod codex;
pub mod cursor;
pub mod opencode;
pub mod pi;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use crate::compiler::mcp::{HeaderValue, McpTransport};
use crate::error::MarsError;
use crate::lock::ItemKind;
use crate::types::DestPath;
const WINDOWS_INVALID_CHARS: &[char] = &[':', '*', '?', '<', '>', '|', '"', '/', '\\'];
#[derive(Debug, Clone)]
pub enum ConfigEntry {
McpServer(McpServerEntry),
Hook(HookEntry),
}
impl ConfigEntry {
pub fn key(&self) -> String {
match self {
ConfigEntry::McpServer(e) => format!("mcp:{}", e.name),
ConfigEntry::Hook(e) => format!("hook:{}:{}", e.event, e.name),
}
}
}
#[derive(Debug, Clone)]
pub struct McpServerEntry {
pub name: String,
pub transport: McpTransport,
pub command: Option<String>,
pub args: Vec<String>,
pub env: IndexMap<String, String>,
pub url: Option<String>,
pub headers: IndexMap<String, HeaderValue>,
}
#[derive(Debug, Clone)]
pub struct HookEntry {
pub name: String,
pub event: String,
pub native_event: String,
pub script_path: String,
pub order: i32,
}
pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
fn name(&self) -> &str;
fn skill_variant_key(&self) -> Option<&str>;
fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
fn write_config_entries(
&self,
_entries: &[ConfigEntry],
_target_dir: &Path,
) -> Result<Vec<PathBuf>, MarsError> {
Ok(Vec::new())
}
fn emit_pre_write_diagnostics(
&self,
_entries: &[ConfigEntry],
_target_dir: &Path,
_diag: &mut crate::diagnostic::DiagnosticCollector,
) {
}
fn remove_config_entries(
&self,
_entry_keys: &[String],
_target_dir: &Path,
) -> Result<(), MarsError> {
Ok(())
}
}
pub struct TargetRegistry {
adapters: Vec<Box<dyn TargetAdapter>>,
}
impl TargetRegistry {
pub fn new() -> Self {
Self {
adapters: vec![
Box::new(agents::AgentsAdapter),
Box::new(claude::ClaudeAdapter),
Box::new(codex::CodexAdapter),
Box::new(opencode::OpencodeAdapter),
Box::new(pi::PiAdapter),
Box::new(cursor::CursorAdapter),
],
}
}
pub fn get(&self, name: &str) -> Option<&dyn TargetAdapter> {
self.adapters
.iter()
.find(|a| a.name() == name)
.map(|a| a.as_ref())
}
pub fn iter(&self) -> impl Iterator<Item = &dyn TargetAdapter> {
self.adapters.iter().map(|a| a.as_ref())
}
}
impl Default for TargetRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn hook_command(script_path: &str) -> String {
hook_command_for_platform(script_path, cfg!(windows))
}
fn hook_command_for_platform(script_path: &str, windows: bool) -> String {
if windows {
format!("bash \"{}\"", script_path.replace('\\', "/"))
} else {
format!("bash '{}'", script_path.replace('\'', "'\\''"))
}
}
pub fn validate_agent_filename(name: &str) -> Result<(), String> {
if let Some(ch) = name.chars().find(|ch| WINDOWS_INVALID_CHARS.contains(ch)) {
return Err(format!(
"agent `{name}` contains portable filename-invalid character `{ch}`"
));
}
let stem = name
.split('.')
.next()
.unwrap_or(name)
.trim_end_matches([' ', '.'])
.to_ascii_uppercase();
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| stem
.strip_prefix("COM")
.is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
|| stem
.strip_prefix("LPT")
.is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
if reserved {
return Err(format!(
"agent `{name}` would create reserved Windows device filename `{stem}`"
));
}
Ok(())
}
pub fn paths_equivalent(a: &str, b: &str) -> bool {
if cfg!(windows) {
a.replace('\\', "/") == b.replace('\\', "/")
} else {
a == b
}
}
pub fn dest_paths_equivalent(a: &str, b: &str) -> bool {
a.replace('\\', "/") == b.replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_contains_all_builtin_adapters() {
let registry = TargetRegistry::new();
let names: Vec<&str> = registry.iter().map(|a| a.name()).collect();
assert!(names.contains(&".agents"));
assert!(names.contains(&".claude"));
assert!(names.contains(&".codex"));
assert!(names.contains(&".opencode"));
assert!(names.contains(&".pi"));
assert!(names.contains(&".cursor"));
}
#[test]
fn registry_get_returns_adapter_by_name() {
let registry = TargetRegistry::new();
let adapter = registry.get(".agents").unwrap();
assert_eq!(adapter.name(), ".agents");
}
#[test]
fn registry_get_unknown_name_returns_none() {
let registry = TargetRegistry::new();
assert!(registry.get(".unknown-target").is_none());
}
#[test]
fn native_adapters_expose_skill_variant_keys() {
let registry = TargetRegistry::new();
let expected = [
(".claude", Some("claude")),
(".codex", Some("codex")),
(".opencode", Some("opencode")),
(".pi", Some("pi")),
(".cursor", Some("cursor")),
(".agents", None),
];
for (target, key) in expected {
let adapter = registry.get(target).unwrap();
assert_eq!(adapter.skill_variant_key(), key);
}
}
#[test]
fn agents_adapter_default_dest_path_agent() {
let registry = TargetRegistry::new();
let adapter = registry.get(".agents").unwrap();
let path = adapter.default_dest_path(ItemKind::Agent, "coder").unwrap();
assert_eq!(path.as_str(), "agents/coder.md");
}
#[test]
fn agents_adapter_default_dest_path_skill() {
let registry = TargetRegistry::new();
let adapter = registry.get(".agents").unwrap();
let path = adapter
.default_dest_path(ItemKind::Skill, "planning")
.unwrap();
assert_eq!(path.as_str(), "skills/planning");
}
#[test]
fn hook_command_posix_uses_single_quotes() {
assert_eq!(
hook_command_for_platform("/hooks/audit/run.sh", false),
"bash '/hooks/audit/run.sh'"
);
}
#[test]
fn hook_command_windows_uses_double_quotes_and_normalizes_backslashes() {
assert_eq!(
hook_command_for_platform(r"C:\hooks\audit\run.sh", true),
"bash \"C:/hooks/audit/run.sh\""
);
}
#[test]
fn windows_invalid_agent_filename_is_rejected() {
assert!(validate_agent_filename("bad:name").is_err());
assert!(validate_agent_filename("team/lead").is_err());
assert!(validate_agent_filename(r"team\lead").is_err());
assert!(validate_agent_filename("CON").is_err());
assert!(validate_agent_filename("com1").is_err());
}
#[test]
fn valid_agent_filename_passes() {
assert!(validate_agent_filename("coder").is_ok());
assert!(validate_agent_filename("deep-agent").is_ok());
}
#[cfg(windows)]
#[test]
fn path_equivalence_normalizes_separators_on_windows() {
assert!(paths_equivalent(r"agents\coder.md", "agents/coder.md"));
}
#[cfg(not(windows))]
#[test]
fn path_equivalence_preserves_backslash_on_posix() {
assert!(!paths_equivalent(r"agents\coder.md", "agents/coder.md"));
}
#[test]
fn dest_path_equivalence_always_normalizes_separators() {
assert!(dest_paths_equivalent(r"agents\coder.md", "agents/coder.md"));
}
}