1use super::{FrameworkAdapter, ParsedHookInput, common};
8use crate::model::EventType;
9use std::borrow::Cow;
10use std::path::PathBuf;
11
12const GEMINI_OTEL_KEYS: &[&str] = &[
14 "OTEL_LOGS_EXPORTER",
15 "OTEL_EXPORTER_OTLP_PROTOCOL",
16 "OTEL_EXPORTER_OTLP_ENDPOINT",
17];
18
19pub struct GeminiAdapter;
23
24impl FrameworkAdapter for GeminiAdapter {
25 fn name(&self) -> &'static str {
26 "gemini"
27 }
28
29 fn display_name(&self) -> &'static str {
30 "Gemini CLI"
31 }
32
33 fn project_config_path(&self) -> PathBuf {
34 PathBuf::from(".gemini/settings.json")
35 }
36
37 fn user_config_path(&self) -> Option<PathBuf> {
38 dirs::home_dir().map(|h| h.join(".gemini/settings.json"))
39 }
40
41 fn generate_hooks_config(
42 &self,
43 enabled_events: &[EventType],
44 mi6_bin: &str,
45 otel_enabled: bool,
46 otel_port: u16,
47 ) -> serde_json::Value {
48 let mut hooks = serde_json::Map::new();
49
50 let gemini_events: std::collections::HashSet<&str> =
52 self.supported_events().into_iter().collect();
53
54 for event in enabled_events {
56 let gemini_event = canonical_to_gemini(event);
58
59 if !gemini_events.contains(gemini_event.as_ref()) {
61 continue;
62 }
63
64 let command = if otel_enabled && *event == EventType::SessionStart {
67 format!(
68 "{} otel start --port {} </dev/null >/dev/null 2>&1; {} ingest event {} --framework gemini",
69 mi6_bin, otel_port, mi6_bin, gemini_event
70 )
71 } else {
72 format!(
73 "{} ingest event {} --framework gemini",
74 mi6_bin, gemini_event
75 )
76 };
77
78 let hook_entry = serde_json::json!([{
79 "hooks": [{
80 "type": "command",
81 "command": command,
82 "timeout": 10000
83 }]
84 }]);
85 hooks.insert(gemini_event.into_owned(), hook_entry);
86 }
87
88 for event_name in self.framework_specific_events() {
90 if hooks.contains_key(event_name) {
92 continue;
93 }
94
95 let command = format!("{} ingest event {} --framework gemini", mi6_bin, event_name);
96
97 let hook_entry = serde_json::json!([{
98 "hooks": [{
99 "type": "command",
100 "command": command,
101 "timeout": 10000
102 }]
103 }]);
104 hooks.insert(event_name.to_string(), hook_entry);
105 }
106
107 serde_json::json!({
108 "tools": {
109 "enableHooks": true
110 },
111 "hooks": hooks
112 })
113 }
114
115 fn merge_config(
116 &self,
117 generated: serde_json::Value,
118 existing: Option<serde_json::Value>,
119 ) -> serde_json::Value {
120 let mut settings = common::merge_json_hooks(generated, existing);
121
122 if let Some(tools) = settings.get_mut("tools") {
124 if let Some(tools_obj) = tools.as_object_mut() {
125 tools_obj.insert("enableHooks".to_string(), serde_json::json!(true));
126 }
127 } else {
128 settings["tools"] = serde_json::json!({ "enableHooks": true });
129 }
130
131 settings
132 }
133
134 fn parse_hook_input(
135 &self,
136 _event_type: &str,
137 stdin_json: &serde_json::Value,
138 ) -> ParsedHookInput {
139 ParsedHookInput {
140 session_id: stdin_json
141 .get("session_id")
142 .and_then(|v| v.as_str())
143 .map(String::from),
144 tool_use_id: stdin_json
145 .get("tool_use_id")
146 .and_then(|v| v.as_str())
147 .map(String::from),
148 tool_name: stdin_json
149 .get("tool_name")
150 .and_then(|v| v.as_str())
151 .map(String::from),
152 cwd: stdin_json
153 .get("cwd")
154 .and_then(|v| v.as_str())
155 .map(String::from),
156 transcript_path: stdin_json
157 .get("transcript_path")
158 .and_then(|v| v.as_str())
159 .map(String::from),
160 subagent_type: stdin_json
162 .get("tool_input")
163 .and_then(|ti| ti.get("subagent_type"))
164 .and_then(|v| v.as_str())
165 .map(String::from),
166 permission_mode: None,
168 spawned_agent_id: None,
169 session_source: None,
170 agent_id: None,
171 agent_transcript_path: None,
172 compact_trigger: None,
173 }
174 }
175
176 fn map_event_type(&self, framework_event: &str) -> EventType {
177 match framework_event {
178 "BeforeTool" => EventType::PreToolUse,
180 "AfterTool" => EventType::PostToolUse,
181 "BeforeAgent" => EventType::UserPromptSubmit,
183 "AfterAgent" => EventType::Stop,
185 "PreCompress" => EventType::PreCompact,
187 "SessionStart" => EventType::SessionStart,
189 "SessionEnd" => EventType::SessionEnd,
190 "Notification" => EventType::Notification,
191 "BeforeModel" | "AfterModel" | "BeforeToolSelection" => {
193 EventType::Custom(framework_event.to_string())
194 }
195 other => other
197 .parse()
198 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
199 }
200 }
201
202 fn supported_events(&self) -> Vec<&'static str> {
203 vec![
204 "SessionStart",
205 "SessionEnd",
206 "BeforeAgent",
207 "AfterAgent",
208 "BeforeModel",
209 "AfterModel",
210 "BeforeToolSelection",
211 "BeforeTool",
212 "AfterTool",
213 "PreCompress",
214 "Notification",
215 ]
216 }
217
218 fn framework_specific_events(&self) -> Vec<&'static str> {
219 vec!["BeforeModel", "AfterModel", "BeforeToolSelection"]
221 }
222
223 fn detection_env_vars(&self) -> &[&'static str] {
224 &["GEMINI_SESSION_ID", "GEMINI_PROJECT_DIR"]
225 }
226
227 fn is_installed(&self) -> bool {
228 common::is_framework_installed(self.user_config_path(), "gemini")
229 }
230
231 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
232 common::remove_json_hooks(existing, GEMINI_OTEL_KEYS)
233 }
234}
235
236fn canonical_to_gemini(event: &EventType) -> Cow<'static, str> {
238 match event {
239 EventType::SessionStart => Cow::Borrowed("SessionStart"),
240 EventType::SessionEnd => Cow::Borrowed("SessionEnd"),
241 EventType::PreToolUse => Cow::Borrowed("BeforeTool"),
242 EventType::PostToolUse => Cow::Borrowed("AfterTool"),
243 EventType::UserPromptSubmit => Cow::Borrowed("BeforeAgent"),
244 EventType::PreCompact => Cow::Borrowed("PreCompress"),
245 EventType::Notification => Cow::Borrowed("Notification"),
246 EventType::Stop => Cow::Borrowed("AfterAgent"),
248 EventType::PermissionRequest => Cow::Borrowed("PermissionRequest"),
250 EventType::SubagentStart => Cow::Borrowed("SubagentStart"),
251 EventType::SubagentStop => Cow::Borrowed("SubagentStop"),
252 EventType::ApiRequest => Cow::Borrowed("ApiRequest"),
253 EventType::Custom(s) => Cow::Owned(s.clone()),
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_name() {
263 let adapter = GeminiAdapter;
264 assert_eq!(adapter.name(), "gemini");
265 assert_eq!(adapter.display_name(), "Gemini CLI");
266 }
267
268 #[test]
269 fn test_project_config_path() {
270 let adapter = GeminiAdapter;
271 assert_eq!(
272 adapter.project_config_path(),
273 PathBuf::from(".gemini/settings.json")
274 );
275 }
276
277 #[test]
278 fn test_map_event_type() {
279 let adapter = GeminiAdapter;
280 assert_eq!(adapter.map_event_type("BeforeTool"), EventType::PreToolUse);
282 assert_eq!(adapter.map_event_type("AfterTool"), EventType::PostToolUse);
283 assert_eq!(
284 adapter.map_event_type("BeforeAgent"),
285 EventType::UserPromptSubmit
286 );
287 assert_eq!(adapter.map_event_type("AfterAgent"), EventType::Stop);
289 assert_eq!(adapter.map_event_type("PreCompress"), EventType::PreCompact);
290 assert_eq!(
292 adapter.map_event_type("SessionStart"),
293 EventType::SessionStart
294 );
295 assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
296 assert_eq!(
297 adapter.map_event_type("Notification"),
298 EventType::Notification
299 );
300 assert_eq!(
302 adapter.map_event_type("BeforeModel"),
303 EventType::Custom("BeforeModel".to_string())
304 );
305 assert_eq!(
306 adapter.map_event_type("AfterModel"),
307 EventType::Custom("AfterModel".to_string())
308 );
309 }
310
311 #[test]
312 fn test_parse_hook_input() {
313 let adapter = GeminiAdapter;
314 let input = serde_json::json!({
315 "session_id": "gemini-session-123",
316 "tool_use_id": "tool-456",
317 "tool_name": "write_file",
318 "cwd": "/projects/test",
319 "transcript_path": "/tmp/transcript.jsonl",
320 "tool_input": {
321 "file_path": "/tmp/test.txt",
322 "content": "hello"
323 }
324 });
325
326 let parsed = adapter.parse_hook_input("BeforeTool", &input);
327
328 assert_eq!(parsed.session_id, Some("gemini-session-123".to_string()));
329 assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
330 assert_eq!(parsed.tool_name, Some("write_file".to_string()));
331 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
332 assert_eq!(
333 parsed.transcript_path,
334 Some("/tmp/transcript.jsonl".to_string())
335 );
336 assert_eq!(parsed.permission_mode, None);
338 assert_eq!(parsed.spawned_agent_id, None);
339 }
340
341 #[test]
342 fn test_parse_hook_input_with_subagent_type() {
343 let adapter = GeminiAdapter;
344 let input = serde_json::json!({
345 "session_id": "gemini-123",
346 "tool_input": {
347 "subagent_type": "Explore"
348 }
349 });
350
351 let parsed = adapter.parse_hook_input("BeforeTool", &input);
352
353 assert_eq!(parsed.session_id, Some("gemini-123".to_string()));
354 assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
355 }
356
357 #[test]
358 fn test_generate_hooks_config() -> Result<(), String> {
359 let adapter = GeminiAdapter;
360 let events = vec![EventType::SessionStart, EventType::PreToolUse];
361
362 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
363
364 let hooks = config
365 .get("hooks")
366 .ok_or("missing hooks")?
367 .as_object()
368 .ok_or("hooks not an object")?;
369 assert!(hooks.contains_key("SessionStart"));
371 assert!(hooks.contains_key("BeforeTool")); let before_tool = &hooks["BeforeTool"][0]["hooks"][0];
374 let command = before_tool["command"].as_str().ok_or("missing command")?;
375 assert!(command.contains("mi6 ingest event BeforeTool"));
376 assert!(command.contains("--framework gemini"));
378 assert_eq!(
380 before_tool["type"].as_str().ok_or("missing type")?,
381 "command"
382 );
383 assert_eq!(
385 before_tool["timeout"].as_i64().ok_or("missing timeout")?,
386 10000
387 );
388
389 assert!(
391 hooks.contains_key("BeforeModel"),
392 "Framework-specific BeforeModel should be included"
393 );
394 assert!(
395 hooks.contains_key("AfterModel"),
396 "Framework-specific AfterModel should be included"
397 );
398 assert!(
399 hooks.contains_key("BeforeToolSelection"),
400 "Framework-specific BeforeToolSelection should be included"
401 );
402 Ok(())
403 }
404
405 #[test]
406 fn test_framework_specific_events() {
407 let adapter = GeminiAdapter;
408 let events = adapter.framework_specific_events();
409
410 assert!(events.contains(&"BeforeModel"));
411 assert!(events.contains(&"AfterModel"));
412 assert!(events.contains(&"BeforeToolSelection"));
413 assert_eq!(events.len(), 3);
414 }
415
416 #[test]
417 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
418 let adapter = GeminiAdapter;
419 let events = vec![EventType::SessionStart];
420
421 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
422
423 let hooks = config
424 .get("hooks")
425 .ok_or("missing hooks")?
426 .as_object()
427 .ok_or("hooks not an object")?;
428 let session_start = &hooks["SessionStart"][0]["hooks"][0];
429 let command = session_start["command"].as_str().ok_or("missing command")?;
430
431 assert!(command.contains("otel start"));
432 assert!(command.contains("--port 4318"));
433 assert!(command.contains("--framework gemini"));
434 Ok(())
435 }
436
437 #[test]
438 fn test_merge_config_new() {
439 let adapter = GeminiAdapter;
440 let generated = serde_json::json!({
441 "hooks": {
442 "BeforeTool": [{"matcher": "", "hooks": []}]
443 }
444 });
445
446 let merged = adapter.merge_config(generated, None);
447
448 assert!(merged.get("hooks").is_some());
449 assert!(merged["hooks"].get("BeforeTool").is_some());
450 }
451
452 #[test]
453 fn test_merge_config_existing() {
454 let adapter = GeminiAdapter;
455 let generated = serde_json::json!({
456 "hooks": {
457 "BeforeTool": [{"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
458 }
459 });
460 let existing = serde_json::json!({
461 "theme": "dark",
462 "hooks": {
463 "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
464 }
465 });
466
467 let merged = adapter.merge_config(generated, Some(existing));
468
469 assert_eq!(merged["theme"], "dark");
471 assert!(merged["hooks"].get("SessionStart").is_some());
473 assert!(merged["hooks"].get("BeforeTool").is_some());
474 }
475
476 #[test]
477 fn test_supported_events() {
478 let adapter = GeminiAdapter;
479 let events = adapter.supported_events();
480
481 assert!(events.contains(&"SessionStart"));
482 assert!(events.contains(&"BeforeTool"));
483 assert!(events.contains(&"AfterTool"));
484 assert!(events.contains(&"BeforeModel"));
485 assert!(events.contains(&"AfterModel"));
486 }
487
488 #[test]
489 fn test_detection_env_vars() {
490 let adapter = GeminiAdapter;
491 let vars = adapter.detection_env_vars();
492
493 assert!(vars.contains(&"GEMINI_SESSION_ID"));
494 assert!(vars.contains(&"GEMINI_PROJECT_DIR"));
495 }
496}