use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use crate::engine::Client;
use crate::engine::behavior::{Claude, Codex, Crush, Gemini, Goose, Opencode, Pi};
use crate::engine::context::ContextReader;
use crate::engine::context::claude::ClaudeReader;
use crate::engine::context::codex::CodexReader;
use crate::engine::context::crush::CrushReader;
use crate::engine::context::gemini::GeminiReader;
use crate::engine::context::goose::GooseReader;
use crate::engine::context::jsonl::JsonlReader;
use crate::engine::context::opencode::OpenCodeReader;
use crate::engine::index::IndexEntry;
use crate::engine::message::ContextListing;
fn goosedump_data_dir_override() -> Option<PathBuf> {
std::env::var_os("GOOSEDUMP_DATA_DIR")
.map(PathBuf::from)
.filter(|path| path.is_dir())
}
fn opencode_data_dir() -> PathBuf {
goosedump_data_dir_override()
.unwrap_or_else(|| {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local")
.join("share")
})
})
.join("opencode")
}
fn goose_data_dir_from(
goosedump_data_dir: Option<PathBuf>,
goose_path_root: Option<PathBuf>,
platform_data_dir: PathBuf,
) -> PathBuf {
if let Some(data_dir) = goosedump_data_dir {
return data_dir.join("goose");
}
if let Some(root) = goose_path_root.filter(|path| path.is_absolute()) {
return root.join("data");
}
platform_data_dir
}
#[cfg(not(windows))]
fn goose_platform_data_dir() -> PathBuf {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local")
.join("share")
})
.join("goose")
}
#[cfg(windows)]
fn goose_platform_data_dir() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Block")
.join("goose")
.join("data")
}
fn goose_data_dir() -> PathBuf {
goose_data_dir_from(
goosedump_data_dir_override(),
std::env::var_os("GOOSE_PATH_ROOT").map(PathBuf::from),
goose_platform_data_dir(),
)
}
pub(crate) trait ProviderStore {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader>;
fn session_files(&self) -> Option<anyhow::Result<Vec<PathBuf>>> {
None
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>>;
}
impl ProviderStore for Claude {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(ClaudeReader::new(path))
}
fn session_files(&self) -> Option<anyhow::Result<Vec<PathBuf>>> {
Some(resolve_claude_sessions_dir().and_then(|d| find_jsonl_files(&d)))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
list_file_store(self)
}
}
impl ProviderStore for Codex {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(CodexReader::new(path))
}
fn session_files(&self) -> Option<anyhow::Result<Vec<PathBuf>>> {
Some(resolve_codex_sessions_dir().and_then(|d| find_jsonl_files(&d)))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
list_file_store(self)
}
}
impl ProviderStore for Pi {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(JsonlReader::new(path))
}
fn session_files(&self) -> Option<anyhow::Result<Vec<PathBuf>>> {
Some(resolve_pi_sessions_dir().and_then(|d| find_jsonl_files(&d)))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
list_file_store(self)
}
}
impl ProviderStore for Gemini {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(GeminiReader::new(path))
}
fn session_files(&self) -> Option<anyhow::Result<Vec<PathBuf>>> {
Some(resolve_gemini_tmp_dir().and_then(|d| find_gemini_chats(&d)))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
list_file_store(self)
}
}
impl ProviderStore for Goose {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(GooseReader::new(path))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
GooseReader::new(resolve_goose_db()?).list_contexts()
}
}
impl ProviderStore for Crush {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(CrushReader::new(path))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
CrushReader::new(resolve_crush_db()?).list_contexts()
}
}
impl ProviderStore for Opencode {
fn open_context(&self, path: PathBuf) -> Box<dyn ContextReader> {
Box::new(OpenCodeReader::new(path))
}
fn list_contexts(&self) -> anyhow::Result<Vec<ContextListing>> {
OpenCodeReader::new(resolve_opencode_db()?).list_contexts()
}
}
fn list_file_store(store: &dyn ProviderStore) -> anyhow::Result<Vec<ContextListing>> {
let files = store
.session_files()
.transpose()?
.context("file storage provider does not expose session files")?;
Ok(list_file_based(&files, |p| store.open_context(p)))
}
impl Client {
pub(crate) fn store(self) -> &'static dyn ProviderStore {
match self {
Self::Claude => &Claude,
Self::Codex => &Codex,
Self::Crush => &Crush,
Self::Gemini => &Gemini,
Self::Goose => &Goose,
Self::Opencode => &Opencode,
Self::Pi => &Pi,
}
}
#[must_use]
pub(crate) fn open_context(self, path: PathBuf) -> Box<dyn ContextReader> {
self.store().open_context(path)
}
pub(crate) fn session_files(self) -> Option<anyhow::Result<Vec<PathBuf>>> {
self.store().session_files()
}
}
pub fn list_provider_contexts(client: Client) -> anyhow::Result<Vec<ContextListing>> {
client.store().list_contexts()
}
#[must_use]
pub fn open_indexed_context(entry: &IndexEntry) -> Box<dyn ContextReader> {
entry.provider.open_context(entry.path.clone())
}
#[must_use]
pub(crate) fn open_listed_context(
client: Client,
listing: &ContextListing,
) -> Box<dyn ContextReader> {
client.open_context(listing.path.clone())
}
pub(crate) fn resolve_opencode_db() -> anyhow::Result<PathBuf> {
let data_dir = opencode_data_dir();
let db = opencode_db_from(&data_dir, std::env::var_os("OPENCODE_DB").as_deref());
if db == Path::new(":memory:") {
return Err(anyhow::anyhow!(
"cannot inspect OpenCode's in-memory session database"
));
}
if db.exists() {
Ok(db)
} else {
Err(anyhow::anyhow!(
"OpenCode database not found at {}",
db.display()
))
}
}
fn opencode_db_from(data_dir: &Path, configured: Option<&OsStr>) -> PathBuf {
let Some(configured) = configured.filter(|value| !value.is_empty()) else {
return data_dir.join("opencode.db");
};
let configured = Path::new(configured);
if configured == Path::new(":memory:") || configured.is_absolute() {
configured.to_path_buf()
} else {
data_dir.join(configured)
}
}
pub(crate) fn resolve_goose_db() -> anyhow::Result<PathBuf> {
let db = goose_data_dir().join("sessions").join("sessions.db");
if db.exists() {
Ok(db)
} else {
Err(anyhow::anyhow!(
"goose sessions.db not found at {}",
db.display()
))
}
}
pub(crate) fn resolve_crush_db() -> anyhow::Result<PathBuf> {
let cwd = std::env::current_dir().context("cwd")?;
let boundary = crush_project_boundary(&cwd);
let config_paths = crush_config_paths(&cwd, &boundary);
if let Some(data_dir) = configured_crush_data_dir(&cwd, &config_paths)? {
let db = data_dir.join("crush.db");
if db.exists() {
return Ok(db);
}
return Err(anyhow::anyhow!("crush.db not found at {}", db.display()));
}
let mut current = cwd.as_path();
loop {
let db = current.join(".crush").join("crush.db");
if db.exists() {
return Ok(db);
}
if current == boundary {
break;
}
let Some(parent) = current.parent() else {
break;
};
current = parent;
}
Err(anyhow::anyhow!("crush.db not found"))
}
fn crush_project_boundary(cwd: &Path) -> PathBuf {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output();
let Ok(output) = output else {
return cwd.to_path_buf();
};
if !output.status.success() {
return cwd.to_path_buf();
}
let root = String::from_utf8_lossy(&output.stdout);
let root = PathBuf::from(root.trim());
if root.is_absolute() && cwd.starts_with(&root) {
root
} else {
cwd.to_path_buf()
}
}
fn crush_config_paths(cwd: &Path, boundary: &Path) -> Vec<PathBuf> {
let mut paths = crush_global_config_paths();
let mut directories = Vec::new();
let mut current = cwd;
loop {
directories.push(current);
if current == boundary {
break;
}
let Some(parent) = current.parent() else {
break;
};
current = parent;
}
for directory in directories.into_iter().rev() {
paths.push(directory.join("crush.json"));
paths.push(directory.join(".crush.json"));
}
paths
}
fn crush_global_config_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
#[cfg(not(windows))]
paths.push(PathBuf::from("/etc/crush/crush.json"));
let config = std::env::var_os("CRUSH_GLOBAL_CONFIG")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.map(|root| root.join("crush"))
})
.map(|root| root.join("crush.json"));
let data = std::env::var_os("CRUSH_GLOBAL_DATA")
.map(PathBuf::from)
.or_else(crush_data_root)
.map(|root| root.join("crush.json"));
paths.extend(config);
paths.extend(data);
paths
}
#[cfg(not(windows))]
fn crush_data_root() -> Option<PathBuf> {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(|| dirs::home_dir().map(|home| home.join(".local").join("share")))
.map(|root| root.join("crush"))
}
#[cfg(windows)]
fn crush_data_root() -> Option<PathBuf> {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.filter(|path| path.is_absolute())
.or_else(dirs::data_local_dir)
.map(|root| root.join("crush"))
}
fn configured_crush_data_dir(
cwd: &Path,
config_paths: &[PathBuf],
) -> anyhow::Result<Option<PathBuf>> {
let mut configured = None;
for config_path in config_paths.iter().filter(|path| path.is_file()) {
let contents = fs::read_to_string(config_path)
.with_context(|| format!("read {}", config_path.display()))?;
let config: serde_json::Value = serde_json::from_str(&contents)
.with_context(|| format!("parse {}", config_path.display()))?;
if let Some(value) = config.pointer("/options/data_directory") {
let path = value.as_str().with_context(|| {
format!(
"{}.options.data_directory must be a string",
config_path.display()
)
})?;
configured = (!path.is_empty()).then(|| PathBuf::from(path));
}
}
Ok(configured.map(|path| {
if path.is_absolute() {
path
} else {
cwd.join(path)
}
}))
}
fn list_file_based(
files: &[PathBuf],
make_reader: impl Fn(PathBuf) -> Box<dyn ContextReader>,
) -> Vec<ContextListing> {
let mut listings = Vec::new();
for file in files {
if let Ok(mut l) = make_reader(file.clone()).list_contexts() {
listings.append(&mut l);
}
}
listings.sort_by_key(|b| std::cmp::Reverse(b.provider_id.from));
listings
}
pub(crate) fn claude_projects_base() -> PathBuf {
let config_dir = std::env::var("CLAUDE_CONFIG_DIR").map_or_else(
|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
},
PathBuf::from,
);
config_dir.join("projects")
}
pub(crate) fn resolve_claude_sessions_dir() -> anyhow::Result<PathBuf> {
let projects = claude_projects_base();
if projects.is_dir() {
return Ok(projects);
}
Err(anyhow::anyhow!(
"claude projects directory not found at {}",
projects.display()
))
}
pub(crate) fn gemini_tmp_base() -> PathBuf {
gemini_global_dir().join("tmp")
}
pub(crate) fn gemini_projects_registry() -> PathBuf {
gemini_global_dir().join("projects.json")
}
fn gemini_global_dir() -> PathBuf {
let home = std::env::var("GEMINI_CLI_HOME").map_or_else(
|_| dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
PathBuf::from,
);
home.join(".gemini")
}
pub(crate) fn resolve_gemini_tmp_dir() -> anyhow::Result<PathBuf> {
let tmp = gemini_tmp_base();
if tmp.is_dir() {
return Ok(tmp);
}
Err(anyhow::anyhow!(
"gemini tmp directory not found at {}",
tmp.display()
))
}
pub(crate) fn find_gemini_chats(tmp_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let entries =
fs::read_dir(tmp_dir).with_context(|| format!("read dir {}", tmp_dir.display()))?;
for entry in entries {
let entry = entry?;
let chats = entry.path().join("chats");
if chats.is_dir() {
collect_json_files(&chats, &mut files)
.with_context(|| format!("read dir {}", chats.display()))?;
}
}
files.sort();
Ok(files)
}
fn collect_json_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_json_files(&path, files)?;
} else if path.extension().and_then(OsStr::to_str) == Some("jsonl") {
files.push(path);
}
}
Ok(())
}
pub(crate) fn resolve_codex_sessions_dir() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("CODEX_HOME") {
let sessions = PathBuf::from(&dir).join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let sessions = home.join(".codex").join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
Err(anyhow::anyhow!("codex sessions directory not found"))
}
pub(crate) fn codex_sessions_base() -> PathBuf {
if let Ok(dir) = std::env::var("CODEX_HOME") {
return PathBuf::from(dir).join("sessions");
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".codex")
.join("sessions")
}
pub(crate) fn resolve_pi_sessions_dir() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("PI_CODING_AGENT_SESSION_DIR") {
let path = PathBuf::from(&dir);
if path.is_dir() {
return Ok(path);
}
}
let agent_dir = std::env::var("PI_CODING_AGENT_DIR").unwrap_or_else(|_| {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".pi/agent").display().to_string()
});
let sessions = PathBuf::from(&agent_dir).join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
Err(anyhow::anyhow!("pi sessions directory not found"))
}
pub(crate) fn pi_sessions_base() -> PathBuf {
if let Ok(dir) = std::env::var("PI_CODING_AGENT_SESSION_DIR") {
return PathBuf::from(dir);
}
let agent_dir = std::env::var("PI_CODING_AGENT_DIR").unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".pi/agent")
.display()
.to_string()
});
PathBuf::from(agent_dir).join("sessions")
}
pub(crate) fn find_jsonl_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_jsonl_files(dir, &mut files).with_context(|| format!("read dir {}", dir.display()))?;
files.sort();
Ok(files)
}
fn collect_jsonl_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_jsonl_files(&path, files)?;
} else if path.extension() == Some(OsStr::new("jsonl")) {
files.push(path);
}
}
Ok(())
}