1use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36
37use super::synth;
38use super::types::{HookEvent, HookResult};
39use super::{PayloadAdapter, PlatformAdapter};
40
41pub struct CursorAdapter;
43
44#[derive(Debug, Clone, Deserialize, Serialize, Default)]
49#[serde(rename_all = "snake_case")]
50pub(crate) struct CursorHookPayload {
51 #[serde(default)]
52 hook_event_name: Option<String>,
53 #[serde(default)]
54 conversation_id: Option<String>,
55 #[serde(default)]
56 workspace_roots: Option<Vec<String>>,
57 #[serde(default)]
58 cwd: Option<String>,
59 #[serde(default)]
60 tool_name: Option<String>,
61 #[serde(default)]
62 tool_input: Option<Value>,
63 #[serde(default)]
66 result_json: Option<Value>,
67 #[serde(default)]
70 command: Option<String>,
71 #[serde(default)]
72 output: Option<String>,
73 #[serde(default)]
76 prompt: Option<String>,
77 #[serde(default)]
78 query: Option<String>,
79 #[serde(default)]
80 input: Option<String>,
81 #[serde(default)]
82 message: Option<String>,
83 #[serde(default)]
86 file_path: Option<String>,
87}
88
89impl CursorHookPayload {
90 fn into_canonical(self) -> Result<HookEvent, String> {
93 let event_name = self
94 .hook_event_name
95 .as_deref()
96 .ok_or_else(|| "missing hook_event_name".to_owned())?;
97 match event_name {
98 "afterFileEdit" => Ok(post_tool_use_for_file_edit(self)),
99 "afterMCPExecution" => Ok(HookEvent::PostToolUse {
100 tool_name: "afterMCPExecution".to_owned(),
101 file_path: None,
102 diff: None,
103 session_id: None,
104 new_text: None,
105 old_text: None,
106 }),
107 "afterShellExecution" => Ok(HookEvent::PostToolUse {
108 tool_name: "Bash".to_owned(),
111 file_path: None,
112 diff: synth::diff_shell(self.command.as_deref(), self.output.as_deref()),
113 session_id: None,
114 new_text: None,
115 old_text: None,
116 }),
117 "beforeSubmitPrompt" => {
118 let prompt = self
119 .prompt
120 .or(self.query)
121 .or(self.input)
122 .or(self.message)
123 .unwrap_or_default();
124 Ok(HookEvent::UserPromptSubmit {
125 prompt,
126 session_id: None,
127 })
128 }
129 "stop" => Ok(HookEvent::Stop {
130 session_id: None,
131 transcript_path: None,
132 cwd: None,
133 }),
134 other => Err(format!("unsupported Cursor hook event: {other}")),
135 }
136 }
137}
138
139fn post_tool_use_for_file_edit(p: CursorHookPayload) -> HookEvent {
146 let file_path = p.file_path.clone().or_else(|| {
147 p.tool_input
148 .as_ref()
149 .and_then(|v| v.get("file_path").or_else(|| v.get("path")))
150 .and_then(|v| v.as_str())
151 .map(String::from)
152 });
153 let diff = synthesise_edit_diff(p.tool_input.as_ref());
154 let (old_text, new_text) = synth::extract_edit_strings(p.tool_input.as_ref());
155 HookEvent::PostToolUse {
156 tool_name: p.tool_name.unwrap_or_else(|| "Edit".to_owned()),
157 file_path,
158 diff,
159 session_id: None,
160 new_text,
161 old_text,
162 }
163}
164
165fn synthesise_edit_diff(tool_input: Option<&Value>) -> Option<String> {
174 let input = tool_input?;
175 if let Some(edits) = input.get("edits").and_then(|v| v.as_array()) {
176 let mut out = String::new();
177 for edit in edits {
178 if let (Some(old), Some(new)) = (
179 edit.get("old_string").and_then(|v| v.as_str()),
180 edit.get("new_string").and_then(|v| v.as_str()),
181 ) {
182 synth::append_old_new(&mut out, old, new);
183 }
184 }
185 if !out.is_empty() {
186 return Some(out);
187 }
188 }
189 if let (Some(old), Some(new)) = (
190 input.get("old_string").and_then(|v| v.as_str()),
191 input.get("new_string").and_then(|v| v.as_str()),
192 ) {
193 return Some(synth::diff_old_new(old, new));
194 }
195 if let Some(content) = input.get("content").and_then(|v| v.as_str()) {
196 return Some(synth::diff_content(content));
197 }
198 None
199}
200
201impl PayloadAdapter for CursorAdapter {
202 type Raw = CursorHookPayload;
203 const PARSE_LABEL: &'static str = "Cursor";
204
205 fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
206 raw.into_canonical()
207 }
208}
209
210impl PlatformAdapter for CursorAdapter {
211 fn name(&self) -> &'static str {
212 "cursor"
213 }
214
215 fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
216 Self::parse_stdin_default(raw)
217 }
218
219 fn format_output(&self, result: HookResult) -> String {
220 let mut obj = json!({
225 "continue": result.continue_,
226 });
227 if let Some(ctx) = result.additional_context {
228 obj["context"] = Value::String(ctx);
229 }
230 if let Some(msg) = result.system_message {
231 obj["systemMessage"] = Value::String(msg);
232 }
233 crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn parse_after_file_edit_flat_form() {
243 let adapter = CursorAdapter;
245 let raw = r#"{
246 "hook_event_name": "afterFileEdit",
247 "workspace_roots": ["/tmp/proj"],
248 "tool_name": "Edit",
249 "tool_input": {
250 "file_path": "src/foo.rs",
251 "old_string": "let x = 1;",
252 "new_string": "let x = 2;"
253 }
254 }"#;
255 let event = adapter.parse_stdin(raw).expect("parse ok");
256 match event {
257 HookEvent::PostToolUse {
258 tool_name,
259 file_path,
260 diff,
261 ..
262 } => {
263 assert_eq!(tool_name, "Edit");
264 assert_eq!(file_path.as_deref(), Some("src/foo.rs"));
265 let d = diff.expect("diff synthesised");
266 assert!(d.contains("-let x = 1;"));
267 assert!(d.contains("+let x = 2;"));
268 }
269 other => panic!("expected PostToolUse, got {other:?}"),
270 }
271 }
272
273 #[test]
274 fn parse_after_file_edit_array_form_with_edits() {
275 let adapter = CursorAdapter;
279 let raw = r#"{
280 "hook_event_name": "afterFileEdit",
281 "tool_input": {
282 "path": "src/bar.rs",
283 "edits": [
284 { "old_string": "A", "new_string": "B" },
285 { "old_string": "C", "new_string": "D" }
286 ]
287 }
288 }"#;
289 let event = adapter.parse_stdin(raw).expect("parse ok");
290 if let HookEvent::PostToolUse {
291 file_path, diff, ..
292 } = event
293 {
294 assert_eq!(file_path.as_deref(), Some("src/bar.rs"));
296 let d = diff.expect("array form diff synthesised");
297 assert!(d.contains("-A") && d.contains("+B"));
298 assert!(d.contains("-C") && d.contains("+D"));
299 } else {
300 panic!("expected PostToolUse");
301 }
302 }
303
304 #[test]
305 fn parse_after_shell_execution_synthesises_bash_diff() {
306 let adapter = CursorAdapter;
307 let raw = r#"{
308 "hook_event_name": "afterShellExecution",
309 "command": "echo hi",
310 "output": "hi\n"
311 }"#;
312 let event = adapter.parse_stdin(raw).expect("parse ok");
313 if let HookEvent::PostToolUse {
314 tool_name,
315 file_path,
316 diff,
317 ..
318 } = event
319 {
320 assert_eq!(tool_name, "Bash");
321 assert!(file_path.is_none());
322 let d = diff.expect("shell diff");
323 assert!(d.contains("$ echo hi"));
324 assert!(d.contains("+hi"));
325 } else {
326 panic!("expected PostToolUse");
327 }
328 }
329
330 #[test]
331 fn parse_before_submit_prompt_probes_alt_keys() {
332 let adapter = CursorAdapter;
336 let raw = r#"{"hook_event_name":"beforeSubmitPrompt","query":"hello"}"#;
337 let event = adapter.parse_stdin(raw).expect("parse ok");
338 assert_eq!(
339 event,
340 HookEvent::UserPromptSubmit {
341 prompt: "hello".into(),
342 session_id: None,
343 }
344 );
345 }
346
347 #[test]
348 fn parse_unsupported_event_errors() {
349 let adapter = CursorAdapter;
350 let err = adapter
351 .parse_stdin(r#"{"hook_event_name":"someNewCursorEvent"}"#)
352 .unwrap_err();
353 assert!(err.contains("unsupported"), "got: {err}");
354 }
355
356 #[test]
357 fn format_output_noop_emits_continue_only() {
358 let adapter = CursorAdapter;
359 let out = adapter.format_output(HookResult::noop());
360 let v: Value = serde_json::from_str(&out).unwrap();
361 assert_eq!(v["continue"], true);
362 assert!(v.get("context").is_none());
363 }
364
365 #[test]
366 fn format_output_with_context_includes_context_field() {
367 let adapter = CursorAdapter;
370 let out = adapter.format_output(HookResult::with_context("Rule 1: X"));
371 let v: Value = serde_json::from_str(&out).unwrap();
372 assert_eq!(v["continue"], true);
373 assert_eq!(v["context"], "Rule 1: X");
374 }
375}