goosedump 0.2.2

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::Context as _;

use crate::context::ContextReader;
use crate::context::codex::CodexReader;
use crate::context::crush::CrushReader;
use crate::context::goose::GooseReader;
use crate::context::jsonl::JsonlReader;
use crate::context::opencode::OpenCodeReader;
use crate::message::ContextListing;

fn data_dir_override() -> PathBuf {
    if let Ok(dir) = std::env::var("GOOSEDUMP_DATA_DIR") {
        let path = PathBuf::from(&dir);
        if path.is_dir() {
            return path;
        }
    }
    dirs::data_dir().unwrap_or_else(|| PathBuf::from("."))
}

type ResolveResult = anyhow::Result<(Box<dyn ContextReader>, Option<Vec<ContextListing>>)>;

pub fn resolve_client(client: &str, context_id: Option<&str>) -> ResolveResult {
    match client {
        "codex" => {
            let sessions_dir = resolve_codex_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(CodexReader::empty()), Some(Vec::new())));
            }
            if let Some(id) = context_id {
                let file_path = resolve_codex_file(&jsonl_files, id)?;
                Ok((Box::new(CodexReader::new(file_path)), None))
            } else {
                let mut listings = Vec::new();
                for file in &jsonl_files {
                    let reader = CodexReader::new(file.clone());
                    if let Ok(mut l) = reader.list_contexts() {
                        listings.append(&mut l);
                    }
                }
                listings.sort_by(|a, b| b.detail.cmp(&a.detail));
                Ok((Box::new(CodexReader::empty()), Some(listings)))
            }
        }
        "opencode" => {
            let db_path = resolve_opencode_db()?;
            let reader = OpenCodeReader::new(db_path);
            if context_id.is_some() {
                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 context_id.is_some() {
                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::empty()), Some(listings)))
            }
        }
        "goose" => {
            let db_path = resolve_goose_db()?;
            let reader = GooseReader::new(db_path);
            if context_id.is_some() {
                Ok((Box::new(reader), None))
            } else {
                let listings = reader.list_contexts()?;
                Ok((Box::new(reader), Some(listings)))
            }
        }
        _ => Err(anyhow::anyhow!(
            "goosedump: client must be codex, crush, goose, opencode, or pi\n\n{USAGE}"
        )),
    }
}

fn resolve_opencode_db() -> anyhow::Result<PathBuf> {
    let data_dir = data_dir_override();
    let db = data_dir.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 data_dir = data_dir_override();
    let db = data_dir.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 {
        if let Some(path) = [".crush.json", "crush.json"]
            .into_iter()
            .map(|name| dir.join(name))
            .find(|path| path.exists())
        {
            return Some(path);
        }
        current = dir.parent().map(std::path::Path::to_path_buf);
    }
    None
}

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

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

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}'")),
    }
}

fn resolve_codex_file(files: &[PathBuf], context_id: &str) -> anyhow::Result<PathBuf> {
    for file in files {
        let reader = CodexReader::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 = CodexReader::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]";