Skip to main content

mars_agents/target/
mod.rs

1/// Per-target compilation adapters.
2///
3/// Each native target root (`.claude`, `.codex`, `.opencode`, `.pi`, `.cursor`)
4/// has an adapter that knows how to lower agents, format config entries, translate
5/// hooks, and resolve model aliases for that target.
6///
7/// The deprecated `.agents` adapter remains available only for explicit legacy
8/// link targets; `.mars/` is the canonical compiled store.
9///
10/// The adapter boundary isolates all per-target branching here, keeping shared
11/// compiler code free of `if target == ...` chains.
12pub 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/// A config entry to be written to a target's config file.
32///
33/// Adapters consume these entries to write or update target-specific config
34/// files (MCP JSON, hooks in settings.json, etc.).
35#[derive(Debug, Clone)]
36pub enum ConfigEntry {
37    /// An MCP server entry to register in the target's MCP config file.
38    McpServer(McpServerEntry),
39    /// A hook binding to register in the target's hook config.
40    Hook(HookEntry),
41}
42
43impl ConfigEntry {
44    /// Stable identity key for this entry (used by stale-cleanup logic).
45    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/// An MCP server entry ready to be written into a target config file.
61///
62/// Env values are variable names (symbolic). Adapters translate them to the
63/// target's interpolation syntax (e.g. `${VAR}` for Claude, plain name for Codex).
64#[derive(Debug, Clone)]
65pub struct McpServerEntry {
66    /// Server name as it appears in the target config.
67    pub name: String,
68    /// Launch command.
69    pub command: String,
70    /// Launch arguments.
71    pub args: Vec<String>,
72    /// Env vars: config key → environment variable name (symbolic, never resolved).
73    pub env: IndexMap<String, String>,
74}
75
76/// A native fragment contribution ready to be merged into a target config.
77#[derive(Debug, Clone)]
78pub struct HookEntry {
79    /// Hook name, retained as ownership provenance.
80    pub name: String,
81    /// Native event name for this target.
82    pub native_event: String,
83    /// Opaque native entries, in author-declared order.
84    pub entries: Vec<serde_json::Value>,
85}
86
87/// How a target consumes hook fragments.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum HookFragmentMode {
90    MergeJson,
91    File,
92}
93
94/// Per-target compilation adapter.
95///
96/// Implementations encapsulate all per-target knowledge:
97/// - Which item kinds this target accepts
98/// - Default destination path layout
99/// - Config-entry format (future: MCP, hooks, model aliases)
100///
101/// The trait is split into file-output surfaces and config-entry surfaces so
102/// parallel pipeline lanes can own disjoint write responsibilities without
103/// interfering with each other.
104///
105/// # Object safety
106/// All methods take `&self` and return concrete types to ensure the trait can
107/// be used as `dyn TargetAdapter`.
108pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
109    /// Target root name (e.g., `.claude`, `.codex`).
110    fn name(&self) -> &str;
111
112    /// Documented native command-hook events, or `None` when this target has
113    /// no declarative command-hook mechanism.
114    fn known_hook_events(&self) -> Option<&'static [&'static str]> {
115        None
116    }
117
118    /// Native fragment placement mechanism declared by this adapter.
119    fn hook_fragment_mode(&self) -> Option<HookFragmentMode> {
120        None
121    }
122
123    /// Relative destination for an opaque file-mode fragment.
124    fn hook_file_dest_path(&self, _name: &str) -> Option<PathBuf> {
125        None
126    }
127
128    /// Skill variant harness key used when projecting skills to this target.
129    ///
130    /// Native harness targets return the `variants/<key>/` directory name they
131    /// consume. Full-fidelity targets that should not select skill variants
132    /// return `None`.
133    fn skill_variant_key(&self) -> Option<&str>;
134
135    // -----------------------------------------------------------------------
136    // Path resolution
137    // -----------------------------------------------------------------------
138
139    /// Default destination path for an item of the given kind and name.
140    ///
141    /// Returns `None` if this target does not accept the item kind. The
142    /// compiler MUST skip items for which this returns `None`.
143    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
144
145    // -----------------------------------------------------------------------
146    // Config-file writing
147    // -----------------------------------------------------------------------
148
149    /// Write config entries (MCP servers, hooks) to this target's config file.
150    ///
151    /// Returns the paths of files written, for lock tracking.
152    /// Default: no-op — targets that don't use a config file leave this as-is.
153    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    /// Config files mutated by MCP entries.
163    fn mcp_config_file_names(&self) -> &'static [&'static str] {
164        &[]
165    }
166
167    /// Config files mutated by merge-mode hook entries.
168    fn hook_config_file_names(&self) -> &'static [&'static str] {
169        &[]
170    }
171
172    /// One-release legacy hook files touched only when old lock records lack
173    /// structural emission data.
174    fn legacy_hook_config_file_names(&self) -> &'static [&'static str] {
175        &[]
176    }
177
178    /// Emit target-specific pre-write diagnostics (e.g., lossiness warnings).
179    ///
180    /// Called unconditionally before `write_config_entries`, even on dry runs.
181    /// Default: no-op — most targets have no pre-write diagnostics.
182    fn emit_pre_write_diagnostics(
183        &self,
184        _entries: &[ConfigEntry],
185        _diag: &mut crate::diagnostic::DiagnosticCollector,
186    ) {
187    }
188
189    /// Remove hook entries recorded in the previous lock by structural equality.
190    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    /// Remove stale config entries from this target's config file.
201    ///
202    /// `entry_keys` are the `ConfigEntry::key` values to remove.
203    /// Default: no-op.
204    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
320/// Registry of target adapters, keyed by target root name.
321///
322/// Constructed once per sync run. Adapters are registered at startup; no
323/// dynamic registration is needed.
324pub struct TargetRegistry {
325    adapters: Vec<Box<dyn TargetAdapter>>,
326}
327
328impl TargetRegistry {
329    /// Build a registry containing all built-in target adapters.
330    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    /// Look up an adapter by target root name.
344    ///
345    /// Returns `None` if no adapter is registered for the given name. Callers
346    /// may fall back to a default behavior (currently: pass-through copy) when
347    /// no adapter is found.
348    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
362/// Return an error message when an agent name would create a Windows-invalid
363/// native filename. Runs on every platform so generated packages stay portable.
364pub 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}