goosedump 0.2.1

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

pub mod codex;
pub mod crush;
pub mod goose;
pub mod jsonl;
pub mod opencode;

use crate::message::{Context, ContextListing, ConversationMessage, Entry};
use std::collections::{HashMap, HashSet};

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

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())
}