use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use crate::context::ContextReader;
use crate::context::crush::CrushReader;
use crate::context::goose::GooseReader;
use crate::context::jsonl::JsonlReader;
use crate::context::opencode::OpenCodeReader;
use crate::message::ContextListing;
type ResolveResult = anyhow::Result<(Box<dyn ContextReader>, Option<Vec<ContextListing>>)>;
pub fn resolve_client(client: &str, context_id: Option<&str>) -> ResolveResult {
match client {
"opencode" => {
let db_path = resolve_opencode_db()?;
let reader = OpenCodeReader::new(db_path);
if let Some(_id) = context_id {
Ok((Box::new(reader), None))
} else {
let listings = reader.list_contexts()?;
Ok((Box::new(reader), Some(listings)))
}
}
"crush" => {
let db_path = resolve_crush_db()?;
let reader = CrushReader::new(db_path);
if let Some(_id) = context_id {
Ok((Box::new(reader), None))
} else {
let listings = reader.list_contexts()?;
Ok((Box::new(reader), Some(listings)))
}
}
"pi" => {
let sessions_dir = resolve_pi_sessions_dir()?;
let jsonl_files = find_jsonl_files(&sessions_dir)?;
if jsonl_files.is_empty() {
if let Some(id) = context_id {
return Err(anyhow::anyhow!("context '{id}' not found"));
}
return Ok((Box::new(JsonlReader::empty()), Some(Vec::new())));
}
if let Some(id) = context_id {
let file_path = resolve_jsonl_file(&jsonl_files, id)?;
Ok((Box::new(JsonlReader::new(file_path)), None))
} else {
let mut listings = Vec::new();
for file in &jsonl_files {
let reader = JsonlReader::new(file.clone());
if let Ok(mut l) = reader.list_contexts() {
listings.append(&mut l);
}
}
Ok((
Box::new(JsonlReader::new(jsonl_files[0].clone())),
Some(listings),
))
}
}
"goose" => {
let db_path = resolve_goose_db()?;
let reader = GooseReader::new(db_path);
if let Some(_id) = context_id {
Ok((Box::new(reader), None))
} else {
let listings = reader.list_contexts()?;
Ok((Box::new(reader), Some(listings)))
}
}
_ => Err(anyhow::anyhow!(
"goosedump: client must be crush, goose, opencode, or pi\n\n{USAGE}"
)),
}
}
fn resolve_opencode_db() -> anyhow::Result<PathBuf> {
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
format!("{home}/.local/share")
});
let db = PathBuf::from(xdg_data).join("opencode").join("opencode.db");
if db.exists() {
Ok(db)
} else {
Err(anyhow::anyhow!("opencode.db not found at {}", db.display()))
}
}
fn resolve_goose_db() -> anyhow::Result<PathBuf> {
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
format!("{home}/.local/share")
});
let db = PathBuf::from(xdg_data)
.join("goose")
.join("sessions")
.join("sessions.db");
if db.exists() {
Ok(db)
} else {
Err(anyhow::anyhow!(
"goose sessions.db not found at {}",
db.display()
))
}
}
fn resolve_crush_db() -> anyhow::Result<PathBuf> {
let cwd = std::env::current_dir().context("cwd")?;
if let Some(config_path) = find_crush_config(&cwd) {
let config: serde_json::Value = {
let contents = fs::read_to_string(&config_path)
.with_context(|| format!("read {}", config_path.display()))?;
serde_json::from_str(&contents)?
};
let data_dir = config["options"]["data_directory"]
.as_str()
.or_else(|| config["data_directory"].as_str());
if let Some(dir) = data_dir {
let db = config_path
.parent()
.unwrap_or(Path::new("."))
.join(dir)
.join("crush.db");
if db.exists() {
return Ok(db);
}
}
}
Err(anyhow::anyhow!("crush.db not found"))
}
fn find_crush_config(cwd: &Path) -> Option<PathBuf> {
let mut current = Some(cwd.to_path_buf());
while let Some(dir) = current {
for name in &[".crush.json", "crush.json"] {
let path = dir.join(name);
if path.exists() {
return Some(path);
}
}
current = dir.parent().map(std::path::Path::to_path_buf);
}
None
}
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 = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
format!("{home}/.pi/agent")
});
let sessions = PathBuf::from(&agent_dir).join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
Err(anyhow::anyhow!("pi sessions directory not found"))
}
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(())
}
fn resolve_jsonl_file(files: &[PathBuf], context_id: &str) -> anyhow::Result<PathBuf> {
for file in files {
let reader = JsonlReader::new(file.clone());
if let Ok(listings) = reader.list_contexts()
&& listings.iter().any(|l| l.id == context_id)
{
return Ok(file.clone());
}
}
let prefix_matches: Vec<&PathBuf> = files
.iter()
.filter(|f| {
let reader = JsonlReader::new((*f).clone());
reader
.list_contexts()
.is_ok_and(|listings| listings.iter().any(|l| l.id.starts_with(context_id)))
})
.collect();
match prefix_matches.len() {
0 => Err(anyhow::anyhow!("context '{context_id}' not found")),
1 => Ok(prefix_matches[0].clone()),
_ => Err(anyhow::anyhow!("ambiguous context id '{context_id}'")),
}
}
pub const USAGE: &str = "Usage: goosedump <client> [<context-id>] [options]";