use crate::context::ContextReader;
use crate::context::ir::{build_tool_call, build_tool_result, extract_text};
use crate::message::{
Context, ContextListing, ConversationMessage, Entry, Part, ProviderId, Reasoning,
};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
pub struct GeminiReader {
file_path: PathBuf,
}
impl GeminiReader {
pub fn new(file_path: PathBuf) -> Self {
Self { file_path }
}
}
struct GeminiConversation {
messages: Vec<Value>,
start: Option<String>,
last_updated: Option<String>,
}
fn load_conversation(file_path: &Path) -> anyhow::Result<GeminiConversation> {
let contents =
fs::read_to_string(file_path).with_context(|| format!("open {}", file_path.display()))?;
if let Ok(record) = serde_json::from_str::<Value>(&contents)
&& let Some(messages) = record.get("messages").and_then(Value::as_array)
{
return Ok(GeminiConversation {
messages: messages.clone(),
start: str_field(&record, "startTime"),
last_updated: str_field(&record, "lastUpdated"),
});
}
Ok(reconstruct_jsonl(&contents))
}
fn str_field(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(Value::as_str).map(str::to_string)
}
fn upsert(order: &mut Vec<String>, by_id: &mut HashMap<String, Value>, id: &str, record: Value) {
if !by_id.contains_key(id) {
order.push(id.to_string());
}
by_id.insert(id.to_string(), record);
}
fn reconstruct_jsonl(contents: &str) -> GeminiConversation {
let mut order: Vec<String> = Vec::new();
let mut by_id: HashMap<String, Value> = HashMap::new();
let mut start: Option<String> = None;
let mut last_updated: Option<String> = None;
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(record) = serde_json::from_str::<Value>(line) else {
continue;
};
if let Some(rewind_to) = record.get("$rewindTo").and_then(Value::as_str) {
if let Some(pos) = order.iter().position(|id| id.as_str() == rewind_to) {
for id in order.split_off(pos) {
by_id.remove(&id);
}
} else {
order.clear();
by_id.clear();
}
} else if let Some(set) = record.get("$set") {
if let Some(messages) = set.get("messages").and_then(Value::as_array) {
order.clear();
by_id.clear();
for msg in messages {
if let Some(id) = msg.get("id").and_then(Value::as_str).map(str::to_string) {
upsert(&mut order, &mut by_id, &id, msg.clone());
}
}
}
if let Some(value) = str_field(set, "startTime") {
start = Some(value);
}
if let Some(value) = str_field(set, "lastUpdated") {
last_updated = Some(value);
}
} else if let Some(id) = record.get("id").and_then(Value::as_str).map(str::to_string) {
upsert(&mut order, &mut by_id, &id, record);
} else if record.get("sessionId").is_some() && record.get("projectHash").is_some() {
if let Some(value) = str_field(&record, "startTime") {
start = Some(value);
}
if let Some(value) = str_field(&record, "lastUpdated") {
last_updated = Some(value);
}
if let Some(messages) = record.get("messages").and_then(Value::as_array) {
for msg in messages {
if let Some(id) = msg.get("id").and_then(Value::as_str).map(str::to_string) {
upsert(&mut order, &mut by_id, &id, msg.clone());
}
}
}
}
}
let messages = order
.into_iter()
.filter_map(|id| by_id.remove(&id))
.collect();
GeminiConversation {
messages,
start,
last_updated,
}
}
fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
value.and_then(|s| {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|dt| dt.with_timezone(&Utc))
})
}
fn session_id(file_path: &Path) -> String {
file_path.file_stem().map_or_else(
|| "unknown".to_string(),
|stem| stem.to_string_lossy().into_owned(),
)
}
impl ContextReader for GeminiReader {
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
let file_path = &self.file_path;
let convo = load_conversation(file_path)?;
let id = session_id(file_path);
Ok(vec![ContextListing {
id,
provider_id: ProviderId {
label: None,
cwd: PathBuf::new(),
count: convo.messages.len().try_into().unwrap_or(0),
from: parse_rfc3339(convo.start.as_deref()),
until: parse_rfc3339(convo.last_updated.as_deref()),
},
path: file_path.clone(),
parent_id: None,
}])
}
fn delete_context(&self, _context_id: &str) -> anyhow::Result<()> {
fs::remove_file(&self.file_path)
.with_context(|| format!("remove {}", self.file_path.display()))
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let file_path = &self.file_path;
let convo = load_conversation(file_path)?;
let mut entries = Vec::new();
let mut messages = Vec::new();
let mut prev_entry_id = String::new();
for (idx, msg) in convo.messages.iter().enumerate() {
let entry_id = msg["id"]
.as_str()
.map_or_else(|| format!("gemini-{idx}"), str::to_string);
let parent_id = prev_entry_id.clone();
let (role, parts) = gemini_build_message(msg);
let mut message = ConversationMessage::new(entry_id.clone(), role, parts);
message.model = msg["model"].as_str().map(str::to_string);
messages.push(message);
entries.push(Entry {
id: entry_id.clone(),
parent_id,
});
prev_entry_id.clone_from(&entry_id);
if msg["type"].as_str() == Some("gemini")
&& let Some(calls) = msg["toolCalls"].as_array()
{
for (call_idx, call) in calls.iter().enumerate() {
let Some(result) = gemini_tool_result(call) else {
continue;
};
let result_id = format!("{entry_id}-tool-{call_idx}");
messages.push(ConversationMessage::new(
result_id.clone(),
"user",
vec![result],
));
entries.push(Entry {
id: result_id.clone(),
parent_id: prev_entry_id.clone(),
});
prev_entry_id = result_id;
}
}
}
Ok(Context {
entries,
messages,
cwd: None,
})
}
}
fn gemini_build_message(msg: &Value) -> (String, Vec<Part>) {
match msg["type"].as_str() {
Some("gemini") => {
let mut parts = Vec::new();
for thought in msg["thoughts"].as_array().into_iter().flatten() {
let text = gemini_thought_text(thought);
if !text.is_empty() {
parts.push(Part::Reasoning(Reasoning::new(text)));
}
}
let text = extract_text(msg.get("content"));
if !text.is_empty() {
parts.push(Part::Text(text));
}
for call in msg["toolCalls"].as_array().into_iter().flatten() {
let name = call["name"].as_str().unwrap_or("");
let id = call["id"].as_str().unwrap_or("");
parts.push(Part::ToolCall(build_tool_call(id, name, call.get("args"))));
}
("assistant".to_string(), parts)
}
Some(other) => (
other.to_string(),
vec![Part::Text(extract_text(msg.get("content")))],
),
None => (
"unknown".to_string(),
vec![Part::Text(extract_text(msg.get("content")))],
),
}
}
fn gemini_thought_text(thought: &Value) -> String {
[thought["subject"].as_str(), thought["description"].as_str()]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n")
}
fn gemini_tool_result(call: &Value) -> Option<Part> {
let result = call.get("result").filter(|r| !r.is_null())?;
let call_id = call["id"].as_str().unwrap_or("");
let name = call["name"].as_str().unwrap_or("tool").to_string();
let is_error = call["status"].as_str() == Some("error");
let content = gemini_result_text(result);
Some(Part::ToolResult(build_tool_result(
call_id, name, content, is_error,
)))
}
fn gemini_result_text(result: &Value) -> String {
match result {
Value::String(text) => text.clone(),
Value::Array(parts) => parts
.iter()
.map(gemini_part_text)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n"),
Value::Object(_) => gemini_part_text(result),
_ => String::new(),
}
}
fn gemini_part_text(part: &Value) -> String {
if let Some(response) = part.get("functionResponse").map(|fr| &fr["response"]) {
for key in ["output", "error"] {
if let Some(value) = response.get(key) {
return value_to_text(value);
}
}
if !response.is_null() {
return value_to_text(response);
}
}
part["text"].as_str().unwrap_or("").to_string()
}
fn value_to_text(value: &Value) -> String {
value
.as_str()
.map_or_else(|| value.to_string(), str::to_string)
}