goosedump 0.2.1

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

use crate::context::ContextReader;
use crate::message::{
    AssistantResponse, Context, ContextListing, ConversationMessage, Entry, MessageKind,
    TextContent, ToolCall,
};
use anyhow::Context as _;
use rusqlite::Connection;
use serde_json::Value;
use std::path::PathBuf;

pub struct OpenCodeReader {
    db_path: PathBuf,
}

impl OpenCodeReader {
    pub fn new(db_path: PathBuf) -> Self {
        Self { db_path }
    }
}

impl ContextReader for OpenCodeReader {
    fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
        let conn = Connection::open(&self.db_path)
            .with_context(|| format!("failed to open opencode db at {}", self.db_path.display()))?;

        let sql = "SELECT session_id, COUNT(*), MIN(time_created), MAX(time_created) \
                   FROM message GROUP BY session_id ORDER BY MAX(time_created) DESC";
        let mut stmt = conn.prepare(sql)?;
        let rows = stmt.query_map([], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, i64>(1)?,
                row.get::<_, i64>(2)?,
                row.get::<_, i64>(3)?,
            ))
        })?;

        let mut contexts = Vec::new();
        for row in rows {
            let (session_id, count, min_time, max_time) = row?;

            let detail = format!(
                "{} messages, {} to {}",
                count,
                format_time(min_time),
                format_time(max_time)
            );
            contexts.push(ContextListing {
                id: session_id,
                detail,
                path: Some(self.db_path.clone()),
            });
        }
        Ok(contexts)
    }

    fn read_context(&self, context_id: &str) -> anyhow::Result<Context> {
        let conn = Connection::open(&self.db_path)
            .with_context(|| format!("failed to open opencode db at {}", self.db_path.display()))?;

        let sql = "SELECT id, data FROM message WHERE session_id = ?1 ORDER BY time_created";
        let mut stmt = conn.prepare(sql)?;
        let rows = stmt.query_map(rusqlite::params![context_id], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })?;

        let mut messages_data: Vec<(String, Value)> = Vec::new();
        for row in rows {
            let (id, data_str) = row?;
            let data: Value = serde_json::from_str(&data_str)?;
            messages_data.push((id, data));
        }

        let part_sql =
            "SELECT message_id, data FROM part WHERE session_id = ?1 ORDER BY time_created";
        let mut part_stmt = conn.prepare(part_sql)?;
        let part_rows = part_stmt.query_map(rusqlite::params![context_id], |row| {
            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
        })?;

        let mut parts: Vec<(String, Value)> = Vec::new();
        for row in part_rows {
            let (msg_id, data_str) = row?;
            let data: Value = serde_json::from_str(&data_str)?;
            parts.push((msg_id, data));
        }

        let mut entries = Vec::new();
        let mut messages = Vec::new();

        for (msg_id, msg_data) in &messages_data {
            let role = msg_data["role"].as_str().unwrap_or("unknown").to_string();
            let entry_id = msg_data["id"]
                .as_str()
                .map_or_else(|| msg_id.clone(), std::string::ToString::to_string);
            let parent_id = msg_data["parentID"]
                .as_str()
                .map(std::string::ToString::to_string)
                .unwrap_or_default();

            let msg_parts: Vec<&Value> = parts
                .iter()
                .filter(|(mid, _)| mid == msg_id)
                .map(|(_, d)| d)
                .collect();

            let kind = build_message_kind(role.as_str(), &msg_parts);

            messages.push(ConversationMessage {
                entry_id: entry_id.clone(),
                kind,
            });

            entries.push(Entry {
                id: entry_id.clone(),
                parent_id,
            });
        }

        Ok(Context { entries, messages })
    }
}

fn build_message_kind(role: &str, parts: &[&Value]) -> MessageKind {
    match role {
        "user" | "system" => {
            let text: String = parts
                .iter()
                .filter(|p| p["type"].as_str() == Some("text"))
                .map(|p| p["text"].as_str().unwrap_or("").to_string())
                .collect::<Vec<_>>()
                .join("\n");
            MessageKind::TextContent(TextContent {
                role: role.to_string(),
                text,
            })
        }
        "assistant" => {
            let mut thinking = Vec::new();
            let mut tool_calls = Vec::new();
            let mut text = String::new();

            for part in parts {
                match part["type"].as_str() {
                    Some("text") => {
                        text.push_str(part["text"].as_str().unwrap_or(""));
                    }
                    Some("reasoning") => {
                        if let Some(t) = part["text"].as_str() {
                            thinking.push(t.to_string());
                        }
                    }
                    Some("tool") => {
                        if let Some(tc) = parse_tool_call(part) {
                            tool_calls.push(tc);
                        }
                    }
                    _ => {}
                }
            }

            MessageKind::AssistantResponse(AssistantResponse {
                thinking,
                tool_calls,
                text,
            })
        }
        _ => {
            let text: String = parts
                .iter()
                .filter(|p| p["type"].as_str() == Some("text"))
                .map(|p| p["text"].as_str().unwrap_or("").to_string())
                .collect::<Vec<_>>()
                .join("\n");
            MessageKind::TextContent(TextContent {
                role: role.to_string(),
                text,
            })
        }
    }
}

fn parse_tool_call(part: &Value) -> Option<ToolCall> {
    let name = part["name"].as_str()?;
    let arguments = part["arguments"].clone();
    Some(ToolCall {
        name: name.to_string(),
        arguments,
    })
}

fn format_time(ts: i64) -> String {
    if ts <= 0 {
        return "unknown".to_string();
    }
    let Some(dt) = chrono::DateTime::from_timestamp(ts, 0) else {
        return ts.to_string();
    };
    dt.format("%Y-%m-%d %H:%M").to_string()
}