supercode-interchange 0.4.19

Canonical, provider-neutral session interchange primitives for Supercode
Documentation
//! Source detection for a session's on-disk form.

use super::*;

// ---- detection ------------------------------------------------------------

pub(super) fn detect_source(text: &str) -> Option<SessionSource> {
    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
    // pretty-printed, MULTI-LINE JSON document, unlike every other format
    // this crate reads. It cannot be recognized by the per-line loop below
    // (no individual line of a pretty-printed document is itself valid
    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
    // attempt: a real JSONL file (many newline-separated objects) fails this
    // parse immediately (trailing-data error) and falls through unaffected.
    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
        if v.get("conversation").and_then(Value::as_array).is_some()
            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
        {
            return Some(SessionSource::Goose);
        }
        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
            return Some(SessionSource::OpenCode);
        }
    }
    for line in non_empty_lines(text) {
        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
        // than abandoning detection — the loaders themselves skip bad lines, so
        // bailing here would silently misroute an otherwise-valid Codex file.
        let Ok(v) = serde_json::from_str::<Value>(line) else {
            continue;
        };
        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
        // one record per line — the synthesized raw-capture unit for the
        // JSON-tree/SQLite generations alike. No other format's lines carry
        // both a top-level `key` ARRAY and a `value` field, so this is
        // unambiguous against Codex/Pi/Claude Code.
        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
            return Some(SessionSource::OpenCode);
        }
        // Codex envelopes always carry a `payload`; Claude Code lines never do.
        if v.get("payload").is_some() {
            return Some(SessionSource::Codex);
        }
        // Gemini CLI starts with an untyped session header. Its project hash
        // and timestamps distinguish it from Claude Code records that also
        // carry `sessionId`.
        if v.get("sessionId").and_then(Value::as_str).is_some()
            && (v.get("projectHash").is_some()
                || v.get("startTime").is_some()
                || v.get("lastUpdated").is_some())
            && v.get("type").is_none()
        {
            return Some(SessionSource::Gemini);
        }
        // Grok's resumable `chat_history.jsonl` stores the role/type and
        // content directly on each record. Claude Code uses a nested
        // `message` envelope for the overlapping `user`/`assistant` tags.
        let tag = v.get("type").and_then(Value::as_str);
        if tag == Some("gemini") && v.get("content").is_some() {
            return Some(SessionSource::Gemini);
        }
        if v.get("message").is_none()
            && v.get("uuid").is_none()
            && v.get("sessionId").is_none()
            && matches!(
                tag,
                Some(
                    "system"
                        | "user"
                        | "assistant"
                        | "tool_result"
                        | "reasoning"
                        | "backend_tool_call"
                )
            )
            && (v.get("content").is_some()
                || v.get("tool_calls").is_some()
                || v.get("tool_call_id").is_some()
                || v.get("encrypted_content").is_some()
                || v.get("kind").is_some())
        {
            return Some(SessionSource::Grok);
        }
        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
        // (the session id) with no `message`/`uuid` — Claude Code's own
        // `type`-bearing lines always carry one or the other, never a
        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
        // §1).
        if v.get("type").and_then(Value::as_str) == Some("session")
            && v.get("id").and_then(Value::as_str).is_some()
            && v.get("message").is_none()
            && v.get("uuid").is_none()
        {
            // OpenClaw (>= 2026.7) writes pi-v3 with an IDENTICAL header; the
            // dialect discriminants live in the body: a `type:"leaf"`
            // navigation entry, or a vendor-namespaced `__openclaw` object on
            // a message payload. Structural (parse-level) checks — a session
            // merely DISCUSSING openclaw in text content never matches.
            for body in non_empty_lines(text) {
                let Ok(entry) = serde_json::from_str::<Value>(body) else {
                    continue;
                };
                match entry.get("type").and_then(Value::as_str) {
                    Some("leaf") if entry.get("targetId").is_some() => {
                        return Some(SessionSource::OpenClaw);
                    }
                    Some("message")
                        if entry
                            .get("message")
                            .and_then(|message| message.get("__openclaw"))
                            .is_some() =>
                    {
                        return Some(SessionSource::OpenClaw);
                    }
                    _ => {}
                }
            }
            return Some(SessionSource::Pi);
        }
        if v.get("type").is_some() || v.get("message").is_some() {
            return Some(SessionSource::ClaudeCode);
        }
    }
    None
}

/// Which on-disk OpenCode storage surface is present under a data root
/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
/// generation A. This is a **filesystem classifier only** — it answers
/// "which generation is this?" for a corpus-discovery tool; it does not
/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
/// for the envelope form any of these three surfaces synthesizes into, and
/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
/// round-trips via the JSON store per upstream's own behavior even on a
/// SQLite install, so nothing is silently lost by not reading the legacy
/// trees directly).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenCodeStorageSurface {
    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
    /// [`opencode_sqlite_corpus_envelope_text`].
    Sqlite,
    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
    /// marker file `storage/migration`.
    JsonTreeB,
    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
    JsonTreeA,
}

/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
/// storage surface present, per the discovery rules frozen in
/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
/// tree generation-B marker (`storage/migration`); otherwise generation-A's
/// `project/` subtree. Returns `None` if nothing is found.
pub fn detect_opencode_storage_surface(
    data_root: &Path,
) -> Option<(OpenCodeStorageSurface, PathBuf)> {
    if let Ok(p) = std::env::var("OPENCODE_DB") {
        let pb = PathBuf::from(p);
        if pb.is_file() {
            return Some((OpenCodeStorageSurface::Sqlite, pb));
        }
    }
    if let Ok(entries) = std::fs::read_dir(data_root) {
        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
        // NOT deterministic — a store with both a default-channel
        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
        // are legal, e.g. after switching install channels) previously
        // returned "whichever the OS happened to list first", which could
        // differ between two `inspect`/`audit`/`convert` runs against the
        // exact same directory. Collect every `opencode*.db` candidate and
        // pick deterministically: the exact `opencode.db` name wins if
        // present (the default/most-common channel); otherwise the
        // lexicographically-smallest match, so repeated runs always agree.
        let mut candidates: Vec<PathBuf> = entries
            .flatten()
            .map(|entry| entry.path())
            .filter(|p| {
                p.file_name()
                    .and_then(|n| n.to_str())
                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
            })
            .collect();
        candidates.sort();
        if let Some(exact) = candidates
            .iter()
            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
        {
            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
        }
        if let Some(first) = candidates.into_iter().next() {
            return Some((OpenCodeStorageSurface::Sqlite, first));
        }
    }
    let storage = data_root.join("storage");
    if storage.join("migration").is_file() {
        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
    }
    let project_dir = data_root.join("project");
    if project_dir.is_dir() {
        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
    }
    None
}

/// First 16 bytes of every SQLite database file — the format's own magic,
/// independent of file extension.
const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";

/// Whether `path` should be routed to the OpenCode SQLite loader instead of
/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
/// the SQLite magic, OR its extension is `.db` — the latter so a
/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
/// A non-existent path is NOT considered SQLite here — the missing-file
/// diagnostic in that case comes from the normal load path (`with_context`
/// at the CLI call sites), which already names the path clearly.
pub fn looks_like_sqlite(path: &Path) -> bool {
    if !path.is_file() {
        return false;
    }
    if path.extension().and_then(|e| e.to_str()) == Some("db") {
        return true;
    }
    use std::io::Read;
    let Ok(mut f) = std::fs::File::open(path) else {
        return false;
    };
    let mut buf = [0u8; 16];
    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
}