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