goosedump 0.12.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 uuid::Uuid;

use crate::context::{
    ContextReader, claude::ClaudeReader, codex::CodexReader, gemini::GeminiReader,
    jsonl::JsonlReader,
};
use crate::message::ProviderId;
use crate::{Client, resolver};
const VERSION: u32 = 7;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
    pub id: String,
    pub parent_id: Option<String>,
    pub path: PathBuf,
    pub provider: Client,
    pub provider_id: ProviderId,
    /// Change-detection signature of the backing session file, so an
    /// incremental refresh can skip re-reading it when unchanged.
    sig: Option<u64>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Index {
    version: u32,
    generated_at: String,
    entries: Vec<IndexEntry>,
    #[serde(skip)]
    by_id: HashMap<(Client, String), usize>,
}
#[derive(Debug)]
struct ScannedEntry {
    path: PathBuf,
    provider: Client,
    id: String,
    parent_id: Option<String>,
    provider_id: ProviderId,
    sig: Option<u64>,
}

/// A previously-scanned file listing, reused by an incremental refresh when the
/// backing file's signature is unchanged.
struct CachedListing {
    sig: Option<u64>,
    id: String,
    parent_id: Option<String>,
    provider_id: ProviderId,
}

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

    pub fn load_or_refresh() -> anyhow::Result<Self> {
        let path = cache_path()?;
        let (mut index, rebuilt) = if path.exists() {
            Self::load_with_status(&path)?
        } else {
            (Self::empty(), false)
        };
        let before = index.cache_snapshot();
        index.refresh();
        // An incompatible cache is replaced even when there are no sessions to
        // discover. Otherwise an invalid file would be parsed on every run.
        if rebuilt || index.cache_snapshot() != before {
            index.save(&path)?;
        }
        Ok(index)
    }

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

    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 = serde_json::to_vec(self).context("encode session index")?;
        // Write to a unique temp file then atomically rename, so a crash or a
        // concurrent reader never observes a torn cache.
        let tmp = path.with_extension(format!("tmp.{}", Uuid::new_v4()));
        fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?;
        fs::rename(&tmp, path)
            .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))
    }

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

    /// Exact lookup of a context by `(provider, id)`. Two indexed contexts
    /// never share that pair: a provider-qualified target resolves to one
    /// entry or none, with no cross-provider shadow.
    #[must_use]
    pub fn lookup_entry(&self, provider: Client, id: &str) -> Option<&IndexEntry> {
        self.by_id
            .get(&(provider, id.to_string()))
            .map(|&idx| &self.entries[idx])
    }

    #[must_use]
    pub fn filter(&self, filter: Option<&crate::query::QueryFilter>) -> Vec<&IndexEntry> {
        let mut out: Vec<&IndexEntry> = self
            .entries
            .iter()
            .filter(|entry| filter.is_none_or(|q| q.matches(entry)))
            .collect();
        out.sort_by(|a, b| {
            a.provider
                .as_str()
                .cmp(b.provider.as_str())
                .then_with(|| a.id.cmp(&b.id))
        });
        out
    }

    /// Collect the `(provider, id)` of every entry that is a descendant
    /// (child, grandchild, ...) of any provider-qualified root, excluding the
    /// roots themselves. Parent links are provider-scoped, so a parent id never
    /// crosses providers even when two providers share an id.
    #[must_use]
    pub fn descendants_of(&self, roots: &[(Client, String)]) -> Vec<(Client, String)> {
        let mut children_by_parent: HashMap<(Client, String), Vec<(Client, String)>> =
            HashMap::new();
        for entry in &self.entries {
            if let Some(parent) = &entry.parent_id {
                children_by_parent
                    .entry((entry.provider, parent.clone()))
                    .or_default()
                    .push((entry.provider, entry.id.clone()));
            }
        }

        let mut seen: HashSet<(Client, String)> = roots.iter().cloned().collect();
        let mut out = Vec::new();
        let mut stack: Vec<(Client, String)> = seen.iter().cloned().collect();
        while let Some((provider, id)) = stack.pop() {
            if let Some(children) = children_by_parent.get(&(provider, id)) {
                for child in children {
                    if seen.insert(child.clone()) {
                        out.push(child.clone());
                        stack.push(child.clone());
                    }
                }
            }
        }
        out
    }

    /// Remove exactly the `(provider, id)` entry, in O(1) via `by_id`. Never
    /// touches an unrelated entry that happens to share the id in another
    /// provider.
    pub fn remove_entry(&mut self, provider: Client, id: &str) -> Option<IndexEntry> {
        let idx = *self.by_id.get(&(provider, id.to_string()))?;
        let entry = self.entries.remove(idx);
        self.refresh_parent_ids();
        self.rebuild_lookup();
        Some(entry)
    }

    fn refresh(&mut self) {
        let cache: HashMap<(Client, PathBuf), CachedListing> = self
            .entries
            .iter()
            .map(|entry| {
                (
                    (entry.provider, entry.path.clone()),
                    CachedListing {
                        sig: entry.sig,
                        id: entry.id.clone(),
                        parent_id: entry.parent_id.clone(),
                        provider_id: entry.provider_id.clone(),
                    },
                )
            })
            .collect();

        let scanned = scan_entries(&cache);
        let mut entries = Vec::with_capacity(scanned.len());
        for scan in scanned {
            entries.push(IndexEntry {
                id: scan.id,
                parent_id: scan.parent_id,
                path: scan.path,
                provider: scan.provider,
                provider_id: scan.provider_id,
                sig: scan.sig,
            });
        }

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

    /// A stable, order-independent snapshot of cached listings used to decide
    /// whether the on-disk cache needs rewriting.
    fn cache_snapshot(&self) -> Vec<String> {
        let mut snapshot: Vec<String> = self
            .entries
            .iter()
            .map(|entry| {
                format!(
                    "{}\0{}\0{}\0{:?}\0{:?}",
                    entry.provider.as_str(),
                    entry.id,
                    entry.path.display(),
                    entry.parent_id,
                    entry.sig,
                )
            })
            .collect();
        snapshot.sort();
        snapshot
    }

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

        for entry in &mut self.entries {
            if entry.parent_id.is_none() {
                entry.parent_id = parent_path(entry)
                    .and_then(|path| by_path.get(&(entry.provider, path)).cloned());
            }
        }
    }

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

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

fn scan_entries(cache: &HashMap<(Client, PathBuf), CachedListing>) -> Vec<ScannedEntry> {
    let mut out = Vec::new();
    for provider in Client::ALL {
        if matches!(provider, Client::Goose | Client::Crush | Client::Opencode) {
            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,
                    id: listing.id,
                    parent_id: listing.parent_id,
                    provider_id: listing.provider_id,
                    sig: None,
                });
            }
            continue;
        }

        let files = match provider {
            Client::Claude => resolver::resolve_claude_sessions_dir()
                .ok()
                .and_then(|d| resolver::find_jsonl_files(&d).ok()),
            Client::Codex => resolver::resolve_codex_sessions_dir()
                .ok()
                .and_then(|d| resolver::find_jsonl_files(&d).ok()),
            Client::Pi => resolver::resolve_pi_sessions_dir()
                .ok()
                .and_then(|d| resolver::find_jsonl_files(&d).ok()),
            Client::Gemini => resolver::resolve_gemini_tmp_dir()
                .ok()
                .and_then(|d| resolver::find_gemini_chats(&d).ok()),
            Client::Goose | Client::Crush | Client::Opencode => Some(Vec::new()),
        };
        let Some(files) = files else {
            continue;
        };
        for file in files {
            let path = fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
            let sig = (|| {
                let meta = fs::metadata(&path).ok()?;
                let secs = meta
                    .modified()
                    .ok()?
                    .duration_since(std::time::UNIX_EPOCH)
                    .ok()?
                    .as_secs();
                Some(
                    secs.wrapping_mul(1_099_511_628_211)
                        .wrapping_add(meta.len()),
                )
            })();
            if let (Some(sig), Some(cached)) = (sig, cache.get(&(provider, path.clone())))
                && cached.sig == Some(sig)
            {
                // Unchanged since the last scan: reuse the cached listing
                // instead of re-reading and re-parsing the file.
                out.push(ScannedEntry {
                    path,
                    provider,
                    id: cached.id.clone(),
                    parent_id: cached.parent_id.clone(),
                    provider_id: cached.provider_id.clone(),
                    sig: Some(sig),
                });
                continue;
            }
            if let Some(listing) = (|| {
                let reader: Box<dyn ContextReader> = match provider {
                    Client::Claude => Box::new(ClaudeReader::new(file.clone())),
                    Client::Codex => Box::new(CodexReader::new(file.clone())),
                    Client::Pi => Box::new(JsonlReader::new(file.clone())),
                    Client::Gemini => Box::new(GeminiReader::new(file.clone())),
                    Client::Goose | Client::Crush | Client::Opencode => return None,
                };
                reader.list_contexts().ok()?.into_iter().next()
            })() {
                out.push(ScannedEntry {
                    path,
                    provider,
                    id: listing.id,
                    parent_id: listing.parent_id,
                    provider_id: listing.provider_id,
                    sig,
                });
            }
        }
    }
    out.sort_by(|a, b| {
        a.provider
            .as_str()
            .cmp(b.provider.as_str())
            .then_with(|| a.path.cmp(&b.path))
            .then_with(|| a.id.cmp(&b.id))
    });
    out
}

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