1use std::collections::HashMap;
61use std::fs::File;
62use std::io::{BufRead, BufReader};
63use std::path::{Path, PathBuf};
64
65use serde::Deserialize;
66
67use crate::adapters::Adapter;
68use crate::error::CoreError;
69use crate::model::{Event, EventKind, Session, SessionRef, ToolCall, Usage};
70
71pub struct Gemini;
73
74fn gemini_root() -> Option<PathBuf> {
79 match std::env::var("GEMINI_HOME") {
80 Ok(dir) if !dir.trim().is_empty() => Some(PathBuf::from(dir)),
81 _ => directories::BaseDirs::new().map(|b| b.home_dir().join(".gemini")),
82 }
83}
84
85fn is_session_file(path: &Path) -> bool {
89 path.extension().and_then(|x| x.to_str()) == Some("jsonl")
90 && path
91 .file_name()
92 .and_then(|n| n.to_str())
93 .is_some_and(|n| n.starts_with("session-"))
94 && path
95 .parent()
96 .and_then(|d| d.file_name())
97 .and_then(|n| n.to_str())
98 == Some("chats")
99}
100
101fn project_from_path(path: &Path) -> Option<String> {
105 path.parent() .and_then(|chats| chats.parent()) .and_then(|p| p.file_name())
108 .map(|n| n.to_string_lossy().into_owned())
109}
110
111impl Adapter for Gemini {
112 fn id(&self) -> &'static str {
113 "gemini"
114 }
115
116 fn detect(&self) -> bool {
117 let Some(tmp) = gemini_root().map(|r| r.join("tmp")) else {
118 return false;
119 };
120 walkdir::WalkDir::new(&tmp)
123 .max_depth(3)
124 .follow_links(false)
125 .into_iter()
126 .filter_map(Result::ok)
127 .any(|e| e.file_type().is_file() && is_session_file(e.path()))
128 }
129
130 fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
131 let root = gemini_root().ok_or_else(|| {
132 anyhow::anyhow!("could not determine the home directory (set GEMINI_HOME)")
133 })?;
134 let tmp = root.join("tmp");
135 let mut refs = Vec::new();
136 if tmp.is_dir() {
137 for entry in walkdir::WalkDir::new(&tmp).max_depth(3).follow_links(false) {
138 let entry = match entry {
139 Ok(e) => e,
140 Err(e) => {
141 tracing::debug!("skipping unreadable directory entry: {e}");
142 continue;
143 }
144 };
145 if !entry.file_type().is_file() || !is_session_file(entry.path()) {
146 continue;
147 }
148 let meta = entry.metadata().ok();
149 refs.push(SessionRef {
150 path: entry.path().to_path_buf(),
151 agent: self.id().to_string(),
152 project: project_from_path(entry.path()),
153 size_bytes: meta.as_ref().map_or(0, |m| m.len()),
154 modified: meta
155 .and_then(|m| m.modified().ok())
156 .and_then(|t| jiff::Timestamp::try_from(t).ok()),
157 });
158 }
159 }
160 refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
161 Ok(refs)
162 }
163
164 fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
165 let file = File::open(&r.path).map_err(|source| CoreError::Io {
166 path: r.path.clone(),
167 source,
168 })?;
169 let id = session_id_for(&r.path);
170 let project = r.project.clone().or_else(|| project_from_path(&r.path));
171 Ok(parse_reader(
172 BufReader::new(file),
173 id,
174 project,
175 self.id(),
176 &r.path,
177 ))
178 }
179}
180
181fn session_id_for(path: &Path) -> String {
185 path.file_stem()
186 .map(|s| s.to_string_lossy().into_owned())
187 .unwrap_or_else(|| path.to_string_lossy().into_owned())
188}
189
190fn parse_reader<R: BufRead>(
194 reader: R,
195 id: String,
196 project: Option<String>,
197 agent: &str,
198 path_for_logs: &Path,
199) -> Session {
200 let mut session = Session {
201 id,
202 agent: agent.to_string(),
203 project,
204 model: None,
205 parent_session: None,
206 started_at: None,
207 ended_at: None,
208 events: Vec::new(),
209 sub_agents: Vec::new(),
210 skipped_lines: 0,
211 };
212 let mut gemini_idx: HashMap<String, usize> = HashMap::new();
216 let mut model_counts: HashMap<String, u64> = HashMap::new();
217 let mut header_start: Option<jiff::Timestamp> = None;
218
219 for (lineno, line) in reader.lines().enumerate() {
220 let line = match line {
221 Ok(l) => l,
222 Err(e) => {
223 session.skipped_lines += 1;
224 tracing::debug!(
225 "{}:{}: unreadable line: {e}",
226 path_for_logs.display(),
227 lineno + 1
228 );
229 continue;
230 }
231 };
232 if line.trim().is_empty() {
233 continue;
234 }
235 let raw: RawLine = match serde_json::from_str(&line) {
236 Ok(v) => v,
237 Err(e) => {
238 session.skipped_lines += 1;
239 tracing::debug!(
240 "{}:{}: unparseable JSON: {e}",
241 path_for_logs.display(),
242 lineno + 1
243 );
244 continue;
245 }
246 };
247 if raw.set.is_some() {
249 continue;
250 }
251 let ts = parse_ts(&raw.timestamp);
252 match raw.msg_type.as_deref() {
253 Some("gemini") => {
254 apply_gemini(&raw, ts, &mut session, &mut gemini_idx, &mut model_counts)
255 }
256 Some("user") => apply_user(&raw, ts, &mut session),
257 Some("info") | Some("error") => {}
260 Some(_) => {
261 session.skipped_lines += 1;
262 tracing::debug!(
263 "{}:{}: unknown message type {:?}",
264 path_for_logs.display(),
265 lineno + 1,
266 raw.msg_type
267 );
268 }
269 None => {
270 if raw.session_id.is_some() {
272 if header_start.is_none() {
273 header_start = parse_ts(&raw.start_time);
274 }
275 } else {
276 session.skipped_lines += 1;
277 tracing::debug!(
278 "{}:{}: unrecognized line shape",
279 path_for_logs.display(),
280 lineno + 1
281 );
282 }
283 }
284 }
285 }
286
287 session.started_at = header_start.or_else(|| session.events.iter().filter_map(|e| e.ts).min());
288 session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
289 session.model = model_counts
290 .into_iter()
291 .max_by_key(|(_, count)| *count)
292 .map(|(model, _)| model);
293 session
294}
295
296fn apply_gemini(
299 raw: &RawLine,
300 ts: Option<jiff::Timestamp>,
301 session: &mut Session,
302 gemini_idx: &mut HashMap<String, usize>,
303 model_counts: &mut HashMap<String, u64>,
304) {
305 let usage = raw.tokens.as_ref().map(map_tokens);
306 let content = raw.content.as_str().unwrap_or_default();
307 let visible_chars = content.chars().count() as u64;
308 let thinking_chars = raw
309 .thoughts
310 .iter()
311 .flatten()
312 .map(RawThought::chars)
313 .sum::<u64>();
314 let has_thinking = raw.thoughts.as_ref().is_some_and(|t| !t.is_empty())
315 || raw.tokens.as_ref().and_then(|t| t.thoughts).unwrap_or(0) > 0;
316 let tool_calls: Vec<ToolCall> = raw
317 .tool_calls
318 .iter()
319 .flatten()
320 .map(RawToolCall::to_tool_call)
321 .collect();
322 let content_chars =
330 visible_chars + thinking_chars + tool_calls.iter().map(|c| c.input_bytes).sum::<u64>();
331
332 let key = raw.id.clone().unwrap_or_default();
333 if let Some(&idx) = gemini_idx.get(&key) {
334 let ev = &mut session.events[idx];
337 ev.usage = match (ev.usage, usage) {
338 (Some(a), Some(b)) => Some(a.merge_max(b)),
339 (a, b) => a.or(b),
340 };
341 ev.thinking_chars = thinking_chars;
342 ev.has_thinking |= has_thinking;
343 if let Some(s) = snippet(content) {
344 ev.content_summary = Some(s);
345 }
346 if !tool_calls.is_empty() {
347 ev.tool_calls = tool_calls;
348 }
349 ev.content_chars = visible_chars
353 + thinking_chars
354 + ev.tool_calls.iter().map(|c| c.input_bytes).sum::<u64>();
355 return;
356 }
357
358 if let Some(model) = raw.model.as_deref().filter(|m| !m.is_empty()) {
359 *model_counts.entry(model.to_string()).or_default() += 1;
360 }
361 gemini_idx.insert(key.clone(), session.events.len());
362 session.events.push(Event {
363 kind: EventKind::Assistant,
364 ts,
365 request_id: (!key.is_empty()).then_some(key),
368 model: raw.model.clone().filter(|m| !m.is_empty()),
369 usage,
370 tool_calls,
371 sidechain: false,
372 content_summary: snippet(content),
373 content_chars,
374 thinking_chars,
375 has_thinking,
376 tool_use_id: None,
377 attachment_kind: None,
378 item_count: 0,
379 });
380}
381
382fn apply_user(raw: &RawLine, ts: Option<jiff::Timestamp>, session: &mut Session) {
385 let blocks = match raw.content.as_array() {
386 Some(b) => b,
387 None => return,
388 };
389 if let Some(fr) = blocks.iter().find_map(|b| b.get("functionResponse")) {
391 let tool_use_id = fr
392 .get("id")
393 .and_then(|v| v.as_str())
394 .map(str::to_string)
395 .filter(|s| !s.is_empty());
396 let output = fr
397 .get("response")
398 .and_then(|r| r.get("output"))
399 .map(value_text)
400 .unwrap_or_default();
401 session.events.push(Event {
402 kind: EventKind::ToolResult,
403 ts,
404 request_id: None,
405 model: None,
406 usage: None,
407 tool_calls: Vec::new(),
408 sidechain: false,
409 content_summary: snippet(&output),
410 content_chars: output.chars().count() as u64,
411 thinking_chars: 0,
412 has_thinking: false,
413 tool_use_id,
414 attachment_kind: None,
415 item_count: 0,
416 });
417 return;
418 }
419 let text = blocks
421 .iter()
422 .filter_map(|b| b.get("text").and_then(|t| t.as_str()))
423 .collect::<Vec<_>>()
424 .join("\n");
425 session.events.push(Event {
426 kind: EventKind::User,
427 ts,
428 request_id: None,
429 model: None,
430 usage: None,
431 tool_calls: Vec::new(),
432 sidechain: false,
433 content_summary: snippet(&text),
434 content_chars: text.chars().count() as u64,
435 thinking_chars: 0,
436 has_thinking: false,
437 tool_use_id: None,
438 attachment_kind: None,
439 item_count: 0,
440 });
441}
442
443fn map_tokens(t: &RawTokens) -> Usage {
455 let cached = t.cached.unwrap_or(0);
456 Usage {
457 input: t.input.map(|i| i.saturating_sub(cached)),
458 output: t.output,
459 cache_creation: None,
460 cache_creation_5m: None,
461 cache_creation_1h: None,
462 cache_read: t.cached,
463 thinking: t.thoughts,
464 }
465}
466
467fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
468 raw.as_deref().and_then(|t| t.parse().ok())
469}
470
471fn snippet(text: &str) -> Option<String> {
473 let first = text.lines().find(|l| !l.trim().is_empty())?;
474 let s: String = first.trim().chars().take(80).collect();
475 (!s.is_empty()).then_some(s)
476}
477
478fn value_text(v: &serde_json::Value) -> String {
481 match v.as_str() {
482 Some(s) => s.to_string(),
483 None => v.to_string(),
484 }
485}
486
487#[derive(Deserialize, Default)]
490struct RawLine {
491 #[serde(rename = "$set")]
493 set: Option<serde::de::IgnoredAny>,
494 #[serde(rename = "sessionId")]
496 session_id: Option<String>,
497 #[serde(rename = "startTime")]
498 start_time: Option<String>,
499 #[serde(rename = "type")]
501 msg_type: Option<String>,
502 id: Option<String>,
504 timestamp: Option<String>,
505 #[serde(default)]
507 content: serde_json::Value,
508 thoughts: Option<Vec<RawThought>>,
510 tokens: Option<RawTokens>,
512 model: Option<String>,
514 #[serde(rename = "toolCalls")]
516 tool_calls: Option<Vec<RawToolCall>>,
517}
518
519#[derive(Deserialize, Clone, Copy)]
522struct RawTokens {
523 input: Option<u64>,
524 output: Option<u64>,
525 cached: Option<u64>,
526 thoughts: Option<u64>,
527 #[allow(dead_code)]
528 tool: Option<u64>,
529 #[allow(dead_code)]
530 total: Option<u64>,
531}
532
533#[derive(Deserialize)]
534struct RawThought {
535 subject: Option<String>,
536 description: Option<String>,
537}
538
539impl RawThought {
540 fn chars(&self) -> u64 {
543 let s = self.subject.as_deref().map_or(0, |x| x.chars().count());
544 let d = self.description.as_deref().map_or(0, |x| x.chars().count());
545 (s + d) as u64
546 }
547}
548
549#[derive(Deserialize)]
550struct RawToolCall {
551 id: Option<String>,
552 name: Option<String>,
553 args: Option<serde_json::Value>,
554}
555
556impl RawToolCall {
557 fn to_tool_call(&self) -> ToolCall {
558 let name = self.name.clone().unwrap_or_else(|| "unknown".to_string());
559 let input_bytes = self
560 .args
561 .as_ref()
562 .map_or(0, |a| value_text(a).chars().count() as u64);
563 ToolCall {
564 server: ToolCall::server_from_name(&name),
565 id: self.id.clone().unwrap_or_default(),
566 name,
567 input_bytes,
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::io::Cursor;
576
577 #[test]
580 fn tokens_map_disjointly_to_total() {
581 let t = RawTokens {
582 input: Some(1000),
583 output: Some(50),
584 cached: Some(200),
585 thoughts: Some(80),
586 tool: Some(0),
587 total: Some(1130),
588 };
589 let u = map_tokens(&t);
590 assert_eq!(u.cache_read, Some(200));
591 assert_eq!(u.input, Some(800), "input minus cached subset");
592 assert_eq!(u.output, Some(50), "output is already visible-only");
593 assert_eq!(u.thinking, Some(80), "thoughts disjoint from output");
594 assert_eq!(u.cache_creation, None);
595 assert_eq!(
596 u.known_total(),
597 1130,
598 "Σ disjoint fields == total (input+output+thoughts)"
599 );
600 }
601
602 #[test]
604 fn underflow_saturates_instead_of_panicking() {
605 let t = RawTokens {
606 input: Some(100),
607 output: Some(40),
608 cached: Some(250),
609 thoughts: Some(10),
610 tool: None,
611 total: Some(150),
612 };
613 let u = map_tokens(&t);
614 assert_eq!(u.input, Some(0));
615 assert_eq!(u.cache_read, Some(250));
616 }
617
618 #[test]
621 fn repeated_gemini_id_collapses_last_wins() {
622 let lines = [
623 r#"{"sessionId":"s1","startTime":"2026-06-17T10:00:00.000Z","kind":"main"}"#,
624 r#"{"id":"g1","timestamp":"2026-06-17T10:00:02.000Z","type":"gemini","content":"","thoughts":[{"subject":"Plan","description":"do it"}],"tokens":{"input":1000,"output":50,"cached":200,"thoughts":80,"tool":0,"total":1130},"model":"gemini-3-flash-preview"}"#,
625 r#"{"id":"g1","timestamp":"2026-06-17T10:00:02.500Z","type":"gemini","content":"done","thoughts":[{"subject":"Plan","description":"do it"}],"tokens":{"input":1000,"output":50,"cached":200,"thoughts":80,"tool":0,"total":1130},"model":"gemini-3-flash-preview","toolCalls":[{"id":"tc1","name":"mcp__acme-db__query","args":{"sql":"select 1"}}]}"#,
626 ]
627 .join("\n");
628 let s = parse_reader(
629 Cursor::new(lines),
630 "s1".into(),
631 Some("demo".into()),
632 "gemini",
633 Path::new("mem://test"),
634 );
635 let assistants: Vec<_> = s
636 .events
637 .iter()
638 .filter(|e| e.kind == EventKind::Assistant)
639 .collect();
640 assert_eq!(assistants.len(), 1, "two lines, one request");
641 let a = assistants[0];
642 assert_eq!(a.usage.unwrap().known_total(), 1130);
643 assert_eq!(
647 a.thinking_chars, 9,
648 "thoughts are a subset of content_chars"
649 );
650 assert_eq!(
651 a.content_chars, 31,
652 "visible(4) + thinking(9) + tool JSON(18)"
653 );
654 assert_eq!(a.tool_calls.len(), 1, "tool call from the later line");
655 assert_eq!(a.tool_calls[0].server.as_deref(), Some("acme-db"));
656 assert_eq!(s.model.as_deref(), Some("gemini-3-flash-preview"));
657 assert_eq!(
658 s.started_at.map(|t| t.to_string()).as_deref(),
659 Some("2026-06-17T10:00:00Z"),
660 "started_at from the header line"
661 );
662 }
663
664 #[test]
666 fn set_ignored_unknown_and_malformed_skipped() {
667 let lines = [
668 r#"{"$set":{"messages":[{"id":"u0","type":"user","content":[{"text":"ctx"}]}]}}"#,
669 r#"{"id":"u1","type":"user","content":[{"text":"hi"}]}"#,
670 r#"{"id":"i1","type":"info","content":[{"text":"model set"}]}"#,
671 r#"{"id":"z1","type":"telemetry_blob","data":1}"#,
672 r#"{"id":"broken","type":"gemini","#,
673 ]
674 .join("\n");
675 let s = parse_reader(
676 Cursor::new(lines),
677 "s2".into(),
678 None,
679 "gemini",
680 Path::new("mem://test"),
681 );
682 assert_eq!(s.skipped_lines, 2, "unknown type + malformed JSON");
683 assert_eq!(
685 s.events
686 .iter()
687 .filter(|e| e.kind == EventKind::User)
688 .count(),
689 1
690 );
691 }
692
693 #[test]
695 fn gemini_home_overrides_root() {
696 std::env::set_var("GEMINI_HOME", "/tmp/fake-gemini");
697 let root = gemini_root();
698 std::env::remove_var("GEMINI_HOME");
699 assert_eq!(root, Some(PathBuf::from("/tmp/fake-gemini")));
700 }
701}