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, extract_tool_result_from_part,
};
use crate::message::{
    Context, ContextListing, ConversationMessage, Entry, Part, ProviderId, ToolResultData,
};
use anyhow::Context as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::path::PathBuf;

pub struct CrushReader {
    db_path: PathBuf,
}

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

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

        let sql = "SELECT s.id, s.title, s.message_count, s.created_at, s.updated_at, s.parent_session_id \
                   FROM sessions s ORDER BY s.created_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::<_, i64>(2)?,
                row.get::<_, i64>(3)?,
                row.get::<_, i64>(4)?,
                row.get::<_, Option<String>>(5)?,
            ))
        })?;

        let mut contexts = Vec::new();
        for row in rows {
            let (id, title, count, created_at, updated_at, parent_id) = row?;
            contexts.push(ContextListing {
                id,
                provider_id: ProviderId {
                    label: if title.is_empty() { None } else { Some(title) },
                    cwd: PathBuf::new(),
                    count: count.try_into().unwrap_or(0),
                    from: DateTime::<Utc>::from_timestamp_millis(created_at),
                    until: DateTime::<Utc>::from_timestamp_millis(updated_at),
                },
                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 sql = "SELECT role, parts FROM messages WHERE session_id = ?1 ORDER BY created_at";
        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 = Vec::new();
        let mut entries = Vec::new();
        let mut previous_entry_id = String::new();
        for (idx, row) in rows.enumerate() {
            let (role, parts_str) = row?;
            let parts: Value = serde_json::from_str(&parts_str)?;

            let crush_msg = crush_build_message(&role, &parts)?;
            let entry_id = format!("crush-{idx}");
            let parent_id = previous_entry_id.clone();
            entries.push(Entry {
                id: entry_id.clone(),
                parent_id,
            });
            previous_entry_id.clone_from(&entry_id);

            messages.push(ConversationMessage::new(
                entry_id,
                crush_msg.role,
                crush_msg.parts,
            ));

            for (tri, tr) in crush_msg.tool_results.into_iter().enumerate() {
                let tr_entry_id = format!("crush-{idx}-tr{tri}");
                let tr_parent = previous_entry_id.clone();
                entries.push(Entry {
                    id: tr_entry_id.clone(),
                    parent_id: tr_parent,
                });
                previous_entry_id.clone_from(&tr_entry_id);
                messages.push(ConversationMessage::new(
                    tr_entry_id,
                    "user",
                    vec![Part::ToolResult(tr)],
                ));
            }
        }

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

struct CrushMessage {
    role: String,
    parts: Vec<Part>,
    tool_results: Vec<ToolResultData>,
}

fn crush_build_message(role: &str, parts: &Value) -> anyhow::Result<CrushMessage> {
    let parts = parts.as_array().context("parts is not an array")?;

    if role != "assistant" {
        let array_value = Value::Array(parts.clone());
        return Ok(CrushMessage {
            role: role.to_string(),
            parts: vec![Part::Text(extract_text(Some(&array_value)))],
            tool_results: Vec::new(),
        });
    }

    let mut out_parts = Vec::new();
    let mut tool_results = Vec::new();

    for part in parts {
        match part["type"].as_str() {
            Some("text") => {
                if let Some(text) = part["text"].as_str() {
                    out_parts.push(Part::Text(text.to_string()));
                }
            }
            Some("tool_use") => {
                let id = part["id"].as_str().unwrap_or("");
                let name = part["name"].as_str().unwrap_or("");
                let arguments = part.get("input");
                out_parts.push(Part::ToolCall(build_tool_call(id, name, arguments)));
                if let Some(res) = part["result"].as_str() {
                    tool_results.push(build_tool_result(
                        id,
                        name.to_string(),
                        res.to_string(),
                        false,
                    ));
                }
            }
            Some("tool_result") => {
                if let Some(tr) = extract_tool_result_from_part(part) {
                    tool_results.push(tr);
                }
            }
            _ => {}
        }
    }

    Ok(CrushMessage {
        role: "assistant".to_string(),
        parts: out_parts,
        tool_results,
    })
}