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