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::engine::message::ProviderId;
use crate::engine::{Client, StorageKind, 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,
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>,
}
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();
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")?;
let tmp = path.with_extension(format!("tmp.{}", Uuid::new_v4()));
fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?;
if let Err(error) = atomic_replace(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(error)
.with_context(|| format!("replace {} with {}", path.display(), tmp.display()));
}
Ok(())
}
pub fn save_default(&self) -> anyhow::Result<()> {
self.save(&cache_path()?)
}
#[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::engine::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
}
#[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
}
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();
}
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();
let by_id: HashSet<(Client, String)> = self
.entries
.iter()
.map(|entry| (entry.provider, entry.id.clone()))
.collect();
for entry in &mut self.entries {
if entry.parent_id.is_some() {
continue;
}
if let Some(path) = parent_file_path(entry)
&& let Some(id) = by_path.get(&(entry.provider, path))
{
entry.parent_id = Some(id.clone());
continue;
}
if let Some(id) = ancestor_session_id(&entry.path, entry.provider, &by_id) {
entry.parent_id = Some(id);
continue;
}
if entry.provider == Client::Pi {
entry.parent_id = pi_parent_id_from_path(&entry.path);
}
}
}
fn rebuild_lookup(&mut self) {
self.by_id = self
.entries
.iter()
.enumerate()
.map(|(idx, entry)| ((entry.provider, entry.id.clone()), idx))
.collect();
}
}
#[cfg(not(windows))]
fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
fs::rename(source, destination)
}
#[cfg(windows)]
fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt as _;
use windows_sys::Win32::Storage::FileSystem::{REPLACEFILE_WRITE_THROUGH, ReplaceFileW};
if !destination.exists() {
return fs::rename(source, destination);
}
let destination: Vec<u16> = destination
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let source: Vec<u16> = source
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
let replaced = unsafe {
ReplaceFileW(
destination.as_ptr(),
source.as_ptr(),
std::ptr::null(),
REPLACEFILE_WRITE_THROUGH,
std::ptr::null(),
std::ptr::null(),
)
};
if replaced == 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
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.storage(), StorageKind::Sqlite) {
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 Some(files) = provider.session_files().and_then(Result::ok) 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)
{
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 = provider.open_context(file.clone());
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_file_path(entry: &IndexEntry) -> Option<PathBuf> {
match entry.provider {
Client::Claude => claude_parent_path(&entry.path),
Client::Pi => pi_legacy_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_legacy_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"))
}
fn ancestor_session_id(
path: &Path,
provider: Client,
by_id: &HashSet<(Client, String)>,
) -> Option<String> {
for component in path.components().rev().skip(1) {
let Some(name) = component.as_os_str().to_str() else {
continue;
};
if by_id.contains(&(provider, name.to_string())) {
return Some(name.to_string());
}
}
None
}
fn pi_parent_id_from_path(path: &Path) -> Option<String> {
let run_dir = path.parent()?;
let parent_dir = run_dir.parent()?;
let parent_name = parent_dir.file_name()?.to_str()?;
if parent_name.starts_with("--") && parent_name.ends_with("--") {
return None;
}
let grand_parent = parent_dir.parent()?;
let grand_name = grand_parent.file_name()?.to_str()?;
if grand_name == "sessions" || grand_name == "subagents" {
return Some(parent_name.to_string());
}
let great_grand_parent = grand_parent.parent()?;
let great_grand_name = great_grand_parent.file_name()?.to_str()?;
if great_grand_name == "sessions" || great_grand_name == "subagents" {
return Some(parent_name.to_string());
}
None
}