goosedump 0.1.4

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, ToolResultData,
};
use anyhow::Context as _;
use rusqlite::Connection;
use serde_json::Value;
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 = Connection::open(&self.db_path)
            .with_context(|| format!("failed to open goose db at {}", self.db_path.display()))?;

        let sql = "SELECT s.id, s.name, s.working_dir, COUNT(m.id) \
                   FROM sessions s LEFT JOIN messages m ON m.session_id = s.id \
                   GROUP BY s.id ORDER BY s.updated_at DESC";
        let mut stmt = conn.prepare(sql)?;
        let rows = stmt.query_map([], |row| {
            Ok((
                row.get::<_, String>(0)?,
                row.get::<_, String>(1)?,
                row.get::<_, String>(2)?,
                row.get::<_, i64>(3)?,
            ))
        })?;

        let mut contexts = Vec::new();
        for row in rows {
            let (id, name, working_dir, count) = row?;
            let detail = if name.is_empty() {
                format!("{count} messages, {working_dir}")
            } else {
                format!("{name}: {count} messages, {working_dir}")
            };
            contexts.push(ContextListing {
                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 goose db at {}", self.db_path.display()))?;

        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 (idx, row) in rows.enumerate() {
            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 kind = goose_build_message_kind(
                &role,
                blocks.as_array().map_or(&[] as &[Value], |a| a.as_slice()),
            );

            messages.push(ConversationMessage {
                entry_id: entry_id.clone(),
                kind,
            });
            entries.push(Entry {
                line: idx,
                r#type: "message".to_string(),
                id: entry_id.clone(),
                parent_id,
            });
            prev_entry_id = entry_id;
        }

        Ok(Context {
            path: self.db_path.clone(),
            entries,
            messages,
        })
    }
}

fn goose_build_message_kind(role: &str, blocks: &[Value]) -> MessageKind {
    match role {
        "user" => {
            for block in blocks {
                if block["type"].as_str() == Some("toolResponse") {
                    let tool_name = block["toolResult"]["value"]["name"]
                        .as_str()
                        .unwrap_or("")
                        .to_string();
                    let content = extract_tool_response_content(block);
                    let is_error = block["toolResult"]["status"].as_str() == Some("error");
                    return MessageKind::ToolResultData(ToolResultData {
                        tool_name,
                        content,
                        is_error,
                    });
                }
            }
            let text: String = blocks
                .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: "user".to_string(),
                text,
            })
        }
        "assistant" => {
            let mut thinking = Vec::new();
            let mut tool_calls = Vec::new();
            let mut text = String::new();

            for block in blocks {
                match block["type"].as_str() {
                    Some("text") => {
                        text.push_str(block["text"].as_str().unwrap_or(""));
                    }
                    Some("thinking") => {
                        if let Some(t) = block["thinking"].as_str() {
                            thinking.push(t.to_string());
                        }
                    }
                    Some("toolRequest") => {
                        if let Some(tc) = block.get("toolCall") {
                            let name = tc["value"]["name"].as_str().unwrap_or("").to_string();
                            let arguments =
                                tc["value"].get("arguments").cloned().unwrap_or(Value::Null);
                            tool_calls.push(ToolCall { name, arguments });
                        }
                    }
                    _ => {}
                }
            }

            MessageKind::AssistantResponse(AssistantResponse {
                thinking,
                tool_calls,
                text,
            })
        }
        _ => {
            let text: String = blocks
                .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 extract_tool_response_content(block: &Value) -> String {
    let content = &block["toolResult"]["value"]["content"];
    if let Some(arr) = content.as_array() {
        arr.iter()
            .filter(|p| p["type"].as_str() == Some("text"))
            .filter_map(|p| p["text"].as_str())
            .collect::<Vec<_>>()
            .join("\n")
    } else if let Some(s) = content.as_str() {
        s.to_string()
    } else if content.is_null() {
        String::new()
    } else {
        content.to_string()
    }
}