goosedump 0.7.3

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;

/// A context to import into a destination provider.
pub struct Importer<'a> {
    pub client: Client,
    pub ctx: &'a Context,
}

impl TryFrom<Importer<'_>> for String {
    type Error = anyhow::Error;

    /// Write `ctx` into `client`'s store under a fresh destination-native id
    /// and return that id.
    fn try_from(imp: Importer<'_>) -> anyhow::Result<String> {
        let Importer { client, ctx } = imp;
        match client {
            Client::Claude => write_claude(ctx),
            Client::Codex => write_codex(ctx),
            Client::Pi => write_pi(ctx),
            Client::Gemini => write_gemini(ctx),
            Client::Goose => write_goose(ctx),
            Client::Opencode => write_opencode(ctx),
            Client::Crush => write_crush(ctx),
        }
    }
}

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

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

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

fn stamp(base: i64, idx: usize) -> i64 {
    base.saturating_add(i64::try_from(idx).unwrap_or_default())
}

fn write_claude(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let body = rendered(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)
}

fn encode_project_dir(cwd: &str) -> String {
    cwd.chars()
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
        .collect()
}

fn write_codex(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let body = rendered(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 write_pi(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let body = rendered(Client::Pi, ctx, &id);
    let path = resolver::pi_sessions_base().join(format!("{id}.jsonl"));
    write_file(&path, &body)?;
    Ok(id)
}

fn write_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();

    let mut doc: Value = serde_json::from_str(&rendered(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(&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`).
fn gemini_project_hash(cwd: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(cwd.as_bytes());
    format!("{:x}", hasher.finalize())
}

fn write_goose(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let doc: Value =
        serde_json::from_str(&rendered(Client::Goose, ctx, &id)).context("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, title, working_dir, created_at, updated_at, message_count) \
         VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
        rusqlite::params![
            id,
            session["name"].as_str().unwrap_or(""),
            session["working_dir"].as_str().unwrap_or(""),
            now.timestamp_millis(),
            now.timestamp_millis(),
            i64::try_from(array(&doc, "messages").len()).unwrap_or(0),
        ],
    )?;
    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 write_opencode(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let doc: Value =
        serde_json::from_str(&rendered(Client::Opencode, ctx, &id)).context("opencode render")?;
    let mut conn =
        Connection::open(resolver::resolve_opencode_db()?).context("open opencode db")?;
    let has_session: bool = conn
        .prepare("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'session'")
        .and_then(|mut stmt| stmt.query_row([], |row| row.get::<_, i64>(0)))
        .is_ok_and(|count| count > 0);

    let tx = conn.transaction()?;
    let now = Utc::now().timestamp_millis();

    if has_session {
        let session = &doc["session"];
        tx.execute(
            "INSERT INTO session(id, directory, title, time_created, time_updated) VALUES(?1, ?2, ?3, ?4, ?5)",
            rusqlite::params![
                id,
                session["directory"].as_str().unwrap_or(""),
                "",
                now,
                now,
            ],
        )?;
    }

    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(now, 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(now, idx),
            ],
        )?;
    }
    tx.commit()?;
    Ok(id)
}

fn write_crush(ctx: &Context) -> anyhow::Result<String> {
    let id = new_id();
    let doc: Value =
        serde_json::from_str(&rendered(Client::Crush, ctx, &id)).context("crush render")?;
    let mut conn = Connection::open(resolver::resolve_crush_db()?).context("open crush db")?;
    let tx = conn.transaction()?;
    let now = Utc::now().timestamp_millis();
    let count = i64::try_from(array(&doc, "messages").len()).unwrap_or(0);
    tx.execute(
        "INSERT INTO sessions(id, title, message_count, created_at, updated_at) VALUES(?1, ?2, ?3, ?4, ?5)",
        rusqlite::params![id, "", count, now, now],
    )?;
    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)
}