use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use crate::context::{
ContextReader, claude::ClaudeReader, codex::CodexReader, gemini::GeminiReader,
jsonl::JsonlReader,
};
use crate::message::ProviderId;
use crate::{Client, resolver, text};
const VERSION: u32 = 6;
#[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,
#[serde(default)]
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)]
pub enum Filter {
All,
Id {
provider: Option<Client>,
glob: String,
path: Option<String>,
},
}
#[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 Filter {
#[must_use]
pub fn parse(value: Option<&str>, path: Option<&str>) -> Self {
let (provider, glob) = match value {
Some(value) => match value.split_once(':') {
Some((prefix, rest)) if !rest.is_empty() => match Client::from_str(prefix) {
Ok(client) => (Some(client), rest),
Err(_) => (None, value),
},
_ => (None, value),
},
None => (None, "*"),
};
if value.is_none() && path.is_none() {
return Self::All;
}
Self::Id {
provider,
glob: glob.to_string(),
path: path.map(str::to_string),
}
}
}
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 = if path.exists() {
Self::load(&path)?
} else {
Self::empty()
};
let before = index.identity_snapshot();
index.refresh();
if index.identity_snapshot() != before {
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")?;
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
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()?)
}
#[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 entries_with_id(&self, id: &str) -> Vec<&IndexEntry> {
self.entries.iter().filter(|entry| entry.id == id).collect()
}
#[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 {
provider,
glob,
path,
} => {
text::glob_match(glob, &entry.id)
&& provider.is_none_or(|p| p == entry.provider)
&& path.as_ref().is_none_or(|p| {
text::glob_match(p, &entry.provider_id.cwd.to_string_lossy())
})
}
})
.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: &[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()
.flat_map(|id| self.entries_with_id(id))
.map(|e| (e.provider, e.id.clone()))
.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 identity_snapshot(&self) -> Vec<String> {
let mut snapshot: Vec<String> = self
.entries
.iter()
.map(|entry| {
format!(
"{}\0{}\0{}",
entry.provider.as_str(),
entry.id,
entry.path.display()
)
})
.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.v1"))
}
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)
{
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"))
}