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