goosedump 0.9.8

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

pub mod claude;
pub mod codex;
pub mod crush;
pub mod gemini;
pub mod goose;
pub mod ir;
pub mod jsonl;
pub mod opencode;

use crate::message::{Context, ContextListing, ConversationMessage, Entry, Part};
use anyhow::Context as _;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;

pub trait ContextReader {
    fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>>;
    fn read_context(&self, context_id: &str) -> anyhow::Result<Context>;
    fn delete_context(&self, context_id: &str) -> anyhow::Result<()>;
}

/// Parse each non-empty line of a JSONL file into a [`Value`] and pass it to
/// `f` with its zero-based line number. Read and parse failures carry
/// `path:line:` context.
pub fn for_each_jsonl_record(
    file_path: &Path,
    mut f: impl FnMut(usize, &Value),
) -> anyhow::Result<()> {
    let file =
        fs::File::open(file_path).with_context(|| format!("open {}", file_path.display()))?;
    let mut reader = BufReader::new(file);

    // Decode each line lossily so a stray non-UTF-8 byte does not abort the read,
    // and skip a line that fails to parse (a corrupt or still-being-written
    // record) rather than dropping the rest of the session.
    let mut buf = Vec::new();
    let mut line_num = 0;
    loop {
        buf.clear();
        let read = reader
            .read_until(b'\n', &mut buf)
            .with_context(|| format!("{}: read error", file_path.display()))?;
        if read == 0 {
            break;
        }
        let line = String::from_utf8_lossy(&buf);
        let trimmed = line.trim();
        if !trimmed.is_empty()
            && let Ok(value) = serde_json::from_str::<Value>(trimmed)
        {
            f(line_num, &value);
        }
        line_num += 1;
    }

    Ok(())
}

/// Open a provider `SQLite` database with a busy timeout so a store the owning
/// app has locked is waited on briefly rather than failing outright.
pub fn open_sqlite(path: &Path) -> anyhow::Result<rusqlite::Connection> {
    let conn =
        rusqlite::Connection::open(path).with_context(|| format!("open {}", path.display()))?;
    let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
    Ok(conn)
}

/// A canonical, id- and timestamp-free projection of a context's messages,
/// used by `import` to detect whether a destination provider already holds the
/// same conversation. Volatile correlation ids, entry ids, and timestamps are
/// excluded so the fingerprint survives a render/re-read round-trip (and a
/// cross-provider conversion), making a repeated import a no-op.
#[must_use]
pub fn content_fingerprint(ctx: &Context) -> String {
    let rows: Vec<Value> = ctx
        .messages
        .iter()
        .map(|m| {
            let parts: Vec<Value> = m.parts.iter().map(part_fingerprint).collect();
            json!([m.role, parts])
        })
        .collect();
    serde_json::to_string(&rows).unwrap_or_default()
}

/// Canonical, id- and signature-free fingerprint of a single content part.
fn part_fingerprint(part: &Part) -> Value {
    match part {
        Part::Text(text) => json!(["text", text]),
        Part::Reasoning(reasoning) => json!(["reasoning", reasoning.text]),
        Part::ToolCall(call) => json!(["tool_call", call.name, call.arguments]),
        Part::ToolResult(result) => {
            json!([
                "tool_result",
                result.tool_name,
                result.content,
                result.is_error
            ])
        }
        Part::Image(image) => json!(["image", image.mime_type, image.data]),
        Part::Bash(bash) => json!(["bash", bash.command, bash.output]),
        Part::Passthrough(passthrough) => json!(["passthrough", passthrough.kind]),
    }
}

pub fn active_lineage_ids(entries: &[Entry]) -> Vec<String> {
    let parent_map: HashMap<&str, &str> = entries
        .iter()
        .filter(|e| !e.parent_id.is_empty())
        .map(|e| (e.id.as_str(), e.parent_id.as_str()))
        .collect();

    let child_ids: HashSet<&str> = parent_map.values().copied().collect();
    let mut leaves: Vec<&str> = entries
        .iter()
        .map(|e| e.id.as_str())
        .filter(|id| !child_ids.contains(id))
        .collect();
    leaves.sort_by_key(|&id| entries.iter().position(|e| e.id == id));

    let mut active_ids = Vec::new();
    for leaf in leaves {
        let mut current = leaf;
        let mut visited = HashSet::new();
        active_ids.push(current.to_string());
        while let Some(&parent) = parent_map.get(current) {
            if parent.is_empty() || !visited.insert(parent) {
                break;
            }
            active_ids.push(parent.to_string());
            current = parent;
        }
    }
    active_ids
}

pub fn filter_messages(
    messages: Vec<ConversationMessage>,
    ids: &[String],
) -> Vec<ConversationMessage> {
    if ids.is_empty() {
        return messages;
    }
    let id_set: HashSet<&str> = ids.iter().map(std::string::String::as_str).collect();
    messages
        .into_iter()
        .filter(|m| id_set.contains(m.entry_id.as_str()))
        .collect()
}

pub fn filter_messages_range(
    messages: Vec<ConversationMessage>,
    from: Option<&str>,
    until: Option<&str>,
) -> Result<Vec<ConversationMessage>, &'static str> {
    let start = match from {
        Some(entry_id) => messages
            .iter()
            .position(|message| message.entry_id == entry_id)
            .ok_or("goosedump: --from <from> not found")?,
        None => 0,
    };
    let end = match until {
        Some(entry_id) => messages
            .iter()
            .position(|message| message.entry_id == entry_id)
            .ok_or("goosedump: --until <until> not found")?,
        None => messages.len(),
    };

    if start > end {
        return Err("goosedump: --from precedes --until");
    }

    Ok(messages.into_iter().skip(start).take(end - start).collect())
}