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 agent_id: None,
170 agent_transcript_path: None,
171 model: stdin_json
173 .get("llm_request")
174 .and_then(|lr| lr.get("model"))
175 .and_then(|v| v.as_str())
176 .map(String::from),
177 duration_ms: None,
178 session_source: stdin_json
180 .get("source")
181 .and_then(|v| v.as_str())
182 .map(String::from),
183 compact_trigger: stdin_json
185 .get("trigger")
186 .and_then(|v| v.as_str())
187 .map(String::from),
188 tokens_input: None,
189 tokens_output: None,
190 tokens_cache_read: None,
191 tokens_cache_write: None,
192 cost_usd: None,
193 prompt: None,
194 }
195 }
196
197 fn map_event_type(&self, framework_event: &str) -> EventType {
198 match framework_event {
199 "BeforeTool" => EventType::PreToolUse,
201 "AfterTool" => EventType::PostToolUse,
202 "BeforeAgent" => EventType::UserPromptSubmit,
204 "AfterAgent" => EventType::Stop,
206 "PreCompress" => EventType::PreCompact,
208 "SessionStart" => EventType::SessionStart,
210 "SessionEnd" => EventType::SessionEnd,
211 "Notification" => EventType::Notification,
212 "AfterModel" => EventType::Stop,
217 "BeforeModel" | "BeforeToolSelection" => EventType::Custom(framework_event.to_string()),
219 other => other
221 .parse()
222 .unwrap_or_else(|_| EventType::Custom(other.to_string())),
223 }
224 }
225
226 fn supported_events(&self) -> Vec<&'static str> {
227 vec![
228 "SessionStart",
229 "SessionEnd",
230 "BeforeAgent",
231 "AfterAgent",
232 "BeforeModel",
233 "AfterModel",
234 "BeforeToolSelection",
235 "BeforeTool",
236 "AfterTool",
237 "PreCompress",
238 "Notification",
239 ]
240 }
241
242 fn framework_specific_events(&self) -> Vec<&'static str> {
243 vec!["BeforeModel", "AfterModel", "BeforeToolSelection"]
248 }
249
250 fn detection_env_vars(&self) -> &[&'static str] {
251 &["GEMINI_SESSION_ID", "GEMINI_PROJECT_DIR"]
252 }
253
254 fn is_installed(&self) -> bool {
255 common::is_framework_installed(self.user_config_path(), "gemini")
256 }
257
258 fn otel_support(&self) -> super::OtelSupport {
259 use super::OtelSupport;
260 let Some(settings_path) = self.user_config_path() else {
262 return OtelSupport::Disabled;
263 };
264 let Ok(contents) = std::fs::read_to_string(&settings_path) else {
265 return OtelSupport::Disabled;
266 };
267 let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
268 return OtelSupport::Disabled;
269 };
270
271 if let Some(env) = json.get("env") {
273 for key in GEMINI_OTEL_KEYS {
274 if env.get(*key).is_some() {
275 return OtelSupport::Enabled;
276 }
277 }
278 }
279 OtelSupport::Disabled
280 }
281
282 fn remove_hooks(&self, existing: serde_json::Value) -> Option<serde_json::Value> {
283 common::remove_json_hooks(existing, GEMINI_OTEL_KEYS)
284 }
285}
286
287fn canonical_to_gemini(event: &EventType) -> Cow<'static, str> {
289 match event {
290 EventType::SessionStart => Cow::Borrowed("SessionStart"),
291 EventType::SessionEnd => Cow::Borrowed("SessionEnd"),
292 EventType::PreToolUse => Cow::Borrowed("BeforeTool"),
293 EventType::PostToolUse => Cow::Borrowed("AfterTool"),
294 EventType::UserPromptSubmit => Cow::Borrowed("BeforeAgent"),
295 EventType::PreCompact => Cow::Borrowed("PreCompress"),
296 EventType::Notification => Cow::Borrowed("Notification"),
297 EventType::Stop => Cow::Borrowed("AfterAgent"),
299 EventType::PermissionRequest => Cow::Borrowed("PermissionRequest"),
301 EventType::SubagentStart => Cow::Borrowed("SubagentStart"),
302 EventType::SubagentStop => Cow::Borrowed("SubagentStop"),
303 EventType::ApiRequest => Cow::Borrowed("ApiRequest"),
304 EventType::Custom(s) => Cow::Owned(s.clone()),
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn test_name() {
314 let adapter = GeminiAdapter;
315 assert_eq!(adapter.name(), "gemini");
316 assert_eq!(adapter.display_name(), "Gemini CLI");
317 }
318
319 #[test]
320 fn test_project_config_path() {
321 let adapter = GeminiAdapter;
322 assert_eq!(
323 adapter.project_config_path(),
324 PathBuf::from(".gemini/settings.json")
325 );
326 }
327
328 #[test]
329 fn test_map_event_type() {
330 let adapter = GeminiAdapter;
331 assert_eq!(adapter.map_event_type("BeforeTool"), EventType::PreToolUse);
333 assert_eq!(adapter.map_event_type("AfterTool"), EventType::PostToolUse);
334 assert_eq!(
335 adapter.map_event_type("BeforeAgent"),
336 EventType::UserPromptSubmit
337 );
338 assert_eq!(adapter.map_event_type("AfterAgent"), EventType::Stop);
340 assert_eq!(adapter.map_event_type("PreCompress"), EventType::PreCompact);
341 assert_eq!(
343 adapter.map_event_type("SessionStart"),
344 EventType::SessionStart
345 );
346 assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
347 assert_eq!(
348 adapter.map_event_type("Notification"),
349 EventType::Notification
350 );
351 assert_eq!(
353 adapter.map_event_type("BeforeModel"),
354 EventType::Custom("BeforeModel".to_string())
355 );
356 assert_eq!(adapter.map_event_type("AfterModel"), EventType::Stop);
358 }
359
360 #[test]
361 fn test_parse_hook_input() {
362 let adapter = GeminiAdapter;
363 let input = serde_json::json!({
364 "session_id": "gemini-session-123",
365 "tool_use_id": "tool-456",
366 "tool_name": "write_file",
367 "cwd": "/projects/test",
368 "transcript_path": "/tmp/transcript.jsonl",
369 "tool_input": {
370 "file_path": "/tmp/test.txt",
371 "content": "hello"
372 }
373 });
374
375 let parsed = adapter.parse_hook_input("BeforeTool", &input);
376
377 assert_eq!(parsed.session_id, Some("gemini-session-123".to_string()));
378 assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
379 assert_eq!(parsed.tool_name, Some("write_file".to_string()));
380 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
381 assert_eq!(
382 parsed.transcript_path,
383 Some("/tmp/transcript.jsonl".to_string())
384 );
385 assert_eq!(parsed.permission_mode, None);
387 assert_eq!(parsed.spawned_agent_id, None);
388 }
389
390 #[test]
391 fn test_parse_hook_input_with_subagent_type() {
392 let adapter = GeminiAdapter;
393 let input = serde_json::json!({
394 "session_id": "gemini-123",
395 "tool_input": {
396 "subagent_type": "Explore"
397 }
398 });
399
400 let parsed = adapter.parse_hook_input("BeforeTool", &input);
401
402 assert_eq!(parsed.session_id, Some("gemini-123".to_string()));
403 assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
404 }
405
406 #[test]
407 fn test_parse_hook_input_session_source() {
408 let adapter = GeminiAdapter;
409 let input = serde_json::json!({
411 "session_id": "gemini-session-456",
412 "source": "startup",
413 "cwd": "/projects/test"
414 });
415
416 let parsed = adapter.parse_hook_input("SessionStart", &input);
417
418 assert_eq!(parsed.session_id, Some("gemini-session-456".to_string()));
419 assert_eq!(parsed.session_source, Some("startup".to_string()));
420 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
421
422 let resume_input = serde_json::json!({
424 "session_id": "gemini-session-789",
425 "source": "resume"
426 });
427 let parsed = adapter.parse_hook_input("SessionStart", &resume_input);
428 assert_eq!(parsed.session_source, Some("resume".to_string()));
429 }
430
431 #[test]
432 fn test_parse_hook_input_compact_trigger() {
433 let adapter = GeminiAdapter;
434 let input = serde_json::json!({
436 "session_id": "gemini-session-101",
437 "trigger": "auto"
438 });
439
440 let parsed = adapter.parse_hook_input("PreCompress", &input);
441
442 assert_eq!(parsed.session_id, Some("gemini-session-101".to_string()));
443 assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
444
445 let manual_input = serde_json::json!({
447 "session_id": "gemini-session-102",
448 "trigger": "manual"
449 });
450 let parsed = adapter.parse_hook_input("PreCompress", &manual_input);
451 assert_eq!(parsed.compact_trigger, Some("manual".to_string()));
452 }
453
454 #[test]
455 fn test_generate_hooks_config() -> Result<(), String> {
456 let adapter = GeminiAdapter;
457 let events = vec![EventType::SessionStart, EventType::PreToolUse];
458
459 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
460
461 let hooks = config
462 .get("hooks")
463 .ok_or("missing hooks")?
464 .as_object()
465 .ok_or("hooks not an object")?;
466 assert!(hooks.contains_key("SessionStart"));
468 assert!(hooks.contains_key("BeforeTool")); let before_tool = &hooks["BeforeTool"][0]["hooks"][0];
471 let command = before_tool["command"].as_str().ok_or("missing command")?;
472 assert!(command.contains("mi6 ingest event BeforeTool"));
473 assert!(command.contains("--framework gemini"));
475 assert_eq!(
477 before_tool["type"].as_str().ok_or("missing type")?,
478 "command"
479 );
480 assert_eq!(
482 before_tool["timeout"].as_i64().ok_or("missing timeout")?,
483 10000
484 );
485
486 assert!(
488 hooks.contains_key("BeforeModel"),
489 "Framework-specific BeforeModel should be included"
490 );
491 assert!(
492 hooks.contains_key("AfterModel"),
493 "AfterModel should be included (maps to Stop as fallback)"
494 );
495 assert!(
496 hooks.contains_key("BeforeToolSelection"),
497 "Framework-specific BeforeToolSelection should be included"
498 );
499 Ok(())
500 }
501
502 #[test]
503 fn test_framework_specific_events() {
504 let adapter = GeminiAdapter;
505 let events = adapter.framework_specific_events();
506
507 assert!(events.contains(&"BeforeModel"));
508 assert!(events.contains(&"AfterModel"));
511 assert!(events.contains(&"BeforeToolSelection"));
512 assert_eq!(events.len(), 3);
513 }
514
515 #[test]
516 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
517 let adapter = GeminiAdapter;
518 let events = vec![EventType::SessionStart];
519
520 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
521
522 let hooks = config
523 .get("hooks")
524 .ok_or("missing hooks")?
525 .as_object()
526 .ok_or("hooks not an object")?;
527 let session_start = &hooks["SessionStart"][0]["hooks"][0];
528 let command = session_start["command"].as_str().ok_or("missing command")?;
529
530 assert!(command.contains("otel start"));
531 assert!(command.contains("--port 4318"));
532 assert!(command.contains("--framework gemini"));
533 Ok(())
534 }
535
536 #[test]
537 fn test_merge_config_new() {
538 let adapter = GeminiAdapter;
539 let generated = serde_json::json!({
540 "hooks": {
541 "BeforeTool": [{"matcher": "", "hooks": []}]
542 }
543 });
544
545 let merged = adapter.merge_config(generated, None);
546
547 assert!(merged.get("hooks").is_some());
548 assert!(merged["hooks"].get("BeforeTool").is_some());
549 }
550
551 #[test]
552 fn test_merge_config_existing() {
553 let adapter = GeminiAdapter;
554 let generated = serde_json::json!({
555 "hooks": {
556 "BeforeTool": [{"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
557 }
558 });
559 let existing = serde_json::json!({
560 "theme": "dark",
561 "hooks": {
562 "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
563 }
564 });
565
566 let merged = adapter.merge_config(generated, Some(existing));
567
568 assert_eq!(merged["theme"], "dark");
570 assert!(merged["hooks"].get("SessionStart").is_some());
572 assert!(merged["hooks"].get("BeforeTool").is_some());
573 }
574
575 #[test]
576 fn test_supported_events() {
577 let adapter = GeminiAdapter;
578 let events = adapter.supported_events();
579
580 assert!(events.contains(&"SessionStart"));
581 assert!(events.contains(&"BeforeTool"));
582 assert!(events.contains(&"AfterTool"));
583 assert!(events.contains(&"BeforeModel"));
584 assert!(events.contains(&"AfterModel"));
585 }
586
587 #[test]
588 fn test_detection_env_vars() {
589 let adapter = GeminiAdapter;
590 let vars = adapter.detection_env_vars();
591
592 assert!(vars.contains(&"GEMINI_SESSION_ID"));
593 assert!(vars.contains(&"GEMINI_PROJECT_DIR"));
594 }
595
596 #[test]
597 fn test_parse_hook_input_with_model() {
598 let adapter = GeminiAdapter;
599 let input = serde_json::json!({
601 "session_id": "gemini-session-abc",
602 "cwd": "/projects/test",
603 "hook_event_name": "BeforeModel",
604 "llm_request": {
605 "model": "gemini-2.5-flash",
606 "config": {
607 "temperature": 1
608 },
609 "messages": []
610 }
611 });
612
613 let parsed = adapter.parse_hook_input("BeforeModel", &input);
614
615 assert_eq!(parsed.session_id, Some("gemini-session-abc".to_string()));
616 assert_eq!(parsed.model, Some("gemini-2.5-flash".to_string()));
617 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
618 }
619
620 #[test]
621 fn test_parse_hook_input_without_model() {
622 let adapter = GeminiAdapter;
623 let input = serde_json::json!({
625 "session_id": "gemini-session-xyz",
626 "cwd": "/projects/test"
627 });
628
629 let parsed = adapter.parse_hook_input("SessionStart", &input);
630
631 assert_eq!(parsed.session_id, Some("gemini-session-xyz".to_string()));
632 assert_eq!(parsed.model, None);
633 }
634}