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