pub mod agents;
pub mod claude;
pub mod codex;
pub mod cursor;
pub mod opencode;
pub mod pi;
use std::path::{Path, PathBuf};
use crate::error::MarsError;
use crate::lock::ItemKind;
#[doc(hidden)]
pub use crate::surface_ownership::retention::ConfigWrite;
use crate::surface_ownership::retention::{RemovalOperation, RemovalReport, Surface};
use crate::types::DestPath;
use indexmap::IndexMap;
const WINDOWS_INVALID_CHARS: &[char] = &[':', '*', '?', '<', '>', '|', '"', '/', '\\'];
#[derive(Debug, Clone)]
pub enum ConfigEntry {
McpServer(McpServerEntry),
Hook(HookEntry),
}
impl ConfigEntry {
pub(crate) fn surface(&self) -> Surface {
match self {
Self::McpServer(_) => Surface::Mcp,
Self::Hook(_) => Surface::Hook,
}
}
pub fn key(&self) -> String {
match self {
ConfigEntry::McpServer(e) => format!("mcp:{}", e.name),
ConfigEntry::Hook(e) => format!("hook:{}:{}", e.native_event, e.name),
}
}
}
#[derive(Debug, Clone)]
pub struct McpServerEntry {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub env: IndexMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct HookEntry {
pub name: String,
pub native_event: String,
pub entries: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookFragmentMode {
MergeJson,
File,
}
pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
fn name(&self) -> &str;
fn known_hook_events(&self) -> Option<&'static [&'static str]> {
None
}
fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
None
}
fn hook_file_dest_path(&self, _name: &str) -> Option<PathBuf> {
None
}
fn skill_variant_key(&self) -> Option<&str>;
fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
fn write_config_entries(
&self,
write: ConfigWrite<'_>,
project_root: &Path,
) -> Result<Vec<PathBuf>, MarsError> {
let (_target_dir, _entries) = write.into_parts(project_root);
Ok(Vec::new())
}
fn mcp_config_file_names(&self) -> &'static [&'static str] {
&[]
}
fn hook_config_file_names(&self) -> &'static [&'static str] {
&[]
}
fn legacy_hook_config_file_names(&self) -> &'static [&'static str] {
&[]
}
fn emit_pre_write_diagnostics(
&self,
_entries: &[ConfigEntry],
_diag: &mut crate::diagnostic::DiagnosticCollector,
) {
}
fn remove_owned_hook_entries(
&self,
operation: RemovalOperation<'_>,
project_root: &Path,
_diag: &mut crate::diagnostic::DiagnosticCollector,
) -> RemovalReport {
let (_, _) = operation.into_parts(project_root);
RemovalReport::confirmed()
}
fn remove_config_entries(
&self,
operation: RemovalOperation<'_>,
project_root: &Path,
) -> RemovalReport {
let (_, _) = operation.into_parts(project_root);
RemovalReport::confirmed()
}
}
pub(crate) fn parse_json_file(path: &Path) -> Result<serde_json::Value, MarsError> {
let raw = std::fs::read_to_string(path)?;
serde_json::from_str(&raw).map_err(|error| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{} is not valid JSON: {error}", path.display()),
})
})
}
pub(crate) fn validate_json_config_file(path: &Path) -> Result<(), MarsError> {
if !path.is_file() {
return Ok(());
}
let root = parse_json_file(path)?;
let object = root.as_object().ok_or_else(|| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{} is not a JSON object", path.display()),
})
})?;
if object
.get("mcpServers")
.is_some_and(|value| !value.is_object())
{
return Err(MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{}: mcpServers is not an object", path.display()),
}));
}
if let Some(hooks) = object.get("hooks") {
let hooks = hooks.as_object().ok_or_else(|| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{}: hooks is not an object", path.display()),
})
})?;
if let Some((event, _)) = hooks.iter().find(|(_, value)| !value.is_array()) {
return Err(MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{}: hooks.{event} is not an array", path.display()),
}));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct JsonEventArrayUpdate {
pub changed: bool,
pub missing: usize,
}
pub(crate) fn append_json_event_entries(
hooks: &mut serde_json::Map<String, serde_json::Value>,
event: &str,
entries: &[serde_json::Value],
path: &Path,
) -> Result<JsonEventArrayUpdate, MarsError> {
if entries.is_empty() {
return Ok(JsonEventArrayUpdate {
changed: false,
missing: 0,
});
}
let event_entries = hooks
.entry(event.to_string())
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| {
MarsError::Config(crate::error::ConfigError::Invalid {
message: format!("{}: hooks.{event} is not an array", path.display()),
})
})?;
event_entries.extend(entries.iter().cloned());
Ok(JsonEventArrayUpdate {
changed: true,
missing: 0,
})
}
pub(crate) fn remove_json_event_entries(
hooks: &mut serde_json::Map<String, serde_json::Value>,
event: &str,
expected: &[serde_json::Value],
) -> JsonEventArrayUpdate {
let Some(current) = hooks
.get_mut(event)
.and_then(serde_json::Value::as_array_mut)
else {
return JsonEventArrayUpdate {
changed: false,
missing: expected.len(),
};
};
let mut removed = 0;
for entry in expected {
if let Some(index) = current.iter().position(|candidate| candidate == entry) {
current.remove(index);
removed += 1;
}
}
if removed > 0 && current.is_empty() {
hooks.remove(event);
}
JsonEventArrayUpdate {
changed: removed > 0,
missing: expected.len() - removed,
}
}
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())
}
}
impl Default for TargetRegistry {
fn default() -> Self {
Self::new()
}
}
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();
for name in [
".agents",
".claude",
".codex",
".opencode",
".pi",
".cursor",
] {
let adapter = registry
.get(name)
.unwrap_or_else(|| panic!("built-in target adapter `{name}` is not registered"));
assert_eq!(adapter.name(), name);
}
}
#[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 hook_event_allowlists_match_supported_command_hook_targets() {
let registry = TargetRegistry::new();
let claude = registry
.get(".claude")
.unwrap()
.known_hook_events()
.unwrap();
let codex = registry.get(".codex").unwrap().known_hook_events().unwrap();
assert_eq!(claude.len(), 29);
assert!(claude.contains(&"SessionEnd"));
assert_eq!(codex.len(), 10);
assert!(!codex.contains(&"SessionEnd"));
let cursor = registry
.get(".cursor")
.unwrap()
.known_hook_events()
.unwrap();
assert_eq!(cursor.len(), 21);
assert!(cursor.contains(&"beforeShellExecution"));
assert!(cursor.contains(&"sessionStart"));
for target in [".opencode", ".pi"] {
assert!(registry.get(target).unwrap().known_hook_events().is_none());
}
}
#[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 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"));
}
}