goosedump 0.9.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::Client;
use crate::context::ContextReader;
use crate::context::claude::ClaudeReader;
use crate::context::codex::CodexReader;
use crate::context::crush::CrushReader;
use crate::context::gemini::GeminiReader;
use crate::context::goose::GooseReader;
use crate::context::jsonl::JsonlReader;
use crate::context::opencode::OpenCodeReader;
use crate::index::IndexEntry;
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("."))
}

/// Collect every context a provider exposes. File-based providers aggregate
/// one listing per session file; DB-based providers query their store.
pub fn list_provider_contexts(client: Client) -> anyhow::Result<Vec<ContextListing>> {
    match client {
        Client::Claude => {
            let files = find_jsonl_files(&resolve_claude_sessions_dir()?)?;
            Ok(list_file_based(&files, |p| Box::new(ClaudeReader::new(p))))
        }
        Client::Codex => {
            let files = find_jsonl_files(&resolve_codex_sessions_dir()?)?;
            Ok(list_file_based(&files, |p| Box::new(CodexReader::new(p))))
        }
        Client::Opencode => OpenCodeReader::new(resolve_opencode_db()?).list_contexts(),
        Client::Crush => CrushReader::new(resolve_crush_db()?).list_contexts(),
        Client::Pi => {
            let files = find_jsonl_files(&resolve_pi_sessions_dir()?)?;
            Ok(list_file_based(&files, |p| Box::new(JsonlReader::new(p))))
        }
        Client::Gemini => {
            let files = find_gemini_chats(&resolve_gemini_tmp_dir()?)?;
            Ok(list_file_based(&files, |p| Box::new(GeminiReader::new(p))))
        }
        Client::Goose => GooseReader::new(resolve_goose_db()?).list_contexts(),
    }
}

fn open_reader(client: Client, path: PathBuf) -> Box<dyn ContextReader> {
    match client {
        Client::Claude => Box::new(ClaudeReader::new(path)),
        Client::Codex => Box::new(CodexReader::new(path)),
        Client::Opencode => Box::new(OpenCodeReader::new(path)),
        Client::Crush => Box::new(CrushReader::new(path)),
        Client::Pi => Box::new(JsonlReader::new(path)),
        Client::Gemini => Box::new(GeminiReader::new(path)),
        Client::Goose => Box::new(GooseReader::new(path)),
    }
}

/// Open a context directly from an index entry. File-based entries point at the
/// transcript file; SQLite-backed entries point at their provider database and
/// carry the session row id in `id`.
#[must_use]
pub fn open_indexed_context(entry: &IndexEntry) -> Box<dyn ContextReader> {
    open_reader(entry.provider, entry.path.clone())
}

#[must_use]
pub(crate) fn open_listed_context(
    client: Client,
    listing: &ContextListing,
) -> Box<dyn ContextReader> {
    open_reader(client, listing.path.clone())
}

pub(crate) 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()))
    }
}

pub(crate) 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()
        ))
    }
}

pub(crate) 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
}

/// Aggregate one listing per session file for a file-based provider (claude,
/// codex, gemini, pi), sorted newest-first by provider id. Files that fail to parse
/// are skipped so a partial store still lists what is readable.
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
}

/// The `claude/projects` directory `import` writes into, honoring
/// `CLAUDE_CONFIG_DIR`. Returned without an existence check so the writer can
/// create it.
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()
    ))
}

/// The `gemini/tmp` directory `import` writes session subtrees into, honoring
/// `GEMINI_DIR`. Returned without an existence check so the writer can create
/// the `<projectHash>/chats` path beneath it.
pub(crate) fn gemini_tmp_base() -> PathBuf {
    let home = std::env::var("GEMINI_DIR").map_or_else(
        |_| {
            dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join(".gemini")
        },
        PathBuf::from,
    );
    home.join("tmp")
}

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

/// Gemini stores recorded sessions per project under
/// `<tmp>/<project-hash>/chats/`; the sibling `logs.json` keystroke files live
/// directly under the project hash and are deliberately not collected.
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 matches!(
            path.extension().and_then(OsStr::to_str),
            Some("json" | "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"))
}

/// The `codex/sessions` directory `import` writes date-bucketed rollouts into,
/// honoring `CODEX_HOME`. Returned without an existence check so the writer can
/// create the `YYYY/MM/DD` path beneath it.
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"))
}

/// The pi sessions directory `import` writes into, honoring
/// `PI_CODING_AGENT_SESSION_DIR` then `PI_CODING_AGENT_DIR`. Returned without an
/// existence check so the writer can create it.
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(())
}