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 indexmap::IndexMap;
22
23use crate::error::MarsError;
24use crate::lock::ItemKind;
25use crate::types::DestPath;
26
27const WINDOWS_INVALID_CHARS: &[char] = &[':', '*', '?', '<', '>', '|', '"', '/', '\\'];
28
29/// A config entry to be written to a target's config file.
30///
31/// Adapters consume these entries to write or update target-specific config
32/// files (MCP JSON, hooks in settings.json, etc.).
33#[derive(Debug, Clone)]
34pub enum ConfigEntry {
35    /// An MCP server entry to register in the target's MCP config file.
36    McpServer(McpServerEntry),
37    /// A hook binding to register in the target's hook config.
38    Hook(HookEntry),
39}
40
41impl ConfigEntry {
42    /// Stable identity key for this entry (used by stale-cleanup logic).
43    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/// An MCP server entry ready to be written into a target config file.
52///
53/// Env values are variable names (symbolic). Adapters translate them to the
54/// target's interpolation syntax (e.g. `${VAR}` for Claude, plain name for Codex).
55#[derive(Debug, Clone)]
56pub struct McpServerEntry {
57    /// Server name as it appears in the target config.
58    pub name: String,
59    /// Launch command.
60    pub command: String,
61    /// Launch arguments.
62    pub args: Vec<String>,
63    /// Env vars: config key → environment variable name (symbolic, never resolved).
64    pub env: IndexMap<String, String>,
65}
66
67/// A hook binding entry ready to be written into a target config file.
68#[derive(Debug, Clone)]
69pub struct HookEntry {
70    /// Hook name (for identification — two hooks with the same name from
71    /// different packages are both executed; hooks are additive).
72    pub name: String,
73    /// Universal event name (e.g. "tool.pre").
74    pub event: String,
75    /// Native event name for this target (e.g. "PreToolUse" for Claude).
76    pub native_event: String,
77    /// Script path to execute, relative to the target directory.
78    pub script_path: String,
79    /// Explicit ordering hint (lower = earlier).
80    pub order: i32,
81}
82
83/// Per-target compilation adapter.
84///
85/// Implementations encapsulate all per-target knowledge:
86/// - Which item kinds this target accepts
87/// - Default destination path layout
88/// - Config-entry format (future: MCP, hooks, model aliases)
89///
90/// The trait is split into file-output surfaces and config-entry surfaces so
91/// parallel pipeline lanes can own disjoint write responsibilities without
92/// interfering with each other.
93///
94/// # Object safety
95/// All methods take `&self` and return concrete types to ensure the trait can
96/// be used as `dyn TargetAdapter`.
97pub trait TargetAdapter: std::fmt::Debug + Send + Sync {
98    /// Target root name (e.g., `.claude`, `.codex`).
99    fn name(&self) -> &str;
100
101    /// Skill variant harness key used when projecting skills to this target.
102    ///
103    /// Native harness targets return the `variants/<key>/` directory name they
104    /// consume. Full-fidelity targets that should not select skill variants
105    /// return `None`.
106    fn skill_variant_key(&self) -> Option<&str>;
107
108    // -----------------------------------------------------------------------
109    // Path resolution
110    // -----------------------------------------------------------------------
111
112    /// Default destination path for an item of the given kind and name.
113    ///
114    /// Returns `None` if this target does not accept the item kind. The
115    /// compiler MUST skip items for which this returns `None`.
116    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath>;
117
118    // -----------------------------------------------------------------------
119    // Config-file writing
120    // -----------------------------------------------------------------------
121
122    /// Write config entries (MCP servers, hooks) to this target's config file.
123    ///
124    /// Returns the paths of files written, for lock tracking.
125    /// Default: no-op — targets that don't use a config file leave this as-is.
126    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    /// Emit target-specific pre-write diagnostics (e.g., lossiness warnings).
135    ///
136    /// Called unconditionally before `write_config_entries`, even on dry runs.
137    /// Default: no-op — most targets have no pre-write diagnostics.
138    fn emit_pre_write_diagnostics(
139        &self,
140        _entries: &[ConfigEntry],
141        _diag: &mut crate::diagnostic::DiagnosticCollector,
142    ) {
143    }
144
145    /// Remove stale config entries from this target's config file.
146    ///
147    /// `entry_keys` are the `ConfigEntry::key` values to remove.
148    /// Default: no-op.
149    fn remove_config_entries(
150        &self,
151        _entry_keys: &[String],
152        _target_dir: &Path,
153    ) -> Result<(), MarsError> {
154        Ok(())
155    }
156}
157
158/// Registry of target adapters, keyed by target root name.
159///
160/// Constructed once per sync run. Adapters are registered at startup; no
161/// dynamic registration is needed.
162pub struct TargetRegistry {
163    adapters: Vec<Box<dyn TargetAdapter>>,
164}
165
166impl TargetRegistry {
167    /// Build a registry containing all built-in target adapters.
168    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    /// Look up an adapter by target root name.
182    ///
183    /// Returns `None` if no adapter is registered for the given name. Callers
184    /// may fall back to a default behavior (currently: pass-through copy) when
185    /// no adapter is found.
186    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    /// Iterate over all registered adapters.
194    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
205/// Build a platform-appropriate command string for executing a hook script.
206pub 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        // Use double quotes for Windows cmd.exe compatibility.
213        format!("bash \"{}\"", script_path.replace('\\', "/"))
214    } else {
215        // POSIX: single quotes with proper escaping.
216        format!("bash '{}'", script_path.replace('\'', "'\\''"))
217    }
218}
219
220/// Return an error message when an agent name would create a Windows-invalid
221/// native filename. Runs on every platform so generated packages stay portable.
222pub 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}