goosedump 0.7.3

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

use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::Context as _;
use serde::{Deserialize, Serialize};

use crate::message::ProviderId;
use crate::{Client, resolver, text};

const VERSION: u32 = 3;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
    pub id: u32,
    pub parent_id: Option<u32>,
    pub path: PathBuf,
    pub provider: Client,
    pub native_id: String,
    pub provider_id: ProviderId,
    #[serde(skip, default)]
    parent_native_id: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Index {
    version: u32,
    generated_at: String,
    next_id: u32,
    free_ids: Vec<u32>,
    entries: Vec<IndexEntry>,
    #[serde(skip)]
    by_id: HashMap<u32, usize>,
}

#[derive(Debug)]
pub enum Filter {
    All,
    Id(String),
}

#[derive(Debug)]
struct ScannedEntry {
    path: PathBuf,
    provider: Client,
    native_id: String,
    provider_id: ProviderId,
    parent_native_id: Option<String>,
}

impl Filter {
    #[must_use]
    pub fn parse(value: Option<&str>) -> Self {
        let Some(value) = value else {
            return Self::All;
        };
        Self::Id(value.to_string())
    }
}

impl Index {
    #[must_use]
    pub fn empty() -> Self {
        Self {
            version: VERSION,
            generated_at: String::new(),
            next_id: 0,
            free_ids: Vec::new(),
            entries: Vec::new(),
            by_id: HashMap::new(),
        }
    }

    pub fn load_or_refresh() -> anyhow::Result<Self> {
        let path = cache_path()?;
        let mut index = if path.exists() {
            Self::load(&path)?
        } else {
            Self::empty()
        };
        index.refresh();
        index.save(&path)?;
        Ok(index)
    }

    pub fn load(path: &Path) -> anyhow::Result<Self> {
        let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
        let Ok(mut index) = bincode::deserialize::<Self>(&bytes) else {
            return Ok(Self::empty());
        };
        if index.version != VERSION {
            return Ok(Self::empty());
        }
        index.rebuild_lookup();
        Ok(index)
    }

    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
        }
        let bytes = bincode::serialize(self).context("encode session index")?;
        fs::write(path, bytes).with_context(|| format!("write {}", path.display()))
    }

    pub fn save_default(&self) -> anyhow::Result<()> {
        self.save(&cache_path()?)
    }

    #[must_use]
    pub fn lookup(&self, id: u32) -> Option<&IndexEntry> {
        self.by_id.get(&id).map(|&idx| &self.entries[idx])
    }

    #[must_use]
    pub fn filter(&self, filter: &Filter) -> Vec<&IndexEntry> {
        let mut out: Vec<&IndexEntry> = self
            .entries
            .iter()
            .filter(|entry| match filter {
                Filter::All => true,
                Filter::Id(glob) => text::glob_match(glob, &format_id(entry.id)),
            })
            .collect();
        out.sort_by_key(|entry| entry.id);
        out
    }

    /// Collect the ids of every entry that is a descendant (child,
    /// grandchild, ...) of any of the given ids, excluding the given ids
    /// themselves.
    #[must_use]
    pub fn descendants_of(&self, roots: &[u32]) -> Vec<u32> {
        let mut children_by_parent: HashMap<u32, Vec<u32>> = HashMap::new();
        for entry in &self.entries {
            if let Some(parent) = entry.parent_id {
                children_by_parent.entry(parent).or_default().push(entry.id);
            }
        }

        let mut seen: HashSet<u32> = roots.iter().copied().collect();
        let mut out = Vec::new();
        let mut stack: Vec<u32> = roots.to_vec();
        while let Some(id) = stack.pop() {
            if let Some(children) = children_by_parent.get(&id) {
                for child in children {
                    if seen.insert(*child) {
                        out.push(*child);
                        stack.push(*child);
                    }
                }
            }
        }
        out
    }

    pub fn remove(&mut self, id: u32) -> Option<IndexEntry> {
        let idx = *self.by_id.get(&id)?;
        let entry = self.entries.remove(idx);
        self.free(id);
        self.refresh_parent_ids();
        self.rebuild_lookup();
        Some(entry)
    }

    fn refresh(&mut self) {
        let scanned = scan_entries();
        let scanned_keys: HashSet<String> = scanned
            .iter()
            .map(|s| key(s.provider, &s.path, &s.native_id))
            .collect();
        let mut retained = HashMap::new();

        for entry in std::mem::take(&mut self.entries) {
            let ek = key(entry.provider, &entry.path, &entry.native_id);
            if scanned_keys.contains(&ek) {
                retained.insert(ek, entry);
            } else {
                self.free(entry.id);
            }
        }

        let mut entries = Vec::with_capacity(scanned.len());
        for scan in scanned {
            let sk = key(scan.provider, &scan.path, &scan.native_id);
            let id = retained
                .remove(&sk)
                .map_or_else(|| self.allocate(), |entry| entry.id);
            entries.push(IndexEntry {
                id,
                parent_id: None,
                path: scan.path,
                provider: scan.provider,
                native_id: scan.native_id,
                provider_id: scan.provider_id,
                parent_native_id: scan.parent_native_id,
            });
        }

        self.entries = entries;
        self.generated_at = chrono::Utc::now().to_rfc3339();
        self.refresh_parent_ids();
        self.rebuild_lookup();
    }

    fn allocate(&mut self) -> u32 {
        if !self.free_ids.is_empty() {
            return self.free_ids.remove(0);
        }
        let id = self.next_id;
        self.next_id = self.next_id.saturating_add(1);
        id
    }

    fn free(&mut self, id: u32) {
        match self.free_ids.binary_search(&id) {
            Ok(_) => {}
            Err(pos) => self.free_ids.insert(pos, id),
        }
    }

    fn refresh_parent_ids(&mut self) {
        let by_path: HashMap<(Client, PathBuf), u32> = self
            .entries
            .iter()
            .map(|entry| ((entry.provider, entry.path.clone()), entry.id))
            .collect();

        let by_native: HashMap<(Client, String), u32> = self
            .entries
            .iter()
            .map(|entry| ((entry.provider, entry.native_id.clone()), entry.id))
            .collect();

        for entry in &mut self.entries {
            entry.parent_id = parent_path(entry)
                .and_then(|path| by_path.get(&(entry.provider, path)).copied())
                .or_else(|| {
                    entry
                        .parent_native_id
                        .as_ref()
                        .and_then(|pnid| by_native.get(&(entry.provider, pnid.clone())).copied())
                });
        }
    }

    fn rebuild_lookup(&mut self) {
        self.by_id = self
            .entries
            .iter()
            .enumerate()
            .map(|(idx, entry)| (entry.id, idx))
            .collect();
    }
}

pub fn parse_id(value: &str) -> anyhow::Result<u32> {
    if value.len() != 8 || !value.chars().all(|c| c.is_ascii_hexdigit()) {
        anyhow::bail!("invalid session id '{value}'");
    }
    u32::from_str_radix(value, 16).with_context(|| format!("invalid session id '{value}'"))
}

#[must_use]
pub fn format_id(id: u32) -> String {
    format!("{id:08x}")
}

fn cache_path() -> anyhow::Result<PathBuf> {
    let dir = dirs::cache_dir().context("cache directory not found")?;
    Ok(dir.join("goosedump").join("index.v1"))
}

fn scan_entries() -> Vec<ScannedEntry> {
    let mut out = Vec::new();
    for provider in Client::ALL {
        let Ok(listings) = resolver::list_provider_contexts(provider) else {
            continue;
        };
        for listing in listings {
            let path = fs::canonicalize(&listing.path).unwrap_or(listing.path);
            out.push(ScannedEntry {
                path,
                provider,
                native_id: listing.native_id,
                provider_id: listing.provider_id,
                parent_native_id: listing.parent_native_id,
            });
        }
    }
    out.sort_by(|a, b| {
        a.provider
            .as_str()
            .cmp(b.provider.as_str())
            .then_with(|| a.path.cmp(&b.path))
            .then_with(|| a.native_id.cmp(&b.native_id))
    });
    out
}

fn key(provider: Client, path: &Path, native_id: &str) -> String {
    format!("{}\0{}\0{}", provider.as_str(), path.display(), native_id)
}

fn parent_path(entry: &IndexEntry) -> Option<PathBuf> {
    match entry.provider {
        Client::Claude => claude_parent_path(&entry.path),
        Client::Pi => pi_parent_path(&entry.path),
        _ => None,
    }
}

fn claude_parent_path(path: &Path) -> Option<PathBuf> {
    let subagents = path.parent()?;
    if subagents.file_name()? != "subagents" {
        return None;
    }
    Some(subagents.parent()?.with_extension("jsonl"))
}

fn pi_parent_path(path: &Path) -> Option<PathBuf> {
    if path.file_stem()? != "session" {
        return None;
    }
    let run_dir = path.parent()?;
    let run_group = run_dir.parent()?;
    Some(run_group.parent()?.with_extension("jsonl"))
}