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