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
244fn canonical_to_gemini(event: &EventType) -> Cow<'static, str> {
246 match event {
247 EventType::SessionStart => Cow::Borrowed("SessionStart"),
248 EventType::SessionEnd => Cow::Borrowed("SessionEnd"),
249 EventType::PreToolUse => Cow::Borrowed("BeforeTool"),
250 EventType::PostToolUse => Cow::Borrowed("AfterTool"),
251 EventType::UserPromptSubmit => Cow::Borrowed("BeforeAgent"),
252 EventType::PreCompact => Cow::Borrowed("PreCompress"),
253 EventType::Notification => Cow::Borrowed("Notification"),
254 EventType::Stop => Cow::Borrowed("AfterAgent"),
256 EventType::PermissionRequest => Cow::Borrowed("PermissionRequest"),
258 EventType::SubagentStart => Cow::Borrowed("SubagentStart"),
259 EventType::SubagentStop => Cow::Borrowed("SubagentStop"),
260 EventType::ApiRequest => Cow::Borrowed("ApiRequest"),
261 EventType::Custom(s) => Cow::Owned(s.clone()),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_name() {
271 let adapter = GeminiAdapter;
272 assert_eq!(adapter.name(), "gemini");
273 assert_eq!(adapter.display_name(), "Gemini CLI");
274 }
275
276 #[test]
277 fn test_project_config_path() {
278 let adapter = GeminiAdapter;
279 assert_eq!(
280 adapter.project_config_path(),
281 PathBuf::from(".gemini/settings.json")
282 );
283 }
284
285 #[test]
286 fn test_map_event_type() {
287 let adapter = GeminiAdapter;
288 assert_eq!(adapter.map_event_type("BeforeTool"), EventType::PreToolUse);
290 assert_eq!(adapter.map_event_type("AfterTool"), EventType::PostToolUse);
291 assert_eq!(
292 adapter.map_event_type("BeforeAgent"),
293 EventType::UserPromptSubmit
294 );
295 assert_eq!(adapter.map_event_type("AfterAgent"), EventType::Stop);
297 assert_eq!(adapter.map_event_type("PreCompress"), EventType::PreCompact);
298 assert_eq!(
300 adapter.map_event_type("SessionStart"),
301 EventType::SessionStart
302 );
303 assert_eq!(adapter.map_event_type("SessionEnd"), EventType::SessionEnd);
304 assert_eq!(
305 adapter.map_event_type("Notification"),
306 EventType::Notification
307 );
308 assert_eq!(
310 adapter.map_event_type("BeforeModel"),
311 EventType::Custom("BeforeModel".to_string())
312 );
313 assert_eq!(adapter.map_event_type("AfterModel"), EventType::Stop);
315 }
316
317 #[test]
318 fn test_parse_hook_input() {
319 let adapter = GeminiAdapter;
320 let input = serde_json::json!({
321 "session_id": "gemini-session-123",
322 "tool_use_id": "tool-456",
323 "tool_name": "write_file",
324 "cwd": "/projects/test",
325 "transcript_path": "/tmp/transcript.jsonl",
326 "tool_input": {
327 "file_path": "/tmp/test.txt",
328 "content": "hello"
329 }
330 });
331
332 let parsed = adapter.parse_hook_input("BeforeTool", &input);
333
334 assert_eq!(parsed.session_id, Some("gemini-session-123".to_string()));
335 assert_eq!(parsed.tool_use_id, Some("tool-456".to_string()));
336 assert_eq!(parsed.tool_name, Some("write_file".to_string()));
337 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
338 assert_eq!(
339 parsed.transcript_path,
340 Some("/tmp/transcript.jsonl".to_string())
341 );
342 assert_eq!(parsed.permission_mode, None);
344 assert_eq!(parsed.spawned_agent_id, None);
345 }
346
347 #[test]
348 fn test_parse_hook_input_with_subagent_type() {
349 let adapter = GeminiAdapter;
350 let input = serde_json::json!({
351 "session_id": "gemini-123",
352 "tool_input": {
353 "subagent_type": "Explore"
354 }
355 });
356
357 let parsed = adapter.parse_hook_input("BeforeTool", &input);
358
359 assert_eq!(parsed.session_id, Some("gemini-123".to_string()));
360 assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
361 }
362
363 #[test]
364 fn test_parse_hook_input_session_source() {
365 let adapter = GeminiAdapter;
366 let input = serde_json::json!({
368 "session_id": "gemini-session-456",
369 "source": "startup",
370 "cwd": "/projects/test"
371 });
372
373 let parsed = adapter.parse_hook_input("SessionStart", &input);
374
375 assert_eq!(parsed.session_id, Some("gemini-session-456".to_string()));
376 assert_eq!(parsed.session_source, Some("startup".to_string()));
377 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
378
379 let resume_input = serde_json::json!({
381 "session_id": "gemini-session-789",
382 "source": "resume"
383 });
384 let parsed = adapter.parse_hook_input("SessionStart", &resume_input);
385 assert_eq!(parsed.session_source, Some("resume".to_string()));
386 }
387
388 #[test]
389 fn test_parse_hook_input_compact_trigger() {
390 let adapter = GeminiAdapter;
391 let input = serde_json::json!({
393 "session_id": "gemini-session-101",
394 "trigger": "auto"
395 });
396
397 let parsed = adapter.parse_hook_input("PreCompress", &input);
398
399 assert_eq!(parsed.session_id, Some("gemini-session-101".to_string()));
400 assert_eq!(parsed.compact_trigger, Some("auto".to_string()));
401
402 let manual_input = serde_json::json!({
404 "session_id": "gemini-session-102",
405 "trigger": "manual"
406 });
407 let parsed = adapter.parse_hook_input("PreCompress", &manual_input);
408 assert_eq!(parsed.compact_trigger, Some("manual".to_string()));
409 }
410
411 #[test]
412 fn test_generate_hooks_config() -> Result<(), String> {
413 let adapter = GeminiAdapter;
414 let events = vec![EventType::SessionStart, EventType::PreToolUse];
415
416 let config = adapter.generate_hooks_config(&events, "mi6", false, 4318);
417
418 let hooks = config
419 .get("hooks")
420 .ok_or("missing hooks")?
421 .as_object()
422 .ok_or("hooks not an object")?;
423 assert!(hooks.contains_key("SessionStart"));
425 assert!(hooks.contains_key("BeforeTool")); let before_tool = &hooks["BeforeTool"][0]["hooks"][0];
428 let command = before_tool["command"].as_str().ok_or("missing command")?;
429 assert!(command.contains("mi6 ingest event BeforeTool"));
430 assert!(command.contains("--framework gemini"));
432 assert_eq!(
434 before_tool["type"].as_str().ok_or("missing type")?,
435 "command"
436 );
437 assert_eq!(
439 before_tool["timeout"].as_i64().ok_or("missing timeout")?,
440 10000
441 );
442
443 assert!(
445 hooks.contains_key("BeforeModel"),
446 "Framework-specific BeforeModel should be included"
447 );
448 assert!(
449 hooks.contains_key("AfterModel"),
450 "AfterModel should be included (maps to Stop as fallback)"
451 );
452 assert!(
453 hooks.contains_key("BeforeToolSelection"),
454 "Framework-specific BeforeToolSelection should be included"
455 );
456 Ok(())
457 }
458
459 #[test]
460 fn test_framework_specific_events() {
461 let adapter = GeminiAdapter;
462 let events = adapter.framework_specific_events();
463
464 assert!(events.contains(&"BeforeModel"));
465 assert!(events.contains(&"AfterModel"));
468 assert!(events.contains(&"BeforeToolSelection"));
469 assert_eq!(events.len(), 3);
470 }
471
472 #[test]
473 fn test_generate_hooks_config_with_otel() -> Result<(), String> {
474 let adapter = GeminiAdapter;
475 let events = vec![EventType::SessionStart];
476
477 let config = adapter.generate_hooks_config(&events, "mi6", true, 4318);
478
479 let hooks = config
480 .get("hooks")
481 .ok_or("missing hooks")?
482 .as_object()
483 .ok_or("hooks not an object")?;
484 let session_start = &hooks["SessionStart"][0]["hooks"][0];
485 let command = session_start["command"].as_str().ok_or("missing command")?;
486
487 assert!(command.contains("otel start"));
488 assert!(command.contains("--port 4318"));
489 assert!(command.contains("--framework gemini"));
490 Ok(())
491 }
492
493 #[test]
494 fn test_merge_config_new() {
495 let adapter = GeminiAdapter;
496 let generated = serde_json::json!({
497 "hooks": {
498 "BeforeTool": [{"matcher": "", "hooks": []}]
499 }
500 });
501
502 let merged = adapter.merge_config(generated, None);
503
504 assert!(merged.get("hooks").is_some());
505 assert!(merged["hooks"].get("BeforeTool").is_some());
506 }
507
508 #[test]
509 fn test_merge_config_existing() {
510 let adapter = GeminiAdapter;
511 let generated = serde_json::json!({
512 "hooks": {
513 "BeforeTool": [{"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
514 }
515 });
516 let existing = serde_json::json!({
517 "theme": "dark",
518 "hooks": {
519 "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
520 }
521 });
522
523 let merged = adapter.merge_config(generated, Some(existing));
524
525 assert_eq!(merged["theme"], "dark");
527 assert!(merged["hooks"].get("SessionStart").is_some());
529 assert!(merged["hooks"].get("BeforeTool").is_some());
530 }
531
532 #[test]
533 fn test_supported_events() {
534 let adapter = GeminiAdapter;
535 let events = adapter.supported_events();
536
537 assert!(events.contains(&"SessionStart"));
538 assert!(events.contains(&"BeforeTool"));
539 assert!(events.contains(&"AfterTool"));
540 assert!(events.contains(&"BeforeModel"));
541 assert!(events.contains(&"AfterModel"));
542 }
543
544 #[test]
545 fn test_detection_env_vars() {
546 let adapter = GeminiAdapter;
547 let vars = adapter.detection_env_vars();
548
549 assert!(vars.contains(&"GEMINI_SESSION_ID"));
550 assert!(vars.contains(&"GEMINI_PROJECT_DIR"));
551 }
552
553 #[test]
554 fn test_parse_hook_input_with_model() {
555 let adapter = GeminiAdapter;
556 let input = serde_json::json!({
558 "session_id": "gemini-session-abc",
559 "cwd": "/projects/test",
560 "hook_event_name": "BeforeModel",
561 "llm_request": {
562 "model": "gemini-2.5-flash",
563 "config": {
564 "temperature": 1
565 },
566 "messages": []
567 }
568 });
569
570 let parsed = adapter.parse_hook_input("BeforeModel", &input);
571
572 assert_eq!(parsed.session_id, Some("gemini-session-abc".to_string()));
573 assert_eq!(parsed.model, Some("gemini-2.5-flash".to_string()));
574 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
575 }
576
577 #[test]
578 fn test_parse_hook_input_without_model() {
579 let adapter = GeminiAdapter;
580 let input = serde_json::json!({
582 "session_id": "gemini-session-xyz",
583 "cwd": "/projects/test"
584 });
585
586 let parsed = adapter.parse_hook_input("SessionStart", &input);
587
588 assert_eq!(parsed.session_id, Some("gemini-session-xyz".to_string()));
589 assert_eq!(parsed.model, None);
590 }
591}