1use serde::{Deserialize, Serialize};
41use serde_json::{Value, json};
42
43use super::synth;
44use super::types::{HookEvent, HookResult};
45use super::{PayloadAdapter, PlatformAdapter};
46
47pub struct GeminiCliAdapter;
49
50#[derive(Debug, Clone, Deserialize, Serialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub(crate) struct GeminiHookPayload {
56 #[serde(default)]
57 hook_event_name: Option<String>,
58 #[serde(default)]
59 session_id: Option<String>,
60 #[serde(default)]
61 cwd: Option<String>,
62 #[serde(default)]
63 transcript_path: Option<String>,
64 #[serde(default)]
65 tool_name: Option<String>,
66 #[serde(default)]
67 tool_input: Option<Value>,
68 #[serde(default)]
69 tool_response: Option<Value>,
70 #[serde(default)]
71 prompt: Option<String>,
72 #[serde(default)]
74 prompt_response: Option<String>,
75}
76
77impl GeminiHookPayload {
78 fn into_canonical(self) -> Result<HookEvent, String> {
79 let event_name = self
80 .hook_event_name
81 .as_deref()
82 .ok_or_else(|| "missing hook_event_name".to_owned())?;
83 match event_name {
84 "SessionStart" | "BeforeAgent" => Ok(HookEvent::SessionStart {
88 cwd: self.cwd.unwrap_or_default(),
89 session_id: None,
90 }),
91 "AfterAgent" => Ok(HookEvent::Stop {
92 session_id: None,
93 transcript_path: None,
94 cwd: None,
95 }),
96 "AfterTool" => Ok(after_tool_event(self)),
97 "SessionEnd" => Ok(HookEvent::SessionEnd {
98 session_id: None,
99 transcript_path: None,
100 cwd: None,
101 }),
102 "BeforeTool" | "PreCompress" | "Notification" => Err(format!(
107 "Gemini CLI event {event_name} is intentionally ignored"
108 )),
109 other => Err(format!("unsupported Gemini CLI hook event: {other}")),
110 }
111 }
112}
113
114fn after_tool_event(p: GeminiHookPayload) -> HookEvent {
127 let raw_tool_name = p.tool_name.clone().unwrap_or_default();
128 let tool_name = match raw_tool_name.as_str() {
129 "WriteFile" => "Write".to_owned(),
130 _ => raw_tool_name,
131 };
132 let file_path = p
133 .tool_input
134 .as_ref()
135 .and_then(|v| {
136 v.get("file_path")
137 .or_else(|| v.get("path"))
138 .or_else(|| v.get("file"))
139 })
140 .and_then(|v| v.as_str())
141 .map(String::from);
142 let diff = synthesise_diff(p.tool_input.as_ref(), p.tool_response.as_ref());
143 let (old_text, new_text) = synth::extract_edit_strings(p.tool_input.as_ref());
144 HookEvent::PostToolUse {
145 tool_name,
146 file_path,
147 diff,
148 session_id: p.session_id,
149 new_text,
150 old_text,
151 }
152}
153
154fn synthesise_diff(tool_input: Option<&Value>, tool_response: Option<&Value>) -> Option<String> {
167 let input = tool_input?;
168 if let (Some(old), Some(new)) = (
169 input.get("old_string").and_then(|v| v.as_str()),
170 input.get("new_string").and_then(|v| v.as_str()),
171 ) {
172 return Some(synth::diff_old_new(old, new));
173 }
174 if let Some(content) = input.get("content").and_then(|v| v.as_str()) {
175 return Some(synth::diff_content(content));
176 }
177 if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) {
178 let cleaned = tool_response
181 .and_then(|v| v.get("output"))
182 .and_then(|v| v.as_str())
183 .map(strip_ansi);
184 return synth::diff_shell(Some(cmd), cleaned.as_deref());
185 }
186 None
187}
188
189pub(crate) fn strip_ansi(s: &str) -> String {
203 if !s.contains('\x1b') && !s.contains('\u{009b}') {
204 return s.to_owned();
205 }
206 let bytes = s.as_bytes();
207 let mut out = Vec::with_capacity(bytes.len());
208 let mut i = 0;
209 while i < bytes.len() {
210 let b = bytes[i];
211 if b == 0xC2 && i + 1 < bytes.len() && bytes[i + 1] == 0x9B {
213 i += 2;
214 i = skip_csi_body(bytes, i);
215 continue;
216 }
217 if b == 0x1B {
219 if i + 1 < bytes.len() && bytes[i + 1] == b'[' {
220 i += 2;
222 i = skip_csi_body(bytes, i);
223 continue;
224 }
225 if i + 1 < bytes.len() {
228 i += 2;
229 } else {
230 i += 1;
231 }
232 continue;
233 }
234 out.push(b);
235 i += 1;
236 }
237 String::from_utf8(out).unwrap_or_else(|_| s.to_owned())
240}
241
242fn skip_csi_body(bytes: &[u8], mut i: usize) -> usize {
247 while i < bytes.len() {
248 let c = bytes[i];
249 i += 1;
250 if (0x40..=0x7E).contains(&c) {
251 return i;
252 }
253 }
254 i
255}
256
257impl PayloadAdapter for GeminiCliAdapter {
258 type Raw = GeminiHookPayload;
259 const PARSE_LABEL: &'static str = "Gemini CLI";
260
261 fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
262 raw.into_canonical()
263 }
264}
265
266impl PlatformAdapter for GeminiCliAdapter {
267 fn name(&self) -> &'static str {
268 "gemini-cli"
269 }
270
271 fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
272 Self::parse_stdin_default(raw)
273 }
274
275 fn format_output(&self, result: HookResult) -> String {
276 let mut obj = json!({
282 "continue": result.continue_,
283 "suppressOutput": false,
284 });
285 if let Some(msg) = result.system_message {
286 obj["systemMessage"] = Value::String(strip_ansi(&msg));
287 }
288 if let Some(ctx) = result.additional_context {
289 obj["hookSpecificOutput"] = json!({
294 "additionalContext": ctx,
295 });
296 }
297 crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn parse_session_start_reads_cwd() {
307 let adapter = GeminiCliAdapter;
308 let raw = r#"{"hook_event_name":"SessionStart","cwd":"/tmp/x"}"#;
309 assert_eq!(
310 adapter.parse_stdin(raw).unwrap(),
311 HookEvent::SessionStart {
312 cwd: "/tmp/x".into(),
313 session_id: None,
314 }
315 );
316 }
317
318 #[test]
319 fn parse_before_agent_maps_to_session_start() {
320 let adapter = GeminiCliAdapter;
322 let raw = r#"{"hook_event_name":"BeforeAgent","cwd":"/home/me/p"}"#;
323 assert_eq!(
324 adapter.parse_stdin(raw).unwrap(),
325 HookEvent::SessionStart {
326 cwd: "/home/me/p".into(),
327 session_id: None,
328 }
329 );
330 }
331
332 #[test]
333 fn parse_after_agent_maps_to_stop() {
334 let adapter = GeminiCliAdapter;
335 assert_eq!(
336 adapter
337 .parse_stdin(r#"{"hook_event_name":"AfterAgent"}"#)
338 .unwrap(),
339 HookEvent::Stop {
340 session_id: None,
341 transcript_path: None,
342 cwd: None
343 }
344 );
345 }
346
347 #[test]
348 fn parse_after_tool_extracts_file_path_and_diff() {
349 let adapter = GeminiCliAdapter;
350 let raw = r#"{
351 "hook_event_name": "AfterTool",
352 "tool_name": "Edit",
353 "tool_input": {
354 "file_path": "src/foo.py",
355 "old_string": "a=1",
356 "new_string": "a=2"
357 }
358 }"#;
359 if let HookEvent::PostToolUse {
360 tool_name,
361 file_path,
362 diff,
363 ..
364 } = adapter.parse_stdin(raw).unwrap()
365 {
366 assert_eq!(tool_name, "Edit");
367 assert_eq!(file_path.as_deref(), Some("src/foo.py"));
368 let d = diff.unwrap();
369 assert!(d.contains("-a=1") && d.contains("+a=2"));
370 } else {
371 panic!("expected PostToolUse");
372 }
373 }
374
375 #[test]
376 fn parse_after_tool_normalises_writefile_to_write() {
377 let adapter = GeminiCliAdapter;
382 let raw = r#"{
383 "hook_event_name": "AfterTool",
384 "tool_name": "WriteFile",
385 "tool_input": {
386 "file_path": "src/new.py",
387 "content": "print('hi')"
388 }
389 }"#;
390 if let HookEvent::PostToolUse { tool_name, .. } = adapter.parse_stdin(raw).unwrap() {
391 assert_eq!(tool_name, "Write");
392 } else {
393 panic!("expected PostToolUse");
394 }
395 }
396
397 #[test]
398 fn parse_after_tool_shell_strips_ansi_from_output() {
399 let adapter = GeminiCliAdapter;
404 let output = format!("{esc}[31mred{esc}[0m plain", esc = '\u{001b}');
409 let payload = json!({
410 "hook_event_name": "AfterTool",
411 "tool_name": "ShellCommand",
412 "tool_input": { "command": "ls" },
413 "tool_response": { "output": output },
414 });
415 let raw = serde_json::to_string(&payload).unwrap();
416 if let HookEvent::PostToolUse { diff, .. } = adapter.parse_stdin(&raw).unwrap() {
417 let d = diff.unwrap();
418 assert!(d.contains("$ ls"));
419 assert!(d.contains("+red plain"), "got: {d:?}");
420 assert!(!d.contains('\x1b'), "ANSI escape leaked into diff: {d:?}");
421 } else {
422 panic!("expected PostToolUse");
423 }
424 }
425
426 #[test]
427 fn parse_ignored_events_error_loudly_so_cli_noops() {
428 let adapter = GeminiCliAdapter;
432 for ev in ["BeforeTool", "PreCompress", "Notification"] {
433 let raw = format!(r#"{{"hook_event_name":"{ev}"}}"#);
434 let err = adapter.parse_stdin(&raw).unwrap_err();
435 assert!(err.contains("ignored"), "for {ev}: {err}");
436 }
437 }
438
439 #[test]
440 fn format_output_includes_continue_and_suppress_output() {
441 let adapter = GeminiCliAdapter;
442 let out = adapter.format_output(HookResult::noop());
443 let v: Value = serde_json::from_str(&out).unwrap();
444 assert_eq!(v["continue"], true);
445 assert_eq!(v["suppressOutput"], false);
446 }
447
448 #[test]
449 fn format_output_nests_additional_context_under_hook_specific_output() {
450 let adapter = GeminiCliAdapter;
451 let out = adapter.format_output(HookResult::with_context("R1"));
452 let v: Value = serde_json::from_str(&out).unwrap();
453 assert_eq!(v["hookSpecificOutput"]["additionalContext"], "R1");
454 }
455
456 #[test]
457 fn format_output_strips_ansi_from_system_message() {
458 let adapter = GeminiCliAdapter;
459 let mut r = HookResult::noop();
460 r.system_message = Some("\u{001b}[31mred\u{001b}[0m OK".into());
461 let out = adapter.format_output(r);
462 let v: Value = serde_json::from_str(&out).unwrap();
463 let msg = v["systemMessage"].as_str().unwrap();
464 assert!(!msg.contains('\x1b'), "ANSI leaked: {msg:?}");
465 assert!(msg.contains("red OK"), "content lost: {msg:?}");
466 }
467}