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 crate::error::MarsError;
22use crate::lock::ItemKind;
23#[doc(hidden)]
24pub use crate::surface_ownership::retention::ConfigWrite;
25use crate::surface_ownership::retention::{RemovalOperation, RemovalReport, Surface};
26use crate::types::DestPath;
27use indexmap::IndexMap;
28
29const WINDOWS_INVALID_CHARS: &[char] = &[':', '*', '?', '<', '>', '|', '"', '/', '\\'];
30
31#[derive(Debug, Clone)]
36pub enum ConfigEntry {
37 McpServer(McpServerEntry),
39 Hook(HookEntry),
41}
42
43impl ConfigEntry {
44 pub(crate) fn surface(&self) -> Surface {
46 match self {
47 Self::McpServer(_) => Surface::Mcp,
48 Self::Hook(_) => Surface::Hook,
49 }
50 }
51
52 pub fn key(&self) -> String {
53 match self {
54 ConfigEntry::McpServer(e) => format!("mcp:{}", e.name),
55 ConfigEntry::Hook(e) => format!("hook:{}:{}", e.native_event, e.name),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
65pub struct McpServerEntry {
66 pub name: String,
68 pub command: String,
70 pub args: Vec<String>,
72 pub env: IndexMap<String, String>,
74}
75
76#[derive(Debug, Clone)]
78pub struct HookEntry {
79 pub name: String,
81 pub native_event: String,
83 pub entries: Vec<serde_json::Value>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum HookFragmentMode {
90 MergeJson,
91 File,
92}
93
94pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
109 fn name(&self) -> &str;
111
112 fn known_hook_events(&self) -> Option<&'static [&'static str]> {
115 None
116 }
117
118 fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
120 None
121 }
122
123 fn hook_file_dest_path(&self, _name: &str) -> Option<PathBuf> {
125 None
126 }
127
128 fn skill_variant_key(&self) -> Option<&str>;
134
135 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
144
145 fn write_config_entries(
154 &self,
155 write: ConfigWrite<'_>,
156 project_root: &Path,
157 ) -> Result<Vec<PathBuf>, MarsError> {
158 let (_target_dir, _entries) = write.into_parts(project_root);
159 Ok(Vec::new())
160 }
161
162 fn mcp_config_file_names(&self) -> &'static [&'static str] {
164 &[]
165 }
166
167 fn hook_config_file_names(&self) -> &'static [&'static str] {
169 &[]
170 }
171
172 fn legacy_hook_config_file_names(&self) -> &'static [&'static str] {
175 &[]
176 }
177
178 fn emit_pre_write_diagnostics(
183 &self,
184 _entries: &[ConfigEntry],
185 _diag: &mut crate::diagnostic::DiagnosticCollector,
186 ) {
187 }
188
189 fn remove_owned_hook_entries(
191 &self,
192 operation: RemovalOperation<'_>,
193 project_root: &Path,
194 _diag: &mut crate::diagnostic::DiagnosticCollector,
195 ) -> RemovalReport {
196 let (_, _) = operation.into_parts(project_root);
197 RemovalReport::confirmed()
198 }
199
200 fn remove_config_entries(
205 &self,
206 operation: RemovalOperation<'_>,
207 project_root: &Path,
208 ) -> RemovalReport {
209 let (_, _) = operation.into_parts(project_root);
210 RemovalReport::confirmed()
211 }
212}
213
214pub(crate) fn parse_json_file(path: &Path) -> Result<serde_json::Value, MarsError> {
215 let raw = std::fs::read_to_string(path)?;
216 serde_json::from_str(&raw).map_err(|error| {
217 MarsError::Config(crate::error::ConfigError::Invalid {
218 message: format!("{} is not valid JSON: {error}", path.display()),
219 })
220 })
221}
222
223pub(crate) fn validate_json_config_file(path: &Path) -> Result<(), MarsError> {
224 if !path.is_file() {
225 return Ok(());
226 }
227 let root = parse_json_file(path)?;
228 let object = root.as_object().ok_or_else(|| {
229 MarsError::Config(crate::error::ConfigError::Invalid {
230 message: format!("{} is not a JSON object", path.display()),
231 })
232 })?;
233 if object
234 .get("mcpServers")
235 .is_some_and(|value| !value.is_object())
236 {
237 return Err(MarsError::Config(crate::error::ConfigError::Invalid {
238 message: format!("{}: mcpServers is not an object", path.display()),
239 }));
240 }
241 if let Some(hooks) = object.get("hooks") {
242 let hooks = hooks.as_object().ok_or_else(|| {
243 MarsError::Config(crate::error::ConfigError::Invalid {
244 message: format!("{}: hooks is not an object", path.display()),
245 })
246 })?;
247 if let Some((event, _)) = hooks.iter().find(|(_, value)| !value.is_array()) {
248 return Err(MarsError::Config(crate::error::ConfigError::Invalid {
249 message: format!("{}: hooks.{event} is not an array", path.display()),
250 }));
251 }
252 }
253 Ok(())
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub(crate) struct JsonEventArrayUpdate {
258 pub changed: bool,
259 pub missing: usize,
260}
261
262pub(crate) fn append_json_event_entries(
263 hooks: &mut serde_json::Map<String, serde_json::Value>,
264 event: &str,
265 entries: &[serde_json::Value],
266 path: &Path,
267) -> Result<JsonEventArrayUpdate, MarsError> {
268 if entries.is_empty() {
269 return Ok(JsonEventArrayUpdate {
270 changed: false,
271 missing: 0,
272 });
273 }
274 let event_entries = hooks
275 .entry(event.to_string())
276 .or_insert_with(|| serde_json::json!([]))
277 .as_array_mut()
278 .ok_or_else(|| {
279 MarsError::Config(crate::error::ConfigError::Invalid {
280 message: format!("{}: hooks.{event} is not an array", path.display()),
281 })
282 })?;
283 event_entries.extend(entries.iter().cloned());
284 Ok(JsonEventArrayUpdate {
285 changed: true,
286 missing: 0,
287 })
288}
289
290pub(crate) fn remove_json_event_entries(
291 hooks: &mut serde_json::Map<String, serde_json::Value>,
292 event: &str,
293 expected: &[serde_json::Value],
294) -> JsonEventArrayUpdate {
295 let Some(current) = hooks
296 .get_mut(event)
297 .and_then(serde_json::Value::as_array_mut)
298 else {
299 return JsonEventArrayUpdate {
300 changed: false,
301 missing: expected.len(),
302 };
303 };
304 let mut removed = 0;
305 for entry in expected {
306 if let Some(index) = current.iter().position(|candidate| candidate == entry) {
307 current.remove(index);
308 removed += 1;
309 }
310 }
311 if removed > 0 && current.is_empty() {
312 hooks.remove(event);
313 }
314 JsonEventArrayUpdate {
315 changed: removed > 0,
316 missing: expected.len() - removed,
317 }
318}
319
320pub struct TargetRegistry {
325 adapters: Vec<Box<dyn TargetAdapter>>,
326}
327
328impl TargetRegistry {
329 pub fn new() -> Self {
331 Self {
332 adapters: vec![
333 Box::new(agents::AgentsAdapter),
334 Box::new(claude::ClaudeAdapter),
335 Box::new(codex::CodexAdapter),
336 Box::new(opencode::OpencodeAdapter),
337 Box::new(pi::PiAdapter),
338 Box::new(cursor::CursorAdapter),
339 ],
340 }
341 }
342
343 pub fn get(&self, name: &str) -> Option<&dyn TargetAdapter> {
349 self.adapters
350 .iter()
351 .find(|a| a.name() == name)
352 .map(|a| a.as_ref())
353 }
354}
355
356impl Default for TargetRegistry {
357 fn default() -> Self {
358 Self::new()
359 }
360}
361
362pub fn validate_agent_filename(name: &str) -> Result<(), String> {
365 if let Some(ch) = name.chars().find(|ch| WINDOWS_INVALID_CHARS.contains(ch)) {
366 return Err(format!(
367 "agent `{name}` contains portable filename-invalid character `{ch}`"
368 ));
369 }
370
371 let stem = name
372 .split('.')
373 .next()
374 .unwrap_or(name)
375 .trim_end_matches([' ', '.'])
376 .to_ascii_uppercase();
377
378 let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
379 || stem
380 .strip_prefix("COM")
381 .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
382 || stem
383 .strip_prefix("LPT")
384 .is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
385
386 if reserved {
387 return Err(format!(
388 "agent `{name}` would create reserved Windows device filename `{stem}`"
389 ));
390 }
391
392 Ok(())
393}
394
395pub fn paths_equivalent(a: &str, b: &str) -> bool {
396 if cfg!(windows) {
397 a.replace('\\', "/") == b.replace('\\', "/")
398 } else {
399 a == b
400 }
401}
402
403pub fn dest_paths_equivalent(a: &str, b: &str) -> bool {
404 a.replace('\\', "/") == b.replace('\\', "/")
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn registry_contains_all_builtin_adapters() {
413 let registry = TargetRegistry::new();
414
415 for name in [
416 ".agents",
417 ".claude",
418 ".codex",
419 ".opencode",
420 ".pi",
421 ".cursor",
422 ] {
423 let adapter = registry
424 .get(name)
425 .unwrap_or_else(|| panic!("built-in target adapter `{name}` is not registered"));
426 assert_eq!(adapter.name(), name);
427 }
428 }
429
430 #[test]
431 fn registry_get_unknown_name_returns_none() {
432 let registry = TargetRegistry::new();
433 assert!(registry.get(".unknown-target").is_none());
434 }
435
436 #[test]
437 fn native_adapters_expose_skill_variant_keys() {
438 let registry = TargetRegistry::new();
439 let expected = [
440 (".claude", Some("claude")),
441 (".codex", Some("codex")),
442 (".opencode", Some("opencode")),
443 (".pi", Some("pi")),
444 (".cursor", Some("cursor")),
445 (".agents", None),
446 ];
447
448 for (target, key) in expected {
449 let adapter = registry.get(target).unwrap();
450 assert_eq!(adapter.skill_variant_key(), key);
451 }
452 }
453
454 #[test]
455 fn hook_event_allowlists_match_supported_command_hook_targets() {
456 let registry = TargetRegistry::new();
457 let claude = registry
458 .get(".claude")
459 .unwrap()
460 .known_hook_events()
461 .unwrap();
462 let codex = registry.get(".codex").unwrap().known_hook_events().unwrap();
463 assert_eq!(claude.len(), 29);
464 assert!(claude.contains(&"SessionEnd"));
465 assert_eq!(codex.len(), 10);
466 assert!(!codex.contains(&"SessionEnd"));
467 let cursor = registry
468 .get(".cursor")
469 .unwrap()
470 .known_hook_events()
471 .unwrap();
472 assert_eq!(cursor.len(), 21);
473 assert!(cursor.contains(&"beforeShellExecution"));
474 assert!(cursor.contains(&"sessionStart"));
475
476 for target in [".opencode", ".pi"] {
477 assert!(registry.get(target).unwrap().known_hook_events().is_none());
478 }
479 }
480
481 #[test]
482 fn agents_adapter_default_dest_path_agent() {
483 let registry = TargetRegistry::new();
484 let adapter = registry.get(".agents").unwrap();
485 let path = adapter.default_dest_path(ItemKind::Agent, "coder").unwrap();
486 assert_eq!(path.as_str(), "agents/coder.md");
487 }
488
489 #[test]
490 fn agents_adapter_default_dest_path_skill() {
491 let registry = TargetRegistry::new();
492 let adapter = registry.get(".agents").unwrap();
493 let path = adapter
494 .default_dest_path(ItemKind::Skill, "planning")
495 .unwrap();
496 assert_eq!(path.as_str(), "skills/planning");
497 }
498
499 #[test]
500 fn windows_invalid_agent_filename_is_rejected() {
501 assert!(validate_agent_filename("bad:name").is_err());
502 assert!(validate_agent_filename("team/lead").is_err());
503 assert!(validate_agent_filename(r"team\lead").is_err());
504 assert!(validate_agent_filename("CON").is_err());
505 assert!(validate_agent_filename("com1").is_err());
506 }
507
508 #[test]
509 fn valid_agent_filename_passes() {
510 assert!(validate_agent_filename("coder").is_ok());
511 assert!(validate_agent_filename("deep-agent").is_ok());
512 }
513
514 #[cfg(windows)]
515 #[test]
516 fn path_equivalence_normalizes_separators_on_windows() {
517 assert!(paths_equivalent(r"agents\coder.md", "agents/coder.md"));
518 }
519
520 #[cfg(not(windows))]
521 #[test]
522 fn path_equivalence_preserves_backslash_on_posix() {
523 assert!(!paths_equivalent(r"agents\coder.md", "agents/coder.md"));
524 }
525
526 #[test]
527 fn dest_path_equivalence_always_normalizes_separators() {
528 assert!(dest_paths_equivalent(r"agents\coder.md", "agents/coder.md"));
529 }
530}