mars_agents/target/
cursor.rs1use std::path::{Path, PathBuf};
9
10use crate::diagnostic::{DiagnosticCategory, DiagnosticCollector};
11use crate::error::MarsError;
12use crate::lock::ItemKind;
13use crate::types::DestPath;
14
15use super::{ConfigEntry, McpServerEntry, TargetAdapter};
16
17#[derive(Debug)]
18pub struct CursorAdapter;
19
20impl TargetAdapter for CursorAdapter {
21 fn name(&self) -> &str {
22 ".cursor"
23 }
24
25 fn skill_variant_key(&self) -> Option<&str> {
26 Some("cursor")
27 }
28
29 fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
30 match kind {
31 ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
32 _ => None,
33 }
34 }
35
36 fn emit_pre_write_diagnostics(
37 &self,
38 entries: &[ConfigEntry],
39 diag: &mut crate::diagnostic::DiagnosticCollector,
40 ) {
41 CursorAdapter::emit_hook_lossiness_diagnostics(entries, diag);
42 }
43
44 fn write_config_entries(
45 &self,
46 entries: &[ConfigEntry],
47 target_dir: &Path,
48 ) -> Result<Vec<PathBuf>, MarsError> {
49 let mcp_servers: Vec<&McpServerEntry> = entries
50 .iter()
51 .filter_map(|e| {
52 if let ConfigEntry::McpServer(s) = e {
53 Some(s)
54 } else {
55 None
56 }
57 })
58 .collect();
59
60 if mcp_servers.is_empty() {
64 return Ok(Vec::new());
65 }
66
67 let path = write_cursor_mcp_json(target_dir, &mcp_servers)?;
68 Ok(vec![path])
69 }
70
71 fn remove_config_entries(
72 &self,
73 entry_keys: &[String],
74 target_dir: &Path,
75 ) -> Result<(), MarsError> {
76 remove_cursor_mcp_entries(entry_keys, target_dir)
77 }
78}
79
80impl CursorAdapter {
81 pub fn emit_hook_lossiness_diagnostics(
86 entries: &[ConfigEntry],
87 diag: &mut DiagnosticCollector,
88 ) {
89 for entry in entries {
90 if let ConfigEntry::Hook(hook) = entry {
91 diag.warn_with_category(
92 "hook-dropped",
93 format!(
94 "hook `{}` (event `{}`) dropped for target `.cursor` — \
95 Cursor has no native hook support",
96 hook.name, hook.event
97 ),
98 DiagnosticCategory::Lossiness,
99 );
100 }
101 }
102 }
103}
104
105fn write_cursor_mcp_json(
121 target_dir: &Path,
122 servers: &[&McpServerEntry],
123) -> Result<PathBuf, MarsError> {
124 let path = target_dir.join("mcp.json");
125
126 let mut root: serde_json::Value = if path.is_file() {
127 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
128 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
129 } else {
130 serde_json::json!({})
131 };
132
133 let mcp_obj = root
134 .as_object_mut()
135 .ok_or_else(|| {
136 MarsError::Config(crate::error::ConfigError::Invalid {
137 message: format!("{} is not a JSON object", path.display()),
138 })
139 })?
140 .entry("mcpServers")
141 .or_insert_with(|| serde_json::json!({}));
142
143 let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
144 MarsError::Config(crate::error::ConfigError::Invalid {
145 message: format!("{}: mcpServers is not an object", path.display()),
146 })
147 })?;
148
149 for server in servers {
150 let mut entry = serde_json::json!({
151 "command": server.command,
152 "args": server.args,
153 });
154
155 if !server.env.is_empty() {
157 let env_obj: serde_json::Map<String, serde_json::Value> = server
158 .env
159 .iter()
160 .map(|(k, v)| {
161 (
162 k.clone(),
163 serde_json::Value::String(format!("${{env:{v}}}")),
164 )
165 })
166 .collect();
167 entry["env"] = serde_json::Value::Object(env_obj);
168 }
169
170 mcp_map.insert(server.name.clone(), entry);
171 }
172
173 let content = serde_json::to_string_pretty(&root).map_err(|e| {
174 MarsError::Config(crate::error::ConfigError::Invalid {
175 message: format!("failed to serialize {}: {e}", path.display()),
176 })
177 })?;
178 crate::fs::atomic_write(&path, content.as_bytes())?;
179
180 Ok(path)
181}
182
183fn remove_cursor_mcp_entries(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
184 let path = target_dir.join("mcp.json");
185 if !path.is_file() {
186 return Ok(());
187 }
188
189 let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
190 let mut root: serde_json::Value =
191 serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
192
193 if let Some(mcp_map) = root
194 .as_object_mut()
195 .and_then(|o| o.get_mut("mcpServers"))
196 .and_then(|v| v.as_object_mut())
197 {
198 for key in entry_keys {
199 if let Some(name) = key.strip_prefix("mcp:") {
200 mcp_map.remove(name);
201 }
202 }
203 }
204
205 let content = serde_json::to_string_pretty(&root).map_err(|e| {
206 MarsError::Config(crate::error::ConfigError::Invalid {
207 message: format!("failed to serialize {}: {e}", path.display()),
208 })
209 })?;
210 crate::fs::atomic_write(&path, content.as_bytes())?;
211 Ok(())
212}
213
214#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::target::{HookEntry, McpServerEntry};
222 use indexmap::IndexMap;
223 use tempfile::TempDir;
224
225 fn make_mcp_entry(name: &str, env_var: Option<(&str, &str)>) -> ConfigEntry {
226 let mut env = IndexMap::new();
227 if let Some((k, v)) = env_var {
228 env.insert(k.to_string(), v.to_string());
229 }
230 ConfigEntry::McpServer(McpServerEntry {
231 name: name.to_string(),
232 command: "npx".to_string(),
233 args: vec![],
234 env,
235 })
236 }
237
238 fn make_hook_entry(name: &str) -> ConfigEntry {
239 ConfigEntry::Hook(HookEntry {
240 name: name.to_string(),
241 event: "tool.pre".to_string(),
242 native_event: "PreToolUse".to_string(),
243 script_path: "/hooks/run.sh".to_string(),
244 order: 0,
245 })
246 }
247
248 #[test]
249 fn write_mcp_creates_mcp_json() {
250 let tmp = TempDir::new().unwrap();
251 let adapter = CursorAdapter;
252 let entries = vec![make_mcp_entry("context7", None)];
253 let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
254 assert_eq!(written.len(), 1);
255 assert!(tmp.path().join("mcp.json").exists());
256
257 let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
258 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
259 assert!(json["mcpServers"]["context7"].is_object());
260 }
261
262 #[test]
263 fn write_mcp_env_uses_cursor_interpolation() {
264 let tmp = TempDir::new().unwrap();
265 let adapter = CursorAdapter;
266 let entries = vec![make_mcp_entry("server", Some(("API_KEY", "MY_SECRET")))];
267 adapter.write_config_entries(&entries, tmp.path()).unwrap();
268
269 let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
270 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
271 assert_eq!(
273 json["mcpServers"]["server"]["env"]["API_KEY"],
274 "${env:MY_SECRET}"
275 );
276 }
277
278 #[test]
279 fn write_hooks_dropped_no_file_written() {
280 let tmp = TempDir::new().unwrap();
281 let adapter = CursorAdapter;
282 let entries = vec![make_hook_entry("audit")];
283 let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
284 assert!(written.is_empty());
286 assert!(!tmp.path().join("settings.json").exists());
287 }
288
289 #[test]
290 fn hook_lossiness_emits_diagnostic() {
291 let entries = vec![make_hook_entry("audit")];
292 let mut diag = crate::diagnostic::DiagnosticCollector::with_lossiness_mode(
293 crate::diagnostic::LossinessMode::Surface,
294 );
295 CursorAdapter::emit_hook_lossiness_diagnostics(&entries, &mut diag);
296 let collected = diag.drain();
297 assert_eq!(collected.len(), 1);
298 assert!(collected[0].message.contains("dropped"));
299 assert_eq!(
300 collected[0].category,
301 Some(crate::diagnostic::DiagnosticCategory::Lossiness)
302 );
303 }
304
305 #[test]
306 fn remove_mcp_entries_preserves_others() {
307 let tmp = TempDir::new().unwrap();
308 let adapter = CursorAdapter;
309 let entries = vec![
310 make_mcp_entry("to-remove", None),
311 make_mcp_entry("to-keep", None),
312 ];
313 adapter.write_config_entries(&entries, tmp.path()).unwrap();
314
315 adapter
316 .remove_config_entries(&["mcp:to-remove".to_string()], tmp.path())
317 .unwrap();
318
319 let raw = std::fs::read_to_string(tmp.path().join("mcp.json")).unwrap();
320 let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
321 assert!(json["mcpServers"]["to-remove"].is_null());
322 assert!(json["mcpServers"]["to-keep"].is_object());
323 }
324}