goosedump 0.6.5

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

//! Persist a context into a provider's native store — the write half of
//! [`crate::format::render`]. File-based providers (claude/codex/gemini/pi)
//! receive the rendered native file at the path their reader scans; the
//! SQLite-backed providers (goose/opencode/crush) receive the rendered row
//! projection inserted into the live store. Each import is given a fresh
//! destination-native id, so a session can be copied into another provider
//! and, paired with `delete`, moved.

use std::fs;
use std::path::Path;

use anyhow::Context as _;
use chrono::Utc;
use rusqlite::Connection;
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;

use crate::Client;
use crate::format;
use crate::message::Context;
use crate::resolver;

/// Write `ctx` into `client`'s store under a fresh destination-native id and
/// return that id as `list` reports it (the value behind the `<provider>:<id>`
/// tag).
pub fn import_context(client: Client, ctx: &Context) -> anyhow::Result<String> {
    match client {
        Client::Claude => import_claude(ctx),
        Client::Codex => import_codex(ctx),
        Client::Pi => import_pi(ctx),
        Client::Gemini => import_gemini(ctx),
        Client::Goose => import_goose(ctx),
        Client::Opencode => import_opencode(ctx),
        Client::Crush => import_crush(ctx),
    }
}

fn new_id() -> String {
    Uuid::new_v4().to_string()
}

fn render(client: Client, ctx: &Context, id: &str) -> String {
    format::render(client, &ctx.entries, &ctx.messages, id, ctx.cwd.as_deref())
}

/// The working directory recorded in the context, falling back to the process
/// cwd for formats whose on-disk layout is keyed by it (claude, gemini).
fn context_cwd(ctx: &Context) -> String {
    ctx.cwd.clone().unwrap_or_else(|| {
        std::env::current_dir().map_or_else(|_| String::new(), |p| p.display().to_string())
    })
}

fn write_file(path: &Path, contents: &str) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
    }
    fs::write(path, contents).with_context(|| format!("write {}", path.display()))
}

/// Monotonic stamp `base + idx` that preserves row order without an `as` cast.
fn stamp(base: i64, idx: usize) -> i64 {
    base.saturating_add(i64::try_from(idx).unwrap_or_default())
}

fn import_claude(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let body = render(Client::Claude, ctx, &id);
    let path = resolver::claude_projects_base()
        .join(encode_project_dir(&context_cwd(ctx)))
        .join(format!("{id}.jsonl"));
    write_file(&path, &body)?;
    Ok(id)
}

/// Claude names each project directory by replacing every non-alphanumeric
/// character of the absolute cwd with `-` (`/tmp/project` -> `-tmp-project`).
fn encode_project_dir(cwd: &str) -> String {
    cwd.chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
        .collect()
}

fn import_codex(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let body = render(Client::Codex, ctx, &id);
    let now = Utc::now();
    let path = resolver::codex_sessions_base()
        .join(now.format("%Y").to_string())
        .join(now.format("%m").to_string())
        .join(now.format("%d").to_string())
        .join(format!(
            "rollout-{}-{id}.jsonl",
            now.format("%Y-%m-%dT%H-%M-%S")
        ));
    write_file(&path, &body)?;
    Ok(id)
}

fn import_pi(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let body = render(Client::Pi, ctx, &id);
    let path = resolver::pi_sessions_base().join(format!("{id}.jsonl"));
    write_file(&path, &body)?;
    Ok(id)
}

fn import_gemini(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let stem = format!("session-{id}");
    let hash = gemini_project_hash(&context_cwd(ctx));
    let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();

    // render_gemini emits {"sessionId":…, "messages":[…]}; gemini-cli keys
    // session discovery on the projectHash directory and shows start/last
    // timestamps, so inject those fields before writing.
    let mut doc: Value = serde_json::from_str(&render(Client::Gemini, ctx, &id))
        .context("re-parse gemini render")?;
    if let Some(obj) = doc.as_object_mut() {
        obj.insert("projectHash".to_string(), Value::String(hash.clone()));
        obj.insert("startTime".to_string(), Value::String(now.clone()));
        obj.insert("lastUpdated".to_string(), Value::String(now));
    }
    let body = serde_json::to_string_pretty(&doc).context("serialize gemini")? + "\n";

    let path = resolver::gemini_tmp_base()
        .join(&hash)
        .join("chats")
        .join(format!("{stem}.json"));
    write_file(&path, &body)?;
    Ok(stem)
}

/// The session's `projectHash`: the hex SHA-256 of the project root path, as
/// gemini-cli computes it (`getProjectHash`). This also names the per-project
/// `tmp/<hash>/` subtree in gemini's original layout — which `find_gemini_chats`
/// scans regardless. (Recent gemini-cli renames that directory to a basename
/// slug tracked in `~/.gemini/projects.json`; registering there is out of scope,
/// so on those installs the import is goosedump-readable but not surfaced by
/// gemini's own resume list.)
fn gemini_project_hash(cwd: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(cwd.as_bytes());
    format!("{:x}", hasher.finalize())
}

fn import_goose(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let doc: Value =
        serde_json::from_str(&render(Client::Goose, ctx, &id)).context("re-parse goose render")?;
    let mut conn = Connection::open(resolver::resolve_goose_db()?).context("open goose db")?;
    let tx = conn.transaction()?;
    let session = &doc["session"];
    let now = Utc::now();
    tx.execute(
        "INSERT INTO sessions(id, name, working_dir, updated_at) VALUES(?1, ?2, ?3, ?4)",
        rusqlite::params![
            id,
            session["name"].as_str().unwrap_or(""),
            session["working_dir"].as_str().unwrap_or(""),
            now.to_rfc3339(),
        ],
    )?;
    let base = now.timestamp_millis();
    for (idx, row) in array(&doc, "messages").iter().enumerate() {
        tx.execute(
            "INSERT INTO messages(message_id, session_id, role, content_json, created_timestamp) \
             VALUES(?1, ?2, ?3, ?4, ?5)",
            rusqlite::params![
                row["message_id"].as_str().unwrap_or(""),
                id,
                row["role"].as_str().unwrap_or(""),
                serde_json::to_string(&row["content_json"])?,
                stamp(base, idx),
            ],
        )?;
    }
    tx.commit()?;
    Ok(id)
}

fn import_opencode(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let doc: Value = serde_json::from_str(&render(Client::Opencode, ctx, &id))
        .context("re-parse opencode render")?;
    let mut conn =
        Connection::open(resolver::resolve_opencode_db()?).context("open opencode db")?;
    let tx = conn.transaction()?;
    let base = Utc::now().timestamp_millis();
    for (idx, row) in array(&doc, "messages").iter().enumerate() {
        tx.execute(
            "INSERT INTO message(session_id, id, data, time_created) VALUES(?1, ?2, ?3, ?4)",
            rusqlite::params![
                id,
                row["id"].as_str().unwrap_or(""),
                serde_json::to_string(&row["data"])?,
                stamp(base, idx),
            ],
        )?;
    }
    for (idx, row) in array(&doc, "parts").iter().enumerate() {
        tx.execute(
            "INSERT INTO part(session_id, message_id, data, time_created) VALUES(?1, ?2, ?3, ?4)",
            rusqlite::params![
                id,
                row["message_id"].as_str().unwrap_or(""),
                serde_json::to_string(&row["data"])?,
                stamp(base, idx),
            ],
        )?;
    }
    tx.commit()?;
    Ok(id)
}

fn import_crush(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let doc: Value =
        serde_json::from_str(&render(Client::Crush, ctx, &id)).context("re-parse crush render")?;
    let mut conn = Connection::open(resolver::resolve_crush_db()?).context("open crush db")?;
    let tx = conn.transaction()?;
    for row in array(&doc, "messages") {
        tx.execute(
            "INSERT INTO messages(session_id, role, parts, created_at) VALUES(?1, ?2, ?3, ?4)",
            rusqlite::params![
                id,
                row["role"].as_str().unwrap_or(""),
                serde_json::to_string(&row["parts"])?,
                row["created_at"].as_str().unwrap_or(""),
            ],
        )?;
    }
    tx.commit()?;
    Ok(id)
}

fn array<'a>(doc: &'a Value, key: &str) -> &'a [Value] {
    doc[key].as_array().map_or(&[], Vec::as_slice)
}