goosedump 0.9.0

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

//! Reader for Gemini CLI session transcripts.

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>,
}

/// Load a Gemini session's messages.
///
/// Handles both the legacy single-object `.json` container and the streaming
/// `.jsonl` container gemini-cli adopted later on. goosedump's own writer
/// emits the single-object form, so it keeps round-tripping.
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))
    })
}

/// 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 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);

            // 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")
                && 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")))],
        ),
    }
}

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

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