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::compiler::mcp::{HeaderValue, McpTransport};
24use crate::error::MarsError;
25use crate::lock::ItemKind;
26use crate::types::DestPath;
27
28const WINDOWS_INVALID_CHARS: &[char] = &[':', '*', '?', '<', '>', '|', '"', '/', '\\'];
29
30#[derive(Debug, Clone)]
35pub enum ConfigEntry {
36 McpServer(McpServerEntry),
38 Hook(HookEntry),
40}
41
42impl ConfigEntry {
43 pub fn key(&self) -> String {
45 match self {
46 ConfigEntry::McpServer(e) => format!("mcp:{}", e.name),
47 ConfigEntry::Hook(e) => format!("hook:{}:{}", e.event, e.name),
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
57pub struct McpServerEntry {
58 pub name: String,
60 pub transport: McpTransport,
62 pub command: Option<String>,
64 pub args: Vec<String>,
66 pub env: IndexMap<String, String>,
68 pub url: Option<String>,
70 pub headers: IndexMap<String, HeaderValue>,
72}
73
74#[derive(Debug, Clone)]
76pub struct HookEntry {
77 pub name: String,
80 pub event: String,
82 pub native_event: String,
84 pub script_path: String,
86 pub order: i32,
88}
89
90pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
105 fn name(&self) -> &str;
107
108 fn skill_variant_key(&self) -> Option<&str>;
114
115 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
124
125 fn write_config_entries(
134 &self,
135 _entries: &[ConfigEntry],
136 _target_dir: &Path,
137 ) -> Result<Vec<PathBuf>, MarsError> {
138 Ok(Vec::new())
139 }
140
141 fn emit_pre_write_diagnostics(
146 &self,
147 _entries: &[ConfigEntry],
148 _target_dir: &Path,
149 _diag: &mut crate::diagnostic::DiagnosticCollector,
150 ) {
151 }
152
153 fn remove_config_entries(
158 &self,
159 _entry_keys: &[String],
160 _target_dir: &Path,
161 ) -> Result<(), MarsError> {
162 Ok(())
163 }
164}
165
166pub struct TargetRegistry {
171 adapters: Vec<Box<dyn TargetAdapter>>,
172}
173
174impl TargetRegistry {
175 pub fn new() -> Self {
177 Self {
178 adapters: vec![
179 Box::new(agents::AgentsAdapter),
180 Box::new(claude::ClaudeAdapter),
181 Box::new(codex::CodexAdapter),
182 Box::new(opencode::OpencodeAdapter),
183 Box::new(pi::PiAdapter),
184 Box::new(cursor::CursorAdapter),
185 ],
186 }
187 }
188
189 pub fn get(&self, name: &str) -> Option<&dyn TargetAdapter> {
195 self.adapters
196 .iter()
197 .find(|a| a.name() == name)
198 .map(|a| a.as_ref())
199 }
200
201 pub fn iter(&self) -> impl Iterator<Item = &dyn TargetAdapter> {
203 self.adapters.iter().map(|a| a.as_ref())
204 }
205}
206
207impl Default for TargetRegistry {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213pub fn hook_command(script_path: &str) -> String {
215 hook_command_for_platform(script_path, cfg!(windows))
216}
217
218fn hook_command_for_platform(script_path: &str, windows: bool) -> String {
219 if windows {
220 format!("bash \"{}\"", script_path.replace('\\', "/"))
222 } else {
223 format!("bash '{}'", script_path.replace('\'', "'\\''"))
225 }
226}
227
228pub fn validate_agent_filename(name: &str) -> Result<(), String> {
231 if let Some(ch) = name.chars().find(|ch| WINDOWS_INVALID_CHARS.contains(ch)) {
232 return Err(format!(
233 "agent `{name}` contains portable filename-invalid character `{ch}`"
234 ));
235 }
236
237 let stem = name
238 .split('.')
239 .next()
240 .unwrap_or(name)
241 .trim_end_matches([' ', '.'])
242 .to_ascii_uppercase();
243
244 let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
245 || stem
246 .strip_prefix("COM")
247 .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
248 || stem
249 .strip_prefix("LPT")
250 .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
251
252 if reserved {
253 return Err(format!(
254 "agent `{name}` would create reserved Windows device filename `{stem}`"
255 ));
256 }
257
258 Ok(())
259}
260
261pub fn paths_equivalent(a: &str, b: &str) -> bool {
262 if cfg!(windows) {
263 a.replace('\\', "/") == b.replace('\\', "/")
264 } else {
265 a == b
266 }
267}
268
269pub fn dest_paths_equivalent(a: &str, b: &str) -> bool {
270 a.replace('\\', "/") == b.replace('\\', "/")
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn registry_contains_all_builtin_adapters() {
279 let registry = TargetRegistry::new();
280 let names: Vec<&str> = registry.iter().map(|a| a.name()).collect();
281 assert!(names.contains(&".agents"));
282 assert!(names.contains(&".claude"));
283 assert!(names.contains(&".codex"));
284 assert!(names.contains(&".opencode"));
285 assert!(names.contains(&".pi"));
286 assert!(names.contains(&".cursor"));
287 }
288
289 #[test]
290 fn registry_get_returns_adapter_by_name() {
291 let registry = TargetRegistry::new();
292 let adapter = registry.get(".agents").unwrap();
293 assert_eq!(adapter.name(), ".agents");
294 }
295
296 #[test]
297 fn registry_get_unknown_name_returns_none() {
298 let registry = TargetRegistry::new();
299 assert!(registry.get(".unknown-target").is_none());
300 }
301
302 #[test]
303 fn native_adapters_expose_skill_variant_keys() {
304 let registry = TargetRegistry::new();
305 let expected = [
306 (".claude", Some("claude")),
307 (".codex", Some("codex")),
308 (".opencode", Some("opencode")),
309 (".pi", Some("pi")),
310 (".cursor", Some("cursor")),
311 (".agents", None),
312 ];
313
314 for (target, key) in expected {
315 let adapter = registry.get(target).unwrap();
316 assert_eq!(adapter.skill_variant_key(), key);
317 }
318 }
319
320 #[test]
321 fn agents_adapter_default_dest_path_agent() {
322 let registry = TargetRegistry::new();
323 let adapter = registry.get(".agents").unwrap();
324 let path = adapter.default_dest_path(ItemKind::Agent, "coder").unwrap();
325 assert_eq!(path.as_str(), "agents/coder.md");
326 }
327
328 #[test]
329 fn agents_adapter_default_dest_path_skill() {
330 let registry = TargetRegistry::new();
331 let adapter = registry.get(".agents").unwrap();
332 let path = adapter
333 .default_dest_path(ItemKind::Skill, "planning")
334 .unwrap();
335 assert_eq!(path.as_str(), "skills/planning");
336 }
337
338 #[test]
339 fn hook_command_posix_uses_single_quotes() {
340 assert_eq!(
341 hook_command_for_platform("/hooks/audit/run.sh", false),
342 "bash '/hooks/audit/run.sh'"
343 );
344 }
345
346 #[test]
347 fn hook_command_windows_uses_double_quotes_and_normalizes_backslashes() {
348 assert_eq!(
349 hook_command_for_platform(r"C:\hooks\audit\run.sh", true),
350 "bash \"C:/hooks/audit/run.sh\""
351 );
352 }
353
354 #[test]
355 fn windows_invalid_agent_filename_is_rejected() {
356 assert!(validate_agent_filename("bad:name").is_err());
357 assert!(validate_agent_filename("team/lead").is_err());
358 assert!(validate_agent_filename(r"team\lead").is_err());
359 assert!(validate_agent_filename("CON").is_err());
360 assert!(validate_agent_filename("com1").is_err());
361 }
362
363 #[test]
364 fn valid_agent_filename_passes() {
365 assert!(validate_agent_filename("coder").is_ok());
366 assert!(validate_agent_filename("deep-agent").is_ok());
367 }
368
369 #[cfg(windows)]
370 #[test]
371 fn path_equivalence_normalizes_separators_on_windows() {
372 assert!(paths_equivalent(r"agents\coder.md", "agents/coder.md"));
373 }
374
375 #[cfg(not(windows))]
376 #[test]
377 fn path_equivalence_preserves_backslash_on_posix() {
378 assert!(!paths_equivalent(r"agents\coder.md", "agents/coder.md"));
379 }
380
381 #[test]
382 fn dest_path_equivalence_always_normalizes_separators() {
383 assert!(dest_paths_equivalent(r"agents\coder.md", "agents/coder.md"));
384 }
385}