use super::{
MessageRole, SessionMessage, SessionProvider, SessionSummary, ShareError, SharedSession,
};
use serde_json::Value;
use std::path::{Path, PathBuf};
const PROVIDER: &str = "opencode";
#[derive(Default)]
pub struct OpenCodeProvider {
root_override: Option<PathBuf>,
}
impl OpenCodeProvider {
pub fn with_root(root: PathBuf) -> Self {
Self { root_override: Some(root) }
}
fn root(&self) -> Option<PathBuf> {
self.root_override
.clone()
.or_else(|| std::env::var("ISELF_OPENCODE_DIR").ok().map(PathBuf::from))
.or_else(|| {
dirs::data_local_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".local").join("share")))
.map(|d| d.join("opencode").join("storage").join("session"))
})
}
}
impl SessionProvider for OpenCodeProvider {
fn name(&self) -> &str {
PROVIDER
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError> {
let root = match self.root() {
Some(r) if r.is_dir() => r,
_ => return Ok(Vec::new()),
};
let info_dir = root.join("info");
if info_dir.is_dir() {
let mut out = Vec::new();
for entry in std::fs::read_dir(&info_dir)?.filter_map(|e| e.ok()) {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let id = match p.file_stem().and_then(|s| s.to_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
if let Some(s) = summarize_modern(&root, &id) {
out.push(s);
}
}
return Ok(out);
}
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let id = match p.file_stem().and_then(|s| s.to_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
if let Some(s) = summarize_jsonl_fallback(&p, &id) {
out.push(s);
}
}
Ok(out)
}
fn load_session(&self, id: &str) -> Result<SharedSession, ShareError> {
let root = self
.root()
.ok_or_else(|| ShareError::NotFound(id.to_string()))?;
let info_path = root.join("info").join(format!("{}.json", id));
let msg_dir = root.join("message").join(id);
if info_path.exists() {
return parse_modern(&root, id);
}
let jsonl = root.join(format!("{}.jsonl", id));
if jsonl.exists() {
return parse_jsonl_fallback(&jsonl, id);
}
if msg_dir.is_dir() {
return parse_modern(&root, id);
}
Err(ShareError::NotFound(id.to_string()))
}
}
fn summarize_modern(root: &Path, id: &str) -> Option<SessionSummary> {
let info_path = root.join("info").join(format!("{}.json", id));
let info: Value = std::fs::read_to_string(&info_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null);
let title = info
.get("title")
.and_then(|t| t.as_str())
.map(|s| s.chars().take(80).collect::<String>());
let started_at = info
.get("created")
.or_else(|| info.get("createdAt"))
.and_then(parse_oc_timestamp);
let project_path = info
.get("cwd")
.or_else(|| info.get("workspace"))
.and_then(|v| v.as_str())
.map(PathBuf::from);
let msg_dir = root.join("message").join(id);
let message_count = if msg_dir.is_dir() {
std::fs::read_dir(&msg_dir)
.ok()
.map(|it| it.filter_map(|e| e.ok()).count())
.unwrap_or(0)
} else {
0
};
Some(SessionSummary {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path,
started_at,
message_count,
title_hint: title,
imported: false,
})
}
fn parse_modern(root: &Path, id: &str) -> Result<SharedSession, ShareError> {
let info_path = root.join("info").join(format!("{}.json", id));
let info: Value = std::fs::read_to_string(&info_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null);
let started_at = info
.get("created")
.or_else(|| info.get("createdAt"))
.and_then(parse_oc_timestamp);
let project_path = info
.get("cwd")
.or_else(|| info.get("workspace"))
.and_then(|v| v.as_str())
.map(PathBuf::from);
let msg_dir = root.join("message").join(id);
let mut messages = Vec::new();
if msg_dir.is_dir() {
let mut paths: Vec<_> = std::fs::read_dir(&msg_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
.collect();
paths.sort();
for p in paths {
if let Ok(content) = std::fs::read_to_string(&p) {
if let Ok(v) = serde_json::from_str::<Value>(&content) {
if let Some(m) = decode_oc_message(&v) {
messages.push(m);
}
}
}
}
}
Ok(SharedSession {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path,
started_at,
messages,
})
}
fn summarize_jsonl_fallback(path: &Path, id: &str) -> Option<SessionSummary> {
let content = std::fs::read_to_string(path).ok()?;
let mut count = 0usize;
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
let mut title_hint: Option<String> = None;
for line in content.lines() {
if let Ok(v) = serde_json::from_str::<Value>(line) {
count += 1;
if started_at.is_none() {
started_at = v
.get("created")
.or_else(|| v.get("timestamp"))
.and_then(parse_oc_timestamp);
}
if title_hint.is_none() {
if let Some(t) = oc_message_text(&v) {
title_hint = Some(t.chars().take(80).collect());
}
}
}
}
Some(SessionSummary {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path: None,
started_at,
message_count: count,
title_hint,
imported: false,
})
}
fn parse_jsonl_fallback(path: &Path, id: &str) -> Result<SharedSession, ShareError> {
let content = std::fs::read_to_string(path)?;
let mut messages = Vec::new();
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
for line in content.lines() {
if let Ok(v) = serde_json::from_str::<Value>(line) {
if started_at.is_none() {
started_at = v
.get("created")
.or_else(|| v.get("timestamp"))
.and_then(parse_oc_timestamp);
}
if let Some(m) = decode_oc_message(&v) {
messages.push(m);
}
}
}
Ok(SharedSession {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path: None,
started_at,
messages,
})
}
fn decode_oc_message(v: &Value) -> Option<SessionMessage> {
let role_str = v.get("role").and_then(|r| r.as_str()).unwrap_or("");
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
let role = match (role_str, ty) {
("user", _) => MessageRole::User,
("assistant", _) => MessageRole::Assistant,
("system", _) => MessageRole::System,
("tool", _) => MessageRole::ToolResult,
(_, "tool") => MessageRole::ToolUse,
_ => return None,
};
let timestamp = v
.get("created")
.or_else(|| v.get("timestamp"))
.and_then(parse_oc_timestamp);
let content = oc_message_text(v)?;
if content.is_empty() {
return None;
}
let mut metadata = std::collections::HashMap::new();
if let Some(m) = v.get("model").and_then(|m| m.as_str()) {
metadata.insert("model".to_string(), m.to_string());
}
if let Some(name) = v.get("name").and_then(|n| n.as_str()) {
metadata.insert("tool_name".to_string(), name.to_string());
}
Some(SessionMessage {
role,
content,
timestamp,
metadata,
})
}
fn oc_message_text(v: &Value) -> Option<String> {
if let Some(s) = v.get("content").and_then(|c| c.as_str()) {
return Some(s.to_string());
}
if let Some(parts) = v.get("parts").and_then(|p| p.as_array()) {
let mut out = Vec::new();
for p in parts {
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
out.push(t.to_string());
}
}
if !out.is_empty() {
return Some(out.join("\n"));
}
}
if let Some(s) = v.get("output").and_then(|o| o.as_str()) {
return Some(s.to_string());
}
None
}
fn parse_oc_timestamp(v: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
if let Some(s) = v.as_str() {
if let Ok(d) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(d.with_timezone(&chrono::Utc));
}
}
if let Some(ms) = v.as_i64() {
if ms > 4_000_000_000 {
return chrono::DateTime::from_timestamp_millis(ms);
}
return chrono::DateTime::from_timestamp(ms, 0);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write(p: &Path, content: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, content).unwrap();
}
#[test]
fn parses_modern_two_dir_layout() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("info").join("s1.json"),
r#"{"title":"refactor","created":"2026-05-07T10:00:00Z","cwd":"/proj"}"#,
);
write(
&tmp.path().join("message").join("s1").join("001-user.json"),
r#"{"role":"user","content":"hi"}"#,
);
write(
&tmp.path().join("message").join("s1").join("002-asst.json"),
r#"{"role":"assistant","parts":[{"type":"text","text":"hello"}]}"#,
);
let p = OpenCodeProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("s1").unwrap();
assert_eq!(s.messages.len(), 2);
assert_eq!(s.messages[0].content, "hi");
assert_eq!(s.messages[1].content, "hello");
assert_eq!(s.project_path.as_ref().unwrap().to_string_lossy(), "/proj");
}
#[test]
fn falls_back_to_jsonl_layout() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("legacy.jsonl"),
r#"{"role":"user","content":"hi"}
{"role":"assistant","content":"hello"}
"#,
);
let p = OpenCodeProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("legacy").unwrap();
assert_eq!(s.messages.len(), 2);
}
#[test]
fn list_sessions_modern_layout() {
let tmp = tempfile::tempdir().unwrap();
write(
&tmp.path().join("info").join("a.json"),
r#"{"title":"A","created":"2026-05-07T10:00:00Z"}"#,
);
write(
&tmp.path().join("message").join("a").join("1.json"),
r#"{"role":"user","content":"q"}"#,
);
write(
&tmp.path().join("info").join("b.json"),
r#"{"title":"B","created":"2026-05-07T11:00:00Z"}"#,
);
write(
&tmp.path().join("message").join("b").join("1.json"),
r#"{"role":"user","content":"q2"}"#,
);
let p = OpenCodeProvider::with_root(tmp.path().to_path_buf());
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 2);
assert!(sessions.iter().any(|s| s.title_hint.as_deref() == Some("A")));
}
#[test]
fn epoch_millisecond_timestamps_decode_correctly() {
assert!(parse_oc_timestamp(&serde_json::json!(1778572800000_i64))
.map(|d| d.format("%Y").to_string() == "2026")
.unwrap_or(false));
}
}