1use serde::{Deserialize, Serialize};
42use serde_json::{Value, json};
43
44use super::synth;
45use super::types::{HookEvent, HookResult};
46use super::{PayloadAdapter, PlatformAdapter};
47
48pub struct WindsurfAdapter;
50
51#[derive(Debug, Clone, Deserialize, Serialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub(crate) struct WindsurfHookPayload {
56 #[serde(default)]
57 agent_action_name: Option<String>,
58 #[serde(default)]
59 trajectory_id: Option<String>,
60 #[serde(default)]
61 execution_id: Option<String>,
62 #[serde(default)]
63 tool_info: Option<Value>,
64}
65
66impl WindsurfHookPayload {
67 fn into_canonical(self) -> Result<HookEvent, String> {
68 let action = self
69 .agent_action_name
70 .as_deref()
71 .ok_or_else(|| "missing agent_action_name".to_owned())?;
72 let info = self.tool_info.as_ref();
73 match action {
74 "session_start" | "beforeAgentResponse" => Ok(HookEvent::SessionStart {
77 cwd: extract_cwd(info),
78 session_id: None,
79 }),
80 "pre_user_prompt" => Ok(HookEvent::UserPromptSubmit {
81 prompt: info
82 .and_then(|v| v.get("user_prompt"))
83 .and_then(|v| v.as_str())
84 .unwrap_or_default()
85 .to_owned(),
86 session_id: None,
87 }),
88 "post_write_code" => {
89 let new_text = info
90 .and_then(|v| v.get("new_code").or_else(|| v.get("content")))
91 .and_then(|v| v.as_str())
92 .map(String::from);
93 let old_text = info
94 .and_then(|v| v.get("old_code"))
95 .and_then(|v| v.as_str())
96 .map(String::from);
97 Ok(HookEvent::PostToolUse {
98 tool_name: "Write".to_owned(),
99 file_path: info
100 .and_then(|v| v.get("file_path"))
101 .and_then(|v| v.as_str())
102 .map(String::from),
103 diff: synthesise_write_diff(info),
104 session_id: None,
105 new_text,
106 old_text,
107 })
108 }
109 "post_run_command" => Ok(HookEvent::PostToolUse {
110 tool_name: "Bash".to_owned(),
111 file_path: None,
112 diff: synthesise_command_diff(info),
113 session_id: None,
114 new_text: None,
115 old_text: None,
116 }),
117 "post_mcp_tool_use" => Ok(HookEvent::PostToolUse {
118 tool_name: info
119 .and_then(|v| v.get("mcp_tool_name"))
120 .and_then(|v| v.as_str())
121 .unwrap_or("mcp_tool")
122 .to_owned(),
123 file_path: None,
124 diff: synthesise_mcp_diff(info),
125 session_id: None,
126 new_text: None,
127 old_text: None,
128 }),
129 "post_cascade_response" => Ok(HookEvent::Stop {
130 session_id: None,
131 transcript_path: None,
132 cwd: None,
133 }),
134 other => Err(format!("unsupported Windsurf hook action: {other}")),
135 }
136 }
137}
138
139fn extract_cwd(info: Option<&Value>) -> String {
140 info.and_then(|v| v.get("cwd"))
141 .and_then(|v| v.as_str())
142 .unwrap_or_default()
143 .to_owned()
144}
145
146fn synthesise_write_diff(info: Option<&Value>) -> Option<String> {
149 let info = info?;
150 if let Some(edits) = info.get("edits").and_then(|v| v.as_array()) {
151 let mut out = String::new();
152 for edit in edits {
153 if let (Some(old), Some(new)) = (
154 edit.get("old_string").and_then(|v| v.as_str()),
155 edit.get("new_string").and_then(|v| v.as_str()),
156 ) {
157 synth::append_old_new(&mut out, old, new);
158 }
159 }
160 if !out.is_empty() {
161 return Some(out);
162 }
163 }
164 if let Some(content) = info.get("content").and_then(|v| v.as_str()) {
165 return Some(synth::diff_content(content));
166 }
167 None
168}
169
170fn synthesise_command_diff(info: Option<&Value>) -> Option<String> {
173 let cmd = info?.get("command_line").and_then(|v| v.as_str())?;
174 synth::diff_shell(Some(cmd), None)
175}
176
177fn synthesise_mcp_diff(info: Option<&Value>) -> Option<String> {
181 let info = info?;
182 let mut out = String::new();
183 if let Some(args) = info.get("mcp_tool_arguments") {
184 out.push_str("+ mcp_tool_arguments: ");
185 out.push_str(&args.to_string());
186 out.push('\n');
187 }
188 if let Some(res) = info.get("mcp_result") {
189 out.push_str("+ mcp_result: ");
190 out.push_str(&res.to_string());
191 out.push('\n');
192 }
193 if out.is_empty() { None } else { Some(out) }
194}
195
196impl PayloadAdapter for WindsurfAdapter {
197 type Raw = WindsurfHookPayload;
198 const PARSE_LABEL: &'static str = "Windsurf";
199
200 fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
201 raw.into_canonical()
202 }
203}
204
205impl PlatformAdapter for WindsurfAdapter {
206 fn name(&self) -> &'static str {
207 "windsurf"
208 }
209
210 fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
211 Self::parse_stdin_default(raw)
212 }
213
214 fn format_output(&self, result: HookResult) -> String {
215 let mut obj = json!({ "continue": result.continue_ });
219 if let Some(ctx) = result.additional_context {
220 obj["context"] = Value::String(ctx);
223 }
224 if let Some(msg) = result.system_message {
225 obj["systemMessage"] = Value::String(msg);
226 }
227 crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn parse_before_agent_response_also_maps_to_session_start() {
237 let adapter = WindsurfAdapter;
238 let raw = r#"{"agent_action_name":"beforeAgentResponse","tool_info":{}}"#;
239 if let HookEvent::SessionStart { .. } = adapter.parse_stdin(raw).unwrap() {
240 } else {
242 panic!("expected SessionStart");
243 }
244 }
245
246 #[test]
247 fn parse_pre_user_prompt_extracts_prompt() {
248 let adapter = WindsurfAdapter;
249 let raw =
250 r#"{"agent_action_name":"pre_user_prompt","tool_info":{"user_prompt":"hi there"}}"#;
251 assert_eq!(
252 adapter.parse_stdin(raw).unwrap(),
253 HookEvent::UserPromptSubmit {
254 prompt: "hi there".into(),
255 session_id: None,
256 }
257 );
258 }
259
260 #[test]
261 fn parse_post_write_code_collects_edits_into_diff() {
262 let adapter = WindsurfAdapter;
263 let raw = r#"{
264 "agent_action_name": "post_write_code",
265 "tool_info": {
266 "file_path": "src/a.ts",
267 "edits": [
268 { "old_string": "x", "new_string": "y" },
269 { "old_string": "1", "new_string": "2" }
270 ]
271 }
272 }"#;
273 if let HookEvent::PostToolUse {
274 tool_name,
275 file_path,
276 diff,
277 ..
278 } = adapter.parse_stdin(raw).unwrap()
279 {
280 assert_eq!(tool_name, "Write");
281 assert_eq!(file_path.as_deref(), Some("src/a.ts"));
282 let d = diff.unwrap();
283 assert!(d.contains("-x") && d.contains("+y"));
284 assert!(d.contains("-1") && d.contains("+2"));
285 } else {
286 panic!("expected PostToolUse");
287 }
288 }
289
290 #[test]
291 fn parse_post_run_command_maps_to_bash() {
292 let adapter = WindsurfAdapter;
293 let raw = r#"{
294 "agent_action_name": "post_run_command",
295 "tool_info": { "command_line": "npm test", "cwd": "/w/p" }
296 }"#;
297 if let HookEvent::PostToolUse {
298 tool_name,
299 file_path,
300 diff,
301 ..
302 } = adapter.parse_stdin(raw).unwrap()
303 {
304 assert_eq!(tool_name, "Bash");
305 assert!(file_path.is_none());
306 assert_eq!(diff.as_deref(), Some("$ npm test\n"));
307 } else {
308 panic!("expected PostToolUse");
309 }
310 }
311
312 #[test]
313 fn parse_post_mcp_tool_use_preserves_tool_name() {
314 let adapter = WindsurfAdapter;
315 let raw = r#"{
316 "agent_action_name": "post_mcp_tool_use",
317 "tool_info": {
318 "mcp_server_name": "difflore",
319 "mcp_tool_name": "search_rules",
320 "mcp_tool_arguments": {"diff": "foo"},
321 "mcp_result": {"rules": []}
322 }
323 }"#;
324 if let HookEvent::PostToolUse {
325 tool_name, diff, ..
326 } = adapter.parse_stdin(raw).unwrap()
327 {
328 assert_eq!(tool_name, "search_rules");
329 let d = diff.unwrap();
330 assert!(d.contains("mcp_tool_arguments"));
331 assert!(d.contains("mcp_result"));
332 } else {
333 panic!("expected PostToolUse");
334 }
335 }
336
337 #[test]
338 fn parse_unknown_action_errors() {
339 let adapter = WindsurfAdapter;
340 let err = adapter
341 .parse_stdin(r#"{"agent_action_name":"post_future_thing","tool_info":{}}"#)
342 .unwrap_err();
343 assert!(err.contains("unsupported"), "got: {err}");
344 }
345
346 #[test]
347 fn parse_missing_action_errors() {
348 let adapter = WindsurfAdapter;
349 let err = adapter.parse_stdin(r"{}").unwrap_err();
350 assert!(err.contains("missing"), "got: {err}");
351 }
352
353 #[test]
354 fn format_output_noop_emits_continue() {
355 let adapter = WindsurfAdapter;
356 let out = adapter.format_output(HookResult::noop());
357 let v: Value = serde_json::from_str(&out).unwrap();
358 assert_eq!(v["continue"], true);
359 }
360
361 #[test]
362 fn format_output_with_context_adds_context_field() {
363 let adapter = WindsurfAdapter;
364 let out = adapter.format_output(HookResult::with_context("rule"));
365 let v: Value = serde_json::from_str(&out).unwrap();
366 assert_eq!(v["context"], "rule");
367 }
368}