goosedump 0.11.2

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 Some(active) = entries.last() else {
        return Vec::new();
    };
    let parent_map: HashMap<&str, &str> = entries
        .iter()
        .filter(|entry| !entry.parent_id.is_empty())
        .map(|entry| (entry.id.as_str(), entry.parent_id.as_str()))
        .collect();
    let mut active_ids = Vec::new();
    let mut visited = HashSet::new();
    let mut current = active.id.as_str();
    while visited.insert(current) {
        active_ids.push(current.to_string());
        let Some(parent) = parent_map.get(current).copied() else {
            break;
        };
        current = parent;
    }
    active_ids
}

pub fn filter_entries(entries: Vec<Entry>, ids: &[String]) -> Vec<Entry> {
    if ids.is_empty() {
        return entries;
    }
    let id_set: HashSet<&str> = ids.iter().map(std::string::String::as_str).collect();
    entries
        .into_iter()
        .filter(|entry| id_set.contains(entry.id.as_str()))
        .collect()
}

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_context_range(
    mut context: Context,
    from: Option<&str>,
    until: Option<&str>,
) -> Result<Context, String> {
    let start = match from {
        Some(entry_id) => context
            .entries
            .iter()
            .position(|entry| entry.id == entry_id)
            .ok_or_else(|| format!("goosedump: --from '{entry_id}' not found"))?,
        None => 0,
    };
    let end = match until {
        Some(entry_id) => context
            .entries
            .iter()
            .position(|entry| entry.id == entry_id)
            .ok_or_else(|| format!("goosedump: --until '{entry_id}' not found"))?,
        None => context.entries.len(),
    };
    if start > end {
        return Err("goosedump: --from precedes --until".to_string());
    }

    context.entries = context.entries[start..end].to_vec();
    let ids: HashSet<&str> = context
        .entries
        .iter()
        .map(|entry| entry.id.as_str())
        .collect();
    context
        .messages
        .retain(|message| ids.contains(message.entry_id.as_str()));
    Ok(context)
}