goosedump 0.9.8

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

use crate::context::ContextReader;
use crate::context::ir::{build_tool_call, extract_text};
use crate::message::{
    Context, ContextListing, ConversationMessage, Entry, Part, ProviderId, Reasoning,
};
use chrono::{DateTime, Utc};
use rusqlite::Connection;
use serde_json::Value;
use std::path::{Path, PathBuf};

fn list_via_session(conn: &Connection, db_path: &Path) -> anyhow::Result<Vec<ContextListing>> {
    let sql = "SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, s.parent_id, \
               (SELECT COUNT(*) FROM message WHERE session_id = s.id) \
               FROM session s ORDER BY s.time_created DESC";
    let mut stmt = conn.prepare(sql)?;
    let rows = stmt.query_map([], |row| {
        Ok((
            row.get::<_, String>(0)?,
            row.get::<_, Option<String>>(1)?,
            row.get::<_, Option<String>>(2)?,
            row.get::<_, Option<i64>>(3)?,
            row.get::<_, Option<i64>>(4)?,
            row.get::<_, Option<String>>(5)?,
            row.get::<_, i64>(6)?,
        ))
    })?;

    let mut contexts = Vec::new();
    for row in rows {
        let (id, directory, title, time_created, time_updated, parent_id, count) = row?;
        contexts.push(ContextListing {
            id,
            provider_id: ProviderId {
                label: title.filter(|t| !t.is_empty()),
                cwd: directory.map_or_else(PathBuf::new, PathBuf::from),
                count: count.try_into().unwrap_or(0),
                from: time_created.and_then(DateTime::<Utc>::from_timestamp_millis),
                until: time_updated.and_then(DateTime::<Utc>::from_timestamp_millis),
            },
            path: db_path.to_path_buf(),
            parent_id,
        });
    }
    Ok(contexts)
}

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 = crate::context::open_sqlite(&self.db_path)?;

        // Try the session table first; fall back to message-only if the table
        // does not exist (test fixture).
        if let Ok(contexts) = list_via_session(&conn, &self.db_path) {
            return Ok(contexts);
        }

        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?;
            contexts.push(ContextListing {
                id: session_id,
                provider_id: ProviderId {
                    label: None,
                    cwd: PathBuf::new(),
                    count: count.try_into().unwrap_or(0),
                    from: DateTime::<Utc>::from_timestamp_millis(min_time),
                    until: DateTime::<Utc>::from_timestamp_millis(max_time),
                },
                path: self.db_path.clone(),
                parent_id: None,
            });
        }
        Ok(contexts)
    }

    fn delete_context(&self, context_id: &str) -> anyhow::Result<()> {
        let mut conn = crate::context::open_sqlite(&self.db_path)?;
        let has_session: bool = conn
            .prepare("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'session'")
            .and_then(|mut stmt| stmt.query_row([], |row| row.get::<_, i64>(0)))
            .is_ok_and(|count| count > 0);
        let tx = conn.transaction()?;
        tx.execute(
            "DELETE FROM message WHERE session_id = ?1",
            rusqlite::params![context_id],
        )?;
        tx.execute(
            "DELETE FROM part WHERE session_id = ?1",
            rusqlite::params![context_id],
        )?;
        if has_session {
            tx.execute(
                "DELETE FROM session WHERE id = ?1",
                rusqlite::params![context_id],
            )?;
        }
        tx.commit()?;
        Ok(())
    }

    fn read_context(&self, context_id: &str) -> anyhow::Result<Context> {
        let conn = crate::context::open_sqlite(&self.db_path)?;

        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");
            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 (role, parts) = build_opencode_message(role, &msg_parts);
            messages.push(ConversationMessage::new(entry_id.clone(), role, parts));

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

        // Try to extract cwd from the session table if it exists.
        let cwd: Option<String> = conn
            .query_row(
                "SELECT directory FROM session WHERE id = ?1",
                rusqlite::params![context_id],
                |row| row.get::<_, Option<String>>(0),
            )
            .ok()
            .flatten();

        Ok(Context {
            entries,
            messages,
            cwd,
        })
    }
}

fn build_opencode_message(role: &str, parts: &[&Value]) -> (String, Vec<Part>) {
    match role {
        "assistant" => {
            let mut out_parts = Vec::new();
            for part in parts {
                match part["type"].as_str() {
                    Some("text") => {
                        out_parts.push(Part::Text(part["text"].as_str().unwrap_or("").to_string()));
                    }
                    Some("reasoning") => {
                        if let Some(text) = part["text"].as_str() {
                            out_parts.push(Part::Reasoning(Reasoning::new(text)));
                        }
                    }
                    Some("tool") => {
                        // Opencode stores `{"tool": name, "state": {"input": args}}`;
                        // rows written by older goosedump imports carry
                        // `{"name": name, "arguments": args}` instead.
                        let name = part["tool"].as_str().or_else(|| part["name"].as_str());
                        if let Some(name) = name {
                            let id = part["callID"].as_str().or_else(|| part["id"].as_str());
                            let args = part
                                .get("state")
                                .and_then(|state| state.get("input"))
                                .or_else(|| part.get("arguments"));
                            out_parts.push(Part::ToolCall(build_tool_call(
                                id.unwrap_or(""),
                                name,
                                args,
                            )));
                        }
                    }
                    _ => {}
                }
            }
            ("assistant".to_string(), out_parts)
        }
        _ => (
            role.to_string(),
            vec![Part::Text(extract_text_from_parts(parts))],
        ),
    }
}

fn extract_text_from_parts(parts: &[&Value]) -> String {
    let value = Value::Array(parts.iter().map(|v| (*v).clone()).collect());
    extract_text(Some(&value))
}