1pub mod agents;
13pub mod claude;
14pub mod codex;
15pub mod cursor;
16pub mod opencode;
17pub mod pi;
18
19use std::path::{Path, PathBuf};
20
21use indexmap::IndexMap;
22
23use crate::error::MarsError;
24use crate::lock::ItemKind;
25use crate::types::DestPath;
26
27const WINDOWS_INVALID_CHARS: &[char] = &[':', '*', '?', '<', '>', '|', '"', '/', '\\'];
28
29#[derive(Debug, Clone)]
34pub enum ConfigEntry {
35 McpServer(McpServerEntry),
37 Hook(HookEntry),
39}
40
41impl ConfigEntry {
42 pub fn key(&self) -> String {
44 match self {
45 ConfigEntry::McpServer(e) => format!("mcp:{}", e.name),
46 ConfigEntry::Hook(e) => format!("hook:{}:{}", e.event, e.name),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
56pub struct McpServerEntry {
57 pub name: String,
59 pub command: String,
61 pub args: Vec<String>,
63 pub env: IndexMap<String, String>,
65}
66
67#[derive(Debug, Clone)]
69pub struct HookEntry {
70 pub name: String,
73 pub event: String,
75 pub native_event: String,
77 pub script_path: String,
79 pub order: i32,
81}
82
83pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
98 fn name(&self) -> &str;
100
101 fn skill_variant_key(&self) -> Option<&str>;
107
108 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
117
118 fn write_config_entries(
127 &self,
128 _entries: &[ConfigEntry],
129 _target_dir: &Path,
130 ) -> Result<Vec<PathBuf>, MarsError> {
131 Ok(Vec::new())
132 }
133
134 fn emit_pre_write_diagnostics(
139 &self,
140 _entries: &[ConfigEntry],
141 _diag: &mut crate::diagnostic::DiagnosticCollector,
142 ) {
143 }
144
145 fn remove_config_entries(
150 &self,
151 _entry_keys: &[String],
152 _target_dir: &Path,
153 ) -> Result<(), MarsError> {
154 Ok(())
155 }
156}
157
158pub struct TargetRegistry {
163 adapters: Vec<Box<dyn TargetAdapter>>,
164}
165
166impl TargetRegistry {
167 pub fn new() -> Self {
169 Self {
170 adapters: vec![
171 Box::new(agents::AgentsAdapter),
172 Box::new(claude::ClaudeAdapter),
173 Box::new(codex::CodexAdapter),
174 Box::new(opencode::OpencodeAdapter),
175 Box::new(pi::PiAdapter),
176 Box::new(cursor::CursorAdapter),
177 ],
178 }
179 }
180
181 pub fn get(&self, name: &str) -> Option<&dyn TargetAdapter> {
187 self.adapters
188 .iter()
189 .find(|a| a.name() == name)
190 .map(|a| a.as_ref())
191 }
192
193 pub fn iter(&self) -> impl Iterator<Item = &dyn TargetAdapter> {
195 self.adapters.iter().map(|a| a.as_ref())
196 }
197}
198
199impl Default for TargetRegistry {
200 fn default() -> Self {
201 Self::new()
202 }
203}
204
205pub fn hook_command(script_path: &str) -> String {
207 hook_command_for_platform(script_path, cfg!(windows))
208}
209
210fn hook_command_for_platform(script_path: &str, windows: bool) -> String {
211 if windows {
212 format!("bash \"{}\"", script_path.replace('\\', "/"))
214 } else {
215 format!("bash '{}'", script_path.replace('\'', "'\\''"))
217 }
218}
219
220pub fn validate_agent_filename(name: &str) -> Result<(), String> {
223 if let Some(ch) = name.chars().find(|ch| WINDOWS_INVALID_CHARS.contains(ch)) {
224 return Err(format!(
225 "agent `{name}` contains portable filename-invalid character `{ch}`"
226 ));
227 }
228
229 let stem = name
230 .split('.')
231 .next()
232 .unwrap_or(name)
233 .trim_end_matches([' ', '.'])
234 .to_ascii_uppercase();
235
236 let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
237 || stem
238 .strip_prefix("COM")
239 .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
240 || stem
241 .strip_prefix("LPT")
242 .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
243
244 if reserved {
245 return Err(format!(
246 "agent `{name}` would create reserved Windows device filename `{stem}`"
247 ));
248 }
249
250 Ok(())
251}
252
253pub fn paths_equivalent(a: &str, b: &str) -> bool {
254 if cfg!(windows) {
255 a.replace('\\', "/") == b.replace('\\', "/")
256 } else {
257 a == b
258 }
259}
260
261pub fn dest_paths_equivalent(a: &str, b: &str) -> bool {
262 a.replace('\\', "/") == b.replace('\\', "/")
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn registry_contains_all_builtin_adapters() {
271 let registry = TargetRegistry::new();
272 let names: Vec<&str> = registry.iter().map(|a| a.name()).collect();
273 assert!(names.contains(&".agents"));
274 assert!(names.contains(&".claude"));
275 assert!(names.contains(&".codex"));
276 assert!(names.contains(&".opencode"));
277 assert!(names.contains(&".pi"));
278 assert!(names.contains(&".cursor"));
279 }
280
281 #[test]
282 fn registry_get_returns_adapter_by_name() {
283 let registry = TargetRegistry::new();
284 let adapter = registry.get(".agents").unwrap();
285 assert_eq!(adapter.name(), ".agents");
286 }
287
288 #[test]
289 fn registry_get_unknown_name_returns_none() {
290 let registry = TargetRegistry::new();
291 assert!(registry.get(".unknown-target").is_none());
292 }
293
294 #[test]
295 fn native_adapters_expose_skill_variant_keys() {
296 let registry = TargetRegistry::new();
297 let expected = [
298 (".claude", Some("claude")),
299 (".codex", Some("codex")),
300 (".opencode", Some("opencode")),
301 (".pi", Some("pi")),
302 (".cursor", Some("cursor")),
303 (".agents", None),
304 ];
305
306 for (target, key) in expected {
307 let adapter = registry.get(target).unwrap();
308 assert_eq!(adapter.skill_variant_key(), key);
309 }
310 }
311
312 #[test]
313 fn agents_adapter_default_dest_path_agent() {
314 let registry = TargetRegistry::new();
315 let adapter = registry.get(".agents").unwrap();
316 let path = adapter.default_dest_path(ItemKind::Agent, "coder").unwrap();
317 assert_eq!(path.as_str(), "agents/coder.md");
318 }
319
320 #[test]
321 fn agents_adapter_default_dest_path_skill() {
322 let registry = TargetRegistry::new();
323 let adapter = registry.get(".agents").unwrap();
324 let path = adapter
325 .default_dest_path(ItemKind::Skill, "planning")
326 .unwrap();
327 assert_eq!(path.as_str(), "skills/planning");
328 }
329
330 #[test]
331 fn hook_command_posix_uses_single_quotes() {
332 assert_eq!(
333 hook_command_for_platform("/hooks/audit/run.sh", false),
334 "bash '/hooks/audit/run.sh'"
335 );
336 }
337
338 #[test]
339 fn hook_command_windows_uses_double_quotes_and_normalizes_backslashes() {
340 assert_eq!(
341 hook_command_for_platform(r"C:\hooks\audit\run.sh", true),
342 "bash \"C:/hooks/audit/run.sh\""
343 );
344 }
345
346 #[test]
347 fn windows_invalid_agent_filename_is_rejected() {
348 assert!(validate_agent_filename("bad:name").is_err());
349 assert!(validate_agent_filename("team/lead").is_err());
350 assert!(validate_agent_filename(r"team\lead").is_err());
351 assert!(validate_agent_filename("CON").is_err());
352 assert!(validate_agent_filename("com1").is_err());
353 }
354
355 #[test]
356 fn valid_agent_filename_passes() {
357 assert!(validate_agent_filename("coder").is_ok());
358 assert!(validate_agent_filename("deep-agent").is_ok());
359 }
360
361 #[cfg(windows)]
362 #[test]
363 fn path_equivalence_normalizes_separators_on_windows() {
364 assert!(paths_equivalent(r"agents\coder.md", "agents/coder.md"));
365 }
366
367 #[cfg(not(windows))]
368 #[test]
369 fn path_equivalence_preserves_backslash_on_posix() {
370 assert!(!paths_equivalent(r"agents\coder.md", "agents/coder.md"));
371 }
372
373 #[test]
374 fn dest_path_equivalence_always_normalizes_separators() {
375 assert!(dest_paths_equivalent(r"agents\coder.md", "agents/coder.md"));
376 }
377}