goosedump 0.4.2

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

//! Reader for Gemini CLI session transcripts.
//!
//! Gemini CLI's `ChatRecordingService` stores one session per JSON file under
//! `~/.gemini/tmp/<project-hash>/chats/session-<timestamp>-<id>.json`. Each
//! file holds a single `ConversationRecord` object: session metadata plus a
//! `messages` array.
//!
//! A message is `user`, `gemini`, or a transient status
//! (`info`/`error`/`warning`). A `gemini` message carries `thoughts`, a text
//! `content`, and a `toolCalls` array; each tool call embeds its own `result`
//! (a list of `functionResponse` parts); the result is not a separate record.
//! The reader emits the assistant turn first, then one `ToolResultData` entry
//! per resolved call, chained sequentially.

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()))
}

/// The context id is the file stem. A resumed session is written to a fresh
/// file that keeps the original `sessionId`, so the stem—which also embeds the
/// start timestamp—is the only collision-free identifier.
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);

            // A gemini turn embeds each tool call's result inline; surface them
            // as follow-up entries so they read like the separate tool-result
            // turns the other providers produce.
            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")),
        }),
    }
}

/// A thought is a `{subject, description}` pair; join the non-empty halves.
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")
}

/// Build a [`MessageKind::ToolResultData`] from a `toolCalls` entry, or `None`
/// when the call has not resolved (no `result` yet).
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,
    )))
}

/// Flatten a tool call `result` (a `PartListUnion`) to text.
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(),
    }
}

/// Extract text from a single result part. Tool results arrive as
/// `functionResponse` parts whose payload is under `response.output` (success)
/// or `response.error` (failure); fall back to plain `text` parts.
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
}