use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::synth;
use super::types::{HookEvent, HookResult};
use super::{PayloadAdapter, PlatformAdapter};
pub struct WindsurfAdapter;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "snake_case")]
pub(crate) struct WindsurfHookPayload {
#[serde(default)]
agent_action_name: Option<String>,
#[serde(default)]
trajectory_id: Option<String>,
#[serde(default)]
execution_id: Option<String>,
#[serde(default)]
tool_info: Option<Value>,
}
impl WindsurfHookPayload {
fn into_canonical(self) -> Result<HookEvent, String> {
let action = self
.agent_action_name
.as_deref()
.ok_or_else(|| "missing agent_action_name".to_owned())?;
let info = self.tool_info.as_ref();
match action {
"session_start" | "beforeAgentResponse" => Ok(HookEvent::SessionStart {
cwd: extract_cwd(info),
session_id: None,
}),
"pre_user_prompt" => Ok(HookEvent::UserPromptSubmit {
prompt: info
.and_then(|v| v.get("user_prompt"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned(),
session_id: None,
}),
"post_write_code" => {
let new_text = info
.and_then(|v| v.get("new_code").or_else(|| v.get("content")))
.and_then(|v| v.as_str())
.map(String::from);
let old_text = info
.and_then(|v| v.get("old_code"))
.and_then(|v| v.as_str())
.map(String::from);
Ok(HookEvent::PostToolUse {
tool_name: "Write".to_owned(),
file_path: info
.and_then(|v| v.get("file_path"))
.and_then(|v| v.as_str())
.map(String::from),
diff: synthesise_write_diff(info),
session_id: None,
new_text,
old_text,
})
}
"post_run_command" => Ok(HookEvent::PostToolUse {
tool_name: "Bash".to_owned(),
file_path: None,
diff: synthesise_command_diff(info),
session_id: None,
new_text: None,
old_text: None,
}),
"post_mcp_tool_use" => Ok(HookEvent::PostToolUse {
tool_name: info
.and_then(|v| v.get("mcp_tool_name"))
.and_then(|v| v.as_str())
.unwrap_or("mcp_tool")
.to_owned(),
file_path: None,
diff: synthesise_mcp_diff(info),
session_id: None,
new_text: None,
old_text: None,
}),
"post_cascade_response" => Ok(HookEvent::Stop {
session_id: None,
transcript_path: None,
cwd: None,
}),
other => Err(format!("unsupported Windsurf hook action: {other}")),
}
}
}
fn extract_cwd(info: Option<&Value>) -> String {
info.and_then(|v| v.get("cwd"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned()
}
fn synthesise_write_diff(info: Option<&Value>) -> Option<String> {
let info = info?;
if let Some(edits) = info.get("edits").and_then(|v| v.as_array()) {
let mut out = String::new();
for edit in edits {
if let (Some(old), Some(new)) = (
edit.get("old_string").and_then(|v| v.as_str()),
edit.get("new_string").and_then(|v| v.as_str()),
) {
synth::append_old_new(&mut out, old, new);
}
}
if !out.is_empty() {
return Some(out);
}
}
if let Some(content) = info.get("content").and_then(|v| v.as_str()) {
return Some(synth::diff_content(content));
}
None
}
fn synthesise_command_diff(info: Option<&Value>) -> Option<String> {
let cmd = info?.get("command_line").and_then(|v| v.as_str())?;
synth::diff_shell(Some(cmd), None)
}
fn synthesise_mcp_diff(info: Option<&Value>) -> Option<String> {
let info = info?;
let mut out = String::new();
if let Some(args) = info.get("mcp_tool_arguments") {
out.push_str("+ mcp_tool_arguments: ");
out.push_str(&args.to_string());
out.push('\n');
}
if let Some(res) = info.get("mcp_result") {
out.push_str("+ mcp_result: ");
out.push_str(&res.to_string());
out.push('\n');
}
if out.is_empty() { None } else { Some(out) }
}
impl PayloadAdapter for WindsurfAdapter {
type Raw = WindsurfHookPayload;
const PARSE_LABEL: &'static str = "Windsurf";
fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
raw.into_canonical()
}
}
impl PlatformAdapter for WindsurfAdapter {
fn name(&self) -> &'static str {
"windsurf"
}
fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
Self::parse_stdin_default(raw)
}
fn format_output(&self, result: HookResult) -> String {
let mut obj = json!({ "continue": result.continue_ });
if let Some(ctx) = result.additional_context {
obj["context"] = Value::String(ctx);
}
if let Some(msg) = result.system_message {
obj["systemMessage"] = Value::String(msg);
}
crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_before_agent_response_also_maps_to_session_start() {
let adapter = WindsurfAdapter;
let raw = r#"{"agent_action_name":"beforeAgentResponse","tool_info":{}}"#;
if let HookEvent::SessionStart { .. } = adapter.parse_stdin(raw).unwrap() {
} else {
panic!("expected SessionStart");
}
}
#[test]
fn parse_pre_user_prompt_extracts_prompt() {
let adapter = WindsurfAdapter;
let raw =
r#"{"agent_action_name":"pre_user_prompt","tool_info":{"user_prompt":"hi there"}}"#;
assert_eq!(
adapter.parse_stdin(raw).unwrap(),
HookEvent::UserPromptSubmit {
prompt: "hi there".into(),
session_id: None,
}
);
}
#[test]
fn parse_post_write_code_collects_edits_into_diff() {
let adapter = WindsurfAdapter;
let raw = r#"{
"agent_action_name": "post_write_code",
"tool_info": {
"file_path": "src/a.ts",
"edits": [
{ "old_string": "x", "new_string": "y" },
{ "old_string": "1", "new_string": "2" }
]
}
}"#;
if let HookEvent::PostToolUse {
tool_name,
file_path,
diff,
..
} = adapter.parse_stdin(raw).unwrap()
{
assert_eq!(tool_name, "Write");
assert_eq!(file_path.as_deref(), Some("src/a.ts"));
let d = diff.unwrap();
assert!(d.contains("-x") && d.contains("+y"));
assert!(d.contains("-1") && d.contains("+2"));
} else {
panic!("expected PostToolUse");
}
}
#[test]
fn parse_post_run_command_maps_to_bash() {
let adapter = WindsurfAdapter;
let raw = r#"{
"agent_action_name": "post_run_command",
"tool_info": { "command_line": "npm test", "cwd": "/w/p" }
}"#;
if let HookEvent::PostToolUse {
tool_name,
file_path,
diff,
..
} = adapter.parse_stdin(raw).unwrap()
{
assert_eq!(tool_name, "Bash");
assert!(file_path.is_none());
assert_eq!(diff.as_deref(), Some("$ npm test\n"));
} else {
panic!("expected PostToolUse");
}
}
#[test]
fn parse_post_mcp_tool_use_preserves_tool_name() {
let adapter = WindsurfAdapter;
let raw = r#"{
"agent_action_name": "post_mcp_tool_use",
"tool_info": {
"mcp_server_name": "difflore",
"mcp_tool_name": "search_rules",
"mcp_tool_arguments": {"diff": "foo"},
"mcp_result": {"rules": []}
}
}"#;
if let HookEvent::PostToolUse {
tool_name, diff, ..
} = adapter.parse_stdin(raw).unwrap()
{
assert_eq!(tool_name, "search_rules");
let d = diff.unwrap();
assert!(d.contains("mcp_tool_arguments"));
assert!(d.contains("mcp_result"));
} else {
panic!("expected PostToolUse");
}
}
#[test]
fn parse_unknown_action_errors() {
let adapter = WindsurfAdapter;
let err = adapter
.parse_stdin(r#"{"agent_action_name":"post_future_thing","tool_info":{}}"#)
.unwrap_err();
assert!(err.contains("unsupported"), "got: {err}");
}
#[test]
fn parse_missing_action_errors() {
let adapter = WindsurfAdapter;
let err = adapter.parse_stdin(r"{}").unwrap_err();
assert!(err.contains("missing"), "got: {err}");
}
#[test]
fn format_output_noop_emits_continue() {
let adapter = WindsurfAdapter;
let out = adapter.format_output(HookResult::noop());
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["continue"], true);
}
#[test]
fn format_output_with_context_adds_context_field() {
let adapter = WindsurfAdapter;
let out = adapter.format_output(HookResult::with_context("rule"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["context"], "rule");
}
}