goosedump 0.8.2

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, build_tool_result, extract_text};
use crate::message::{
    Context, ContextListing, ConversationMessage, Entry, Part, ProviderId, Reasoning,
};
use anyhow::Context as _;
use chrono::{DateTime, NaiveDateTime, Utc};
use rusqlite::types::ValueRef;
use serde_json::Value;
use std::collections::HashSet;
use std::path::PathBuf;

pub struct GooseReader {
    db_path: PathBuf,
}

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

impl ContextReader for GooseReader {
    fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
        let conn = crate::context::open_sqlite(&self.db_path)?;

        // Adapt to whichever columns exist rather than hardcoding one schema.
        let mut stmt = conn.prepare("PRAGMA table_info(sessions)")?;
        let cols: HashSet<String> = stmt
            .query_map([], |row| row.get::<_, String>(1))?
            .collect::<Result<_, _>>()?;
        let title_expr = if cols.contains("title") {
            "title"
        } else if cols.contains("name") {
            "name"
        } else {
            "NULL"
        };
        let working_expr = if cols.contains("working_dir") {
            "working_dir"
        } else {
            "NULL"
        };
        let count_expr = if cols.contains("message_count") {
            "message_count"
        } else {
            "(SELECT COUNT(*) FROM messages WHERE messages.session_id = sessions.id)"
        };
        let parent_expr = if cols.contains("parent_session_id") {
            "parent_session_id"
        } else {
            "NULL"
        };
        let sql = format!(
            "SELECT id, {title_expr}, {working_expr}, created_at, updated_at, {count_expr}, \
             {parent_expr} FROM sessions ORDER BY created_at 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)?,
                goose_time(row.get_ref(3)?),
                goose_time(row.get_ref(4)?),
                row.get::<_, i64>(5)?,
                row.get::<_, Option<String>>(6)?,
            ))
        })?;

        let mut contexts = Vec::new();
        for row in rows {
            let (id, title, working_dir, from, until, count, parent_id) = row?;
            contexts.push(ContextListing {
                id,
                provider_id: ProviderId {
                    label: title.filter(|t| !t.is_empty()),
                    cwd: PathBuf::from(working_dir.unwrap_or_default()),
                    count: count.try_into().unwrap_or(0),
                    from,
                    until,
                },
                path: self.db_path.clone(),
                parent_id,
            });
        }
        Ok(contexts)
    }

    fn delete_context(&self, context_id: &str) -> anyhow::Result<()> {
        let mut conn = crate::context::open_sqlite(&self.db_path)?;
        let tx = conn.transaction()?;
        tx.execute(
            "DELETE FROM messages WHERE session_id = ?1",
            rusqlite::params![context_id],
        )?;
        tx.execute(
            "DELETE FROM sessions 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 cwd: Option<String> = conn
            .query_row(
                "SELECT working_dir FROM sessions WHERE id = ?1",
                rusqlite::params![context_id],
                |row| row.get::<_, String>(0),
            )
            .ok()
            .filter(|dir| !dir.is_empty());

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

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

        for row in rows {
            let (id, role, content_str) = row?;

            let blocks: Value = serde_json::from_str(&content_str)
                .with_context(|| format!("goose: failed to parse content_json for message {id}"))?;

            let entry_id = format!("goose-{id}");
            let parent_id = prev_entry_id.clone();

            let parts = goose_build_message(
                &role,
                blocks.as_array().map_or(&[] as &[Value], |a| a.as_slice()),
            );

            messages.push(ConversationMessage::new(entry_id.clone(), role, parts));
            entries.push(Entry {
                id: entry_id.clone(),
                parent_id,
            });
            prev_entry_id = entry_id;
        }

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

fn goose_build_message(role: &str, blocks: &[Value]) -> Vec<Part> {
    let array_value = Value::Array(blocks.to_vec());

    match role {
        "user" => {
            for block in blocks {
                if block["type"].as_str() == Some("toolResponse") {
                    let call_id = block["id"].as_str().unwrap_or("");
                    let tool_name = block["toolResult"]["value"]["name"]
                        .as_str()
                        .unwrap_or("tool")
                        .to_string();
                    let content_value = &block["toolResult"]["value"]["content"];
                    let content = extract_text(Some(content_value));
                    let is_error = block["toolResult"]["status"].as_str() == Some("error");
                    return vec![Part::ToolResult(build_tool_result(
                        call_id, tool_name, content, is_error,
                    ))];
                }
            }
            vec![Part::Text(extract_text(Some(&array_value)))]
        }
        "assistant" => {
            let mut parts = Vec::new();
            for block in blocks {
                match block["type"].as_str() {
                    Some("text") => {
                        parts.push(Part::Text(block["text"].as_str().unwrap_or("").to_string()));
                    }
                    Some("thinking") => {
                        if let Some(text) = block["thinking"].as_str() {
                            parts.push(Part::Reasoning(Reasoning::new(text)));
                        }
                    }
                    Some("toolRequest") => {
                        if let Some(tc) = block.get("toolCall") {
                            let id = block["id"].as_str().unwrap_or("");
                            let name = tc["value"]["name"].as_str().unwrap_or("");
                            let arguments = tc["value"].get("arguments");
                            parts.push(Part::ToolCall(build_tool_call(id, name, arguments)));
                        }
                    }
                    _ => {}
                }
            }
            parts
        }
        _ => vec![Part::Text(extract_text(Some(&array_value)))],
    }
}

fn goose_time(value: ValueRef<'_>) -> Option<DateTime<Utc>> {
    match value {
        ValueRef::Integer(ms) => DateTime::<Utc>::from_timestamp_millis(ms),
        ValueRef::Text(bytes) => {
            let text = std::str::from_utf8(bytes).ok()?;
            DateTime::parse_from_rfc3339(text)
                .map(|dt| dt.with_timezone(&Utc))
                .ok()
                .or_else(|| {
                    NaiveDateTime::parse_from_str(text, "%Y-%m-%d %H:%M:%S")
                        .ok()
                        .map(|naive| naive.and_utc())
                })
        }
        _ => None,
    }
}