use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::adapters::Adapter;
use crate::error::CoreError;
use crate::model::{Event, EventKind, Session, SessionRef, ToolCall, Usage};
pub struct Gemini;
fn gemini_root() -> Option<PathBuf> {
match std::env::var("GEMINI_HOME") {
Ok(dir) if !dir.trim().is_empty() => Some(PathBuf::from(dir)),
_ => directories::BaseDirs::new().map(|b| b.home_dir().join(".gemini")),
}
}
fn is_session_file(path: &Path) -> bool {
path.extension().and_then(|x| x.to_str()) == Some("jsonl")
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("session-"))
&& path
.parent()
.and_then(|d| d.file_name())
.and_then(|n| n.to_str())
== Some("chats")
}
fn project_from_path(path: &Path) -> Option<String> {
path.parent() .and_then(|chats| chats.parent()) .and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
}
impl Adapter for Gemini {
fn id(&self) -> &'static str {
"gemini"
}
fn detect(&self) -> bool {
let Some(tmp) = gemini_root().map(|r| r.join("tmp")) else {
return false;
};
walkdir::WalkDir::new(&tmp)
.max_depth(3)
.follow_links(false)
.into_iter()
.filter_map(Result::ok)
.any(|e| e.file_type().is_file() && is_session_file(e.path()))
}
fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
let root = gemini_root().ok_or_else(|| {
anyhow::anyhow!("could not determine the home directory (set GEMINI_HOME)")
})?;
let tmp = root.join("tmp");
let mut refs = Vec::new();
if tmp.is_dir() {
for entry in walkdir::WalkDir::new(&tmp).max_depth(3).follow_links(false) {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::debug!("skipping unreadable directory entry: {e}");
continue;
}
};
if !entry.file_type().is_file() || !is_session_file(entry.path()) {
continue;
}
let meta = entry.metadata().ok();
refs.push(SessionRef {
path: entry.path().to_path_buf(),
agent: self.id().to_string(),
project: project_from_path(entry.path()),
size_bytes: meta.as_ref().map_or(0, |m| m.len()),
modified: meta
.and_then(|m| m.modified().ok())
.and_then(|t| jiff::Timestamp::try_from(t).ok()),
});
}
}
refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
Ok(refs)
}
fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
let file = File::open(&r.path).map_err(|source| CoreError::Io {
path: r.path.clone(),
source,
})?;
let id = session_id_for(&r.path);
let project = r.project.clone().or_else(|| project_from_path(&r.path));
Ok(parse_reader(
BufReader::new(file),
id,
project,
self.id(),
&r.path,
))
}
}
fn session_id_for(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
}
fn parse_reader<R: BufRead>(
reader: R,
id: String,
project: Option<String>,
agent: &str,
path_for_logs: &Path,
) -> Session {
let mut session = Session {
id,
agent: agent.to_string(),
project,
model: None,
parent_session: None,
started_at: None,
ended_at: None,
events: Vec::new(),
sub_agents: Vec::new(),
skipped_lines: 0,
};
let mut gemini_idx: HashMap<String, usize> = HashMap::new();
let mut model_counts: HashMap<String, u64> = HashMap::new();
let mut header_start: Option<jiff::Timestamp> = None;
for (lineno, line) in reader.lines().enumerate() {
let line = match line {
Ok(l) => l,
Err(e) => {
session.skipped_lines += 1;
tracing::debug!(
"{}:{}: unreadable line: {e}",
path_for_logs.display(),
lineno + 1
);
continue;
}
};
if line.trim().is_empty() {
continue;
}
let raw: RawLine = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
session.skipped_lines += 1;
tracing::debug!(
"{}:{}: unparseable JSON: {e}",
path_for_logs.display(),
lineno + 1
);
continue;
}
};
if raw.set.is_some() {
continue;
}
let ts = parse_ts(&raw.timestamp);
match raw.msg_type.as_deref() {
Some("gemini") => {
apply_gemini(&raw, ts, &mut session, &mut gemini_idx, &mut model_counts)
}
Some("user") => apply_user(&raw, ts, &mut session),
Some("info") | Some("error") => {}
Some(_) => {
session.skipped_lines += 1;
tracing::debug!(
"{}:{}: unknown message type {:?}",
path_for_logs.display(),
lineno + 1,
raw.msg_type
);
}
None => {
if raw.session_id.is_some() {
if header_start.is_none() {
header_start = parse_ts(&raw.start_time);
}
} else {
session.skipped_lines += 1;
tracing::debug!(
"{}:{}: unrecognized line shape",
path_for_logs.display(),
lineno + 1
);
}
}
}
}
session.started_at = header_start.or_else(|| session.events.iter().filter_map(|e| e.ts).min());
session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
session.model = model_counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(model, _)| model);
session
}
fn apply_gemini(
raw: &RawLine,
ts: Option<jiff::Timestamp>,
session: &mut Session,
gemini_idx: &mut HashMap<String, usize>,
model_counts: &mut HashMap<String, u64>,
) {
let usage = raw.tokens.as_ref().map(map_tokens);
let content = raw.content.as_str().unwrap_or_default();
let visible_chars = content.chars().count() as u64;
let thinking_chars = raw
.thoughts
.iter()
.flatten()
.map(RawThought::chars)
.sum::<u64>();
let has_thinking = raw.thoughts.as_ref().is_some_and(|t| !t.is_empty())
|| raw.tokens.as_ref().and_then(|t| t.thoughts).unwrap_or(0) > 0;
let tool_calls: Vec<ToolCall> = raw
.tool_calls
.iter()
.flatten()
.map(RawToolCall::to_tool_call)
.collect();
let content_chars =
visible_chars + thinking_chars + tool_calls.iter().map(|c| c.input_bytes).sum::<u64>();
let key = raw.id.clone().unwrap_or_default();
if let Some(&idx) = gemini_idx.get(&key) {
let ev = &mut session.events[idx];
ev.usage = match (ev.usage, usage) {
(Some(a), Some(b)) => Some(a.merge_max(b)),
(a, b) => a.or(b),
};
ev.thinking_chars = thinking_chars;
ev.has_thinking |= has_thinking;
if let Some(s) = snippet(content) {
ev.content_summary = Some(s);
}
if !tool_calls.is_empty() {
ev.tool_calls = tool_calls;
}
ev.content_chars = visible_chars
+ thinking_chars
+ ev.tool_calls.iter().map(|c| c.input_bytes).sum::<u64>();
return;
}
if let Some(model) = raw.model.as_deref().filter(|m| !m.is_empty()) {
*model_counts.entry(model.to_string()).or_default() += 1;
}
gemini_idx.insert(key.clone(), session.events.len());
session.events.push(Event {
kind: EventKind::Assistant,
ts,
request_id: (!key.is_empty()).then_some(key),
model: raw.model.clone().filter(|m| !m.is_empty()),
usage,
tool_calls,
sidechain: false,
content_summary: snippet(content),
content_chars,
thinking_chars,
has_thinking,
tool_use_id: None,
attachment_kind: None,
item_count: 0,
});
}
fn apply_user(raw: &RawLine, ts: Option<jiff::Timestamp>, session: &mut Session) {
let blocks = match raw.content.as_array() {
Some(b) => b,
None => return,
};
if let Some(fr) = blocks.iter().find_map(|b| b.get("functionResponse")) {
let tool_use_id = fr
.get("id")
.and_then(|v| v.as_str())
.map(str::to_string)
.filter(|s| !s.is_empty());
let output = fr
.get("response")
.and_then(|r| r.get("output"))
.map(value_text)
.unwrap_or_default();
session.events.push(Event {
kind: EventKind::ToolResult,
ts,
request_id: None,
model: None,
usage: None,
tool_calls: Vec::new(),
sidechain: false,
content_summary: snippet(&output),
content_chars: output.chars().count() as u64,
thinking_chars: 0,
has_thinking: false,
tool_use_id,
attachment_kind: None,
item_count: 0,
});
return;
}
let text = blocks
.iter()
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
session.events.push(Event {
kind: EventKind::User,
ts,
request_id: None,
model: None,
usage: None,
tool_calls: Vec::new(),
sidechain: false,
content_summary: snippet(&text),
content_chars: text.chars().count() as u64,
thinking_chars: 0,
has_thinking: false,
tool_use_id: None,
attachment_kind: None,
item_count: 0,
});
}
fn map_tokens(t: &RawTokens) -> Usage {
let cached = t.cached.unwrap_or(0);
Usage {
input: t.input.map(|i| i.saturating_sub(cached)),
output: t.output,
cache_creation: None,
cache_creation_5m: None,
cache_creation_1h: None,
cache_read: t.cached,
thinking: t.thoughts,
}
}
fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
raw.as_deref().and_then(|t| t.parse().ok())
}
fn snippet(text: &str) -> Option<String> {
let first = text.lines().find(|l| !l.trim().is_empty())?;
let s: String = first.trim().chars().take(80).collect();
(!s.is_empty()).then_some(s)
}
fn value_text(v: &serde_json::Value) -> String {
match v.as_str() {
Some(s) => s.to_string(),
None => v.to_string(),
}
}
#[derive(Deserialize, Default)]
struct RawLine {
#[serde(rename = "$set")]
set: Option<serde::de::IgnoredAny>,
#[serde(rename = "sessionId")]
session_id: Option<String>,
#[serde(rename = "startTime")]
start_time: Option<String>,
#[serde(rename = "type")]
msg_type: Option<String>,
id: Option<String>,
timestamp: Option<String>,
#[serde(default)]
content: serde_json::Value,
thoughts: Option<Vec<RawThought>>,
tokens: Option<RawTokens>,
model: Option<String>,
#[serde(rename = "toolCalls")]
tool_calls: Option<Vec<RawToolCall>>,
}
#[derive(Deserialize, Clone, Copy)]
struct RawTokens {
input: Option<u64>,
output: Option<u64>,
cached: Option<u64>,
thoughts: Option<u64>,
#[allow(dead_code)]
tool: Option<u64>,
#[allow(dead_code)]
total: Option<u64>,
}
#[derive(Deserialize)]
struct RawThought {
subject: Option<String>,
description: Option<String>,
}
impl RawThought {
fn chars(&self) -> u64 {
let s = self.subject.as_deref().map_or(0, |x| x.chars().count());
let d = self.description.as_deref().map_or(0, |x| x.chars().count());
(s + d) as u64
}
}
#[derive(Deserialize)]
struct RawToolCall {
id: Option<String>,
name: Option<String>,
args: Option<serde_json::Value>,
}
impl RawToolCall {
fn to_tool_call(&self) -> ToolCall {
let name = self.name.clone().unwrap_or_else(|| "unknown".to_string());
let input_bytes = self
.args
.as_ref()
.map_or(0, |a| value_text(a).chars().count() as u64);
ToolCall {
server: ToolCall::server_from_name(&name),
id: self.id.clone().unwrap_or_default(),
name,
input_bytes,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn tokens_map_disjointly_to_total() {
let t = RawTokens {
input: Some(1000),
output: Some(50),
cached: Some(200),
thoughts: Some(80),
tool: Some(0),
total: Some(1130),
};
let u = map_tokens(&t);
assert_eq!(u.cache_read, Some(200));
assert_eq!(u.input, Some(800), "input minus cached subset");
assert_eq!(u.output, Some(50), "output is already visible-only");
assert_eq!(u.thinking, Some(80), "thoughts disjoint from output");
assert_eq!(u.cache_creation, None);
assert_eq!(
u.known_total(),
1130,
"Σ disjoint fields == total (input+output+thoughts)"
);
}
#[test]
fn underflow_saturates_instead_of_panicking() {
let t = RawTokens {
input: Some(100),
output: Some(40),
cached: Some(250),
thoughts: Some(10),
tool: None,
total: Some(150),
};
let u = map_tokens(&t);
assert_eq!(u.input, Some(0));
assert_eq!(u.cache_read, Some(250));
}
#[test]
fn repeated_gemini_id_collapses_last_wins() {
let lines = [
r#"{"sessionId":"s1","startTime":"2026-06-17T10:00:00.000Z","kind":"main"}"#,
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"}"#,
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"}}]}"#,
]
.join("\n");
let s = parse_reader(
Cursor::new(lines),
"s1".into(),
Some("demo".into()),
"gemini",
Path::new("mem://test"),
);
let assistants: Vec<_> = s
.events
.iter()
.filter(|e| e.kind == EventKind::Assistant)
.collect();
assert_eq!(assistants.len(), 1, "two lines, one request");
let a = assistants[0];
assert_eq!(a.usage.unwrap().known_total(), 1130);
assert_eq!(
a.thinking_chars, 9,
"thoughts are a subset of content_chars"
);
assert_eq!(
a.content_chars, 31,
"visible(4) + thinking(9) + tool JSON(18)"
);
assert_eq!(a.tool_calls.len(), 1, "tool call from the later line");
assert_eq!(a.tool_calls[0].server.as_deref(), Some("acme-db"));
assert_eq!(s.model.as_deref(), Some("gemini-3-flash-preview"));
assert_eq!(
s.started_at.map(|t| t.to_string()).as_deref(),
Some("2026-06-17T10:00:00Z"),
"started_at from the header line"
);
}
#[test]
fn set_ignored_unknown_and_malformed_skipped() {
let lines = [
r#"{"$set":{"messages":[{"id":"u0","type":"user","content":[{"text":"ctx"}]}]}}"#,
r#"{"id":"u1","type":"user","content":[{"text":"hi"}]}"#,
r#"{"id":"i1","type":"info","content":[{"text":"model set"}]}"#,
r#"{"id":"z1","type":"telemetry_blob","data":1}"#,
r#"{"id":"broken","type":"gemini","#,
]
.join("\n");
let s = parse_reader(
Cursor::new(lines),
"s2".into(),
None,
"gemini",
Path::new("mem://test"),
);
assert_eq!(s.skipped_lines, 2, "unknown type + malformed JSON");
assert_eq!(
s.events
.iter()
.filter(|e| e.kind == EventKind::User)
.count(),
1
);
}
#[test]
fn gemini_home_overrides_root() {
std::env::set_var("GEMINI_HOME", "/tmp/fake-gemini");
let root = gemini_root();
std::env::remove_var("GEMINI_HOME");
assert_eq!(root, Some(PathBuf::from("/tmp/fake-gemini")));
}
}