use crate::context::ContextReader;
use crate::context::ir::{IrMessage, IrPart, build_tool_call, build_tool_result, extract_text};
use crate::message::{
AssistantResponse, Context, ContextListing, ConversationMessage, Entry, MessageKind,
TextContent,
};
use anyhow::Context as _;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
pub struct GeminiReader {
file_path: Option<PathBuf>,
}
impl GeminiReader {
pub fn new(file_path: PathBuf) -> Self {
Self {
file_path: Some(file_path),
}
}
pub fn empty() -> Self {
Self { file_path: None }
}
}
fn read_record(file_path: &Path) -> anyhow::Result<Value> {
let contents =
fs::read_to_string(file_path).with_context(|| format!("open {}", file_path.display()))?;
serde_json::from_str(&contents).with_context(|| format!("parse {}", file_path.display()))
}
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 Some(file_path) = &self.file_path else {
return Ok(Vec::new());
};
let record = read_record(file_path)?;
let id = session_id(file_path);
let timestamp = record["startTime"].as_str().unwrap_or("");
let count = record["messages"].as_array().map_or(0, Vec::len);
let detail = if timestamp.is_empty() {
format!("{count} messages")
} else {
format!("{timestamp}, {count} messages")
};
Ok(vec![ContextListing {
id,
detail,
path: Some(file_path.clone()),
}])
}
fn read_context(&self, _context_id: &str) -> anyhow::Result<Context> {
let Some(file_path) = &self.file_path else {
anyhow::bail!("no sessions found");
};
let record = read_record(file_path)?;
let empty = Vec::new();
let source = record["messages"].as_array().unwrap_or(&empty);
let mut entries = Vec::new();
let mut messages = Vec::new();
let mut prev_entry_id = String::new();
for (idx, msg) in source.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 kind = gemini_build_message_kind(msg);
let mut conversation = ConversationMessage::new(entry_id.clone(), kind);
conversation.metadata = gemini_metadata(msg);
messages.push(conversation);
entries.push(Entry {
id: entry_id.clone(),
parent_id,
});
prev_entry_id.clone_from(&entry_id);
if msg["type"].as_str() == Some("gemini") {
if 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}");
let mut conversation = ConversationMessage::new(result_id.clone(), result);
conversation
.metadata
.insert("role".to_string(), Value::String("tool".to_string()));
messages.push(conversation);
entries.push(Entry {
id: result_id.clone(),
parent_id: prev_entry_id.clone(),
});
prev_entry_id = result_id;
}
}
}
}
Ok(Context { entries, messages })
}
}
fn gemini_build_message_kind(msg: &Value) -> MessageKind {
match msg["type"].as_str() {
Some("gemini") => {
let mut ir = IrMessage::new();
for thought in msg["thoughts"].as_array().into_iter().flatten() {
let text = gemini_thought_text(thought);
if !text.is_empty() {
ir.push(IrPart::thinking(text));
}
}
let text = extract_text(msg.get("content"));
if !text.is_empty() {
ir.push(IrPart::text(text));
}
for call in msg["toolCalls"].as_array().into_iter().flatten() {
let name = call["name"].as_str().unwrap_or("");
ir.push(IrPart::tool_call(build_tool_call(name, call.get("args"))));
}
MessageKind::AssistantResponse(AssistantResponse {
thinking: ir.thinking(),
tool_calls: ir.tool_calls(),
text: ir.text(),
})
}
Some(other) => MessageKind::TextContent(TextContent {
role: other.to_string(),
text: extract_text(msg.get("content")),
}),
None => MessageKind::TextContent(TextContent {
role: "unknown".to_string(),
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<MessageKind> {
let result = call.get("result").filter(|r| !r.is_null())?;
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(MessageKind::ToolResultData(build_tool_result(
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)
}
fn gemini_metadata(msg: &Value) -> std::collections::BTreeMap<String, Value> {
let mut metadata = std::collections::BTreeMap::new();
if let Some(kind) = msg["type"].as_str() {
let role = if kind == "gemini" { "assistant" } else { kind };
metadata.insert("role".to_string(), Value::String(role.to_string()));
}
if let Some(model) = msg["model"].as_str() {
metadata.insert("model".to_string(), Value::String(model.to_string()));
}
if let Some(timestamp) = msg["timestamp"].as_str() {
metadata.insert(
"timestamp".to_string(),
Value::String(timestamp.to_string()),
);
}
metadata
}