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 session_id = non_empty(self.trajectory_id).or_else(|| non_empty(self.execution_id));
let info = self.tool_info.as_ref();
match action {
"session_start" | "beforeAgentResponse" => Ok(HookEvent::SessionStart {
cwd: extract_cwd(info),
session_id,
}),
"pre_user_prompt" => {
let cwd = extract_cwd(info);
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,
transcript_path: None,
cwd: (!cwd.trim().is_empty()).then_some(cwd),
})
}
"post_write_code" => {
let file_path = info
.and_then(|v| v.get("file_path"))
.and_then(|v| v.as_str())
.map(String::from);
let target_files = file_path.iter().cloned().collect();
let (old_text, new_text) = extract_write_text(info);
Ok(HookEvent::PostToolUse {
tool_name: "Write".to_owned(),
cwd: non_empty_cwd(info),
file_path,
target_files,
diff: synthesise_write_diff(info),
session_id,
new_text,
old_text,
})
}
"post_run_command" => Ok(HookEvent::PostToolUse {
tool_name: "Bash".to_owned(),
cwd: non_empty_cwd(info),
file_path: None,
target_files: Vec::new(),
diff: synthesise_command_diff(info),
session_id,
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(),
cwd: non_empty_cwd(info),
file_path: None,
target_files: Vec::new(),
diff: synthesise_mcp_diff(info),
session_id,
new_text: None,
old_text: None,
}),
"post_cascade_response" => Ok(HookEvent::Stop {
session_id,
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 non_empty_cwd(info: Option<&Value>) -> Option<String> {
let cwd = extract_cwd(info);
(!cwd.trim().is_empty()).then_some(cwd)
}
fn non_empty(value: Option<String>) -> Option<String> {
value.filter(|s| !s.trim().is_empty())
}
fn extract_write_text(info: Option<&Value>) -> (Option<String>, Option<String>) {
let Some(info) = info else {
return (None, None);
};
let (edit_old, edit_new) = synth::extract_edit_strings(Some(info));
if edit_old.is_some() || edit_new.is_some() {
return (edit_old, edit_new);
}
let old_text = info
.get("old_code")
.and_then(|v| v.as_str())
.map(String::from);
let new_text = info
.get("new_code")
.or_else(|| info.get("content"))
.and_then(|v| v.as_str())
.map(String::from);
(old_text, new_text)
}
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);
}
let _ = result.system_message;
crate::support::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,
transcript_path: None,
cwd: 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,
old_text,
new_text,
..
} = adapter.parse_stdin(raw).unwrap()
{
assert_eq!(tool_name, "Write");
assert_eq!(file_path.as_deref(), Some("src/a.ts"));
assert_eq!(old_text.as_deref(), Some("x\n\n1"));
assert_eq!(new_text.as_deref(), Some("y\n\n2"));
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 trajectory_id_threads_into_session_id() {
let adapter = WindsurfAdapter;
let raw = r#"{
"agent_action_name": "post_write_code",
"trajectory_id": "traj-123",
"execution_id": "exec-456",
"tool_info": { "file_path": "src/a.ts", "content": "x" }
}"#;
if let HookEvent::PostToolUse { session_id, .. } = adapter.parse_stdin(raw).unwrap() {
assert_eq!(session_id.as_deref(), Some("traj-123"));
} else {
panic!("expected PostToolUse");
}
}
#[test]
fn execution_id_used_when_trajectory_id_absent() {
let adapter = WindsurfAdapter;
let raw = r#"{
"agent_action_name": "post_cascade_response",
"execution_id": "exec-456"
}"#;
if let HookEvent::Stop { session_id, .. } = adapter.parse_stdin(raw).unwrap() {
assert_eq!(session_id.as_deref(), Some("exec-456"));
} else {
panic!("expected Stop");
}
}
#[test]
fn blank_session_ids_become_none() {
let adapter = WindsurfAdapter;
let raw = r#"{
"agent_action_name": "session_start",
"trajectory_id": "",
"execution_id": " ",
"tool_info": {}
}"#;
if let HookEvent::SessionStart { session_id, .. } = adapter.parse_stdin(raw).unwrap() {
assert!(session_id.is_none());
} else {
panic!("expected SessionStart");
}
}
#[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_omits_system_message() {
let adapter = WindsurfAdapter;
let mut result = HookResult::noop();
result.system_message = Some("DiffLore lifecycle note".to_owned());
let out = adapter.format_output(result);
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("systemMessage").is_none());
}
#[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");
}
}