1use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12pub fn opencode_storage_dir() -> Option<PathBuf> {
14 directories::BaseDirs::new().map(|b| b.data_local_dir().join("opencode").join("storage"))
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct OpenCodeSession {
20 pub id: String,
21 pub version: String,
22 #[serde(rename = "projectID")]
23 pub project_id: String,
24 pub directory: String,
25 pub title: String,
26 pub time: OpenCodeTime,
27 #[serde(default)]
28 pub summary: Option<OpenCodeSummary>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct OpenCodeTime {
33 pub created: i64,
34 pub updated: i64,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct OpenCodeSummary {
39 #[serde(default)]
40 pub additions: i64,
41 #[serde(default)]
42 pub deletions: i64,
43 #[serde(default)]
44 pub files: i64,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct OpenCodeMessage {
50 pub id: String,
51 #[serde(rename = "sessionID")]
52 pub session_id: String,
53 pub role: String,
54 pub time: OpenCodeMessageTime,
55 #[serde(default)]
56 pub summary: Option<OpenCodeMessageSummary>,
57 #[serde(rename = "parentID", default)]
58 pub parent_id: Option<String>,
59 #[serde(rename = "modelID", default)]
60 pub model_id: Option<String>,
61 #[serde(rename = "providerID", default)]
62 pub provider_id: Option<String>,
63 #[serde(default)]
64 pub mode: Option<String>,
65 #[serde(default)]
66 pub path: Option<OpenCodeMessagePath>,
67 #[serde(default)]
68 pub cost: Option<f64>,
69 #[serde(default)]
70 pub tokens: Option<OpenCodeTokens>,
71 #[serde(default)]
72 pub agent: Option<String>,
73 #[serde(default)]
74 pub model: Option<OpenCodeModel>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct OpenCodeMessageTime {
79 pub created: i64,
80 #[serde(rename = "completed", default)]
81 pub completed: Option<i64>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct OpenCodeMessageSummary {
86 #[serde(default)]
87 pub title: Option<String>,
88 #[serde(default)]
89 pub diffs: Vec<serde_json::Value>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct OpenCodeMessagePath {
94 pub cwd: String,
95 pub root: String,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct OpenCodeTokens {
100 #[serde(default)]
101 pub input: i64,
102 #[serde(default)]
103 pub output: i64,
104 #[serde(default)]
105 pub reasoning: i64,
106 #[serde(default)]
107 pub cache: Option<OpenCodeCacheTokens>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct OpenCodeCacheTokens {
112 #[serde(default)]
113 pub read: i64,
114 #[serde(default)]
115 pub write: i64,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct OpenCodeModel {
120 #[serde(rename = "providerID")]
121 pub provider_id: String,
122 #[serde(rename = "modelID")]
123 pub model_id: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct OpenCodePart {
129 pub id: String,
130 #[serde(rename = "sessionID")]
131 pub session_id: String,
132 #[serde(rename = "messageID")]
133 pub message_id: String,
134 #[serde(rename = "type")]
135 pub part_type: String,
136 #[serde(default)]
137 pub text: Option<String>,
138 #[serde(default)]
139 pub time: Option<OpenCodePartTime>,
140 #[serde(rename = "toolCallID", default)]
142 pub tool_call_id: Option<String>,
143 #[serde(default)]
144 pub name: Option<String>,
145 #[serde(default)]
146 pub arguments: Option<String>,
147 #[serde(default)]
149 pub content: Option<String>,
150 #[serde(default)]
151 pub error: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct OpenCodePartTime {
156 #[serde(default)]
157 pub start: Option<i64>,
158 #[serde(default)]
159 pub end: Option<i64>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct OpenCodeTodo {
165 pub id: String,
166 pub content: String,
167 pub status: String,
168 pub priority: String,
169}
170
171#[derive(Debug, Clone)]
173pub struct OpenCodeSessionSummary {
174 pub id: String,
175 pub title: String,
176 pub directory: String,
177 pub created_at: DateTime<Utc>,
178 pub updated_at: DateTime<Utc>,
179 pub message_count: usize,
180}
181
182pub struct OpenCodeStorage {
184 storage_dir: PathBuf,
185}
186
187impl OpenCodeStorage {
188 pub fn new() -> Option<Self> {
190 opencode_storage_dir().map(|dir| Self { storage_dir: dir })
191 }
192
193 pub fn with_path(path: impl AsRef<Path>) -> Self {
195 Self {
196 storage_dir: path.as_ref().to_path_buf(),
197 }
198 }
199
200 pub fn exists(&self) -> bool {
202 self.storage_dir.exists()
203 }
204
205 pub fn path(&self) -> &Path {
207 &self.storage_dir
208 }
209
210 pub async fn list_sessions(&self) -> Result<Vec<OpenCodeSessionSummary>> {
212 let session_dir = self.storage_dir.join("session");
213
214 if !session_dir.exists() {
215 return Ok(Vec::new());
216 }
217
218 let mut summaries = Vec::new();
219 let mut entries = fs::read_dir(&session_dir).await?;
220
221 while let Some(entry) = entries.next_entry().await? {
222 let path = entry.path();
223 if path.extension().map(|e| e == "json").unwrap_or(false) {
224 if let Ok(content) = fs::read_to_string(&path).await {
225 if let Ok(session) = serde_json::from_str::<OpenCodeSession>(&content) {
226 let message_count = self.count_messages(&session.id).await.unwrap_or(0);
227
228 summaries.push(OpenCodeSessionSummary {
229 id: session.id.clone(),
230 title: session.title,
231 directory: session.directory,
232 created_at: DateTime::from_timestamp_millis(session.time.created)
233 .unwrap_or_else(|| Utc::now()),
234 updated_at: DateTime::from_timestamp_millis(session.time.updated)
235 .unwrap_or_else(|| Utc::now()),
236 message_count,
237 });
238 }
239 }
240 }
241 }
242
243 summaries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
245 Ok(summaries)
246 }
247
248 pub async fn list_sessions_for_directory(
250 &self,
251 dir: &Path,
252 ) -> Result<Vec<OpenCodeSessionSummary>> {
253 let all = self.list_sessions().await?;
254 let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
255
256 Ok(all
257 .into_iter()
258 .filter(|s| {
259 let session_dir = Path::new(&s.directory);
260 let canonical_session = session_dir
261 .canonicalize()
262 .unwrap_or_else(|_| session_dir.to_path_buf());
263 canonical_session == canonical_dir
264 })
265 .collect())
266 }
267
268 pub async fn load_session(&self, session_id: &str) -> Result<OpenCodeSession> {
270 let path = self
271 .storage_dir
272 .join("session")
273 .join(format!("{}.json", session_id));
274 let content = fs::read_to_string(&path)
275 .await
276 .with_context(|| format!("Failed to read session file: {}", path.display()))?;
277 let session: OpenCodeSession = serde_json::from_str(&content)
278 .with_context(|| format!("Failed to parse session JSON: {}", session_id))?;
279 Ok(session)
280 }
281
282 pub async fn load_messages(&self, session_id: &str) -> Result<Vec<OpenCodeMessage>> {
284 let message_dir = self.storage_dir.join("message").join(session_id);
285
286 if !message_dir.exists() {
287 return Ok(Vec::new());
288 }
289
290 let mut messages = Vec::new();
291 let mut entries = fs::read_dir(&message_dir).await?;
292
293 while let Some(entry) = entries.next_entry().await? {
294 let path = entry.path();
295 if path.extension().map(|e| e == "json").unwrap_or(false) {
296 if let Ok(content) = fs::read_to_string(&path).await {
297 if let Ok(message) = serde_json::from_str::<OpenCodeMessage>(&content) {
298 messages.push(message);
299 }
300 }
301 }
302 }
303
304 messages.sort_by_key(|m| m.time.created);
306 Ok(messages)
307 }
308
309 pub async fn load_parts(&self, message_id: &str) -> Result<Vec<OpenCodePart>> {
311 let part_dir = self.storage_dir.join("part").join(message_id);
312
313 if !part_dir.exists() {
314 return Ok(Vec::new());
315 }
316
317 let mut parts = Vec::new();
318 let mut entries = fs::read_dir(&part_dir).await?;
319
320 while let Some(entry) = entries.next_entry().await? {
321 let path = entry.path();
322 if path.extension().map(|e| e == "json").unwrap_or(false) {
323 if let Ok(content) = fs::read_to_string(&path).await {
324 if let Ok(part) = serde_json::from_str::<OpenCodePart>(&content) {
325 parts.push(part);
326 }
327 }
328 }
329 }
330
331 parts.sort_by_key(|p| p.time.as_ref().and_then(|t| t.start).unwrap_or(0));
333 Ok(parts)
334 }
335
336 pub async fn load_todos(&self, session_id: &str) -> Result<Vec<OpenCodeTodo>> {
338 let todo_path = self
339 .storage_dir
340 .join("todo")
341 .join(format!("{}.json", session_id));
342
343 if !todo_path.exists() {
344 return Ok(Vec::new());
345 }
346
347 let content = fs::read_to_string(&todo_path).await?;
348 let todos: Vec<OpenCodeTodo> = serde_json::from_str(&content)?;
349 Ok(todos)
350 }
351
352 async fn count_messages(&self, session_id: &str) -> Result<usize> {
354 let message_dir = self.storage_dir.join("message").join(session_id);
355
356 if !message_dir.exists() {
357 return Ok(0);
358 }
359
360 let mut count = 0;
361 let mut entries = fs::read_dir(&message_dir).await?;
362
363 while let Some(entry) = entries.next_entry().await? {
364 let path = entry.path();
365 if path.extension().map(|e| e == "json").unwrap_or(false) {
366 count += 1;
367 }
368 }
369
370 Ok(count)
371 }
372
373 pub async fn last_session(&self) -> Result<OpenCodeSession> {
375 let sessions = self.list_sessions().await?;
376
377 if let Some(first) = sessions.first() {
378 self.load_session(&first.id).await
379 } else {
380 anyhow::bail!("No OpenCode sessions found")
381 }
382 }
383
384 pub async fn last_session_for_directory(&self, dir: &Path) -> Result<OpenCodeSession> {
386 let sessions = self.list_sessions_for_directory(dir).await?;
387
388 if let Some(first) = sessions.first() {
389 self.load_session(&first.id).await
390 } else {
391 anyhow::bail!(
392 "No OpenCode sessions found for directory: {}",
393 dir.display()
394 )
395 }
396 }
397}
398
399pub mod convert {
401 use super::*;
402 use crate::provider::{ContentPart, Message, Role};
403 use crate::session::{Session, SessionMetadata};
404
405 pub async fn to_codetether_session(
407 opencode_session: &OpenCodeSession,
408 messages: Vec<(OpenCodeMessage, Vec<OpenCodePart>)>,
409 ) -> Result<Session> {
410 let mut codetether_messages = Vec::new();
411
412 for (msg, parts) in messages {
413 let role = match msg.role.as_str() {
414 "user" => Role::User,
415 "assistant" => Role::Assistant,
416 _ => Role::User, };
418
419 let content = convert_parts_to_content(&parts);
420
421 codetether_messages.push(Message { role, content });
422 }
423
424 let session = Session {
425 id: format!("opencode_{}", opencode_session.id),
426 title: Some(opencode_session.title.clone()),
427 created_at: DateTime::from_timestamp_millis(opencode_session.time.created)
428 .unwrap_or_else(|| Utc::now()),
429 updated_at: DateTime::from_timestamp_millis(opencode_session.time.updated)
430 .unwrap_or_else(|| Utc::now()),
431 messages: codetether_messages,
432 tool_uses: Vec::new(), usage: Default::default(),
434 agent: "build".to_string(), metadata: SessionMetadata {
436 directory: Some(PathBuf::from(&opencode_session.directory)),
437 model: None, shared: false,
439 share_url: None,
440 },
441 };
442
443 Ok(session)
444 }
445
446 fn convert_parts_to_content(parts: &[OpenCodePart]) -> Vec<ContentPart> {
448 let mut content = Vec::new();
449
450 for part in parts {
451 match part.part_type.as_str() {
452 "text" => {
453 if let Some(text) = &part.text {
454 content.push(ContentPart::Text { text: text.clone() });
455 }
456 }
457 "tool-call" => {
458 if let (Some(name), Some(args)) = (&part.name, &part.arguments) {
459 content.push(ContentPart::ToolCall {
460 id: part.tool_call_id.clone().unwrap_or_default(),
461 name: name.clone(),
462 arguments: args.clone(),
463 });
464 }
465 }
466 "tool-result" => {
467 if let Some(tool_content) = &part.content {
468 content.push(ContentPart::ToolResult {
469 tool_call_id: part.tool_call_id.clone().unwrap_or_default(),
470 content: tool_content.clone(),
471 });
472 }
473 }
474 _ => {
475 if let Some(text) = &part.text {
477 content.push(ContentPart::Text { text: text.clone() });
478 }
479 }
480 }
481 }
482
483 content
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[test]
492 fn test_opencode_storage_dir() {
493 let dir = opencode_storage_dir();
494 assert!(dir.is_some());
495 let dir = dir.unwrap();
496 assert!(dir.to_string_lossy().contains("opencode"));
497 assert!(dir.to_string_lossy().contains("storage"));
498 }
499
500 #[tokio::test]
501 async fn test_storage_exists() {
502 if let Some(storage) = OpenCodeStorage::new() {
503 let _ = storage.exists();
506 }
507 }
508}