1use anyhow::{Context, Result};
16use chrono::{DateTime, TimeZone, Utc};
17use serde::Deserialize;
18use std::collections::HashMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23use crate::storage::models::{Message, MessageContent, MessageRole, Session};
24
25use super::{Watcher, WatcherInfo};
26
27pub struct OpenCodeWatcher;
32
33impl Watcher for OpenCodeWatcher {
34 fn info(&self) -> WatcherInfo {
35 WatcherInfo {
36 name: "opencode",
37 description: "OpenCode CLI",
38 default_paths: vec![opencode_storage_dir()],
39 }
40 }
41
42 fn is_available(&self) -> bool {
43 opencode_storage_dir().exists()
44 }
45
46 fn find_sources(&self) -> Result<Vec<PathBuf>> {
47 find_opencode_session_files()
48 }
49
50 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
51 let parsed = parse_opencode_session(path)?;
52 if parsed.messages.is_empty() {
53 return Ok(vec![]);
54 }
55 let (session, messages) = parsed.to_storage_models();
56 Ok(vec![(session, messages)])
57 }
58
59 fn watch_paths(&self) -> Vec<PathBuf> {
60 vec![opencode_storage_dir()]
61 }
62}
63
64fn opencode_storage_dir() -> PathBuf {
68 dirs::home_dir()
69 .unwrap_or_else(|| PathBuf::from("."))
70 .join(".local")
71 .join("share")
72 .join("opencode")
73 .join("storage")
74}
75
76#[derive(Debug, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct RawOpenCodeSession {
80 id: String,
81 #[serde(default)]
82 version: Option<String>,
83 #[serde(default, rename = "projectID")]
85 #[allow(dead_code)]
86 project_id: Option<String>,
87 #[serde(default)]
88 directory: Option<String>,
89 #[serde(default)]
90 title: Option<String>,
91 #[serde(default)]
92 time: Option<RawOpenCodeTime>,
93}
94
95#[derive(Debug, Deserialize)]
97struct RawOpenCodeTime {
98 created: i64,
99 #[serde(default)]
100 updated: Option<i64>,
101}
102
103#[derive(Debug, Deserialize)]
105#[serde(rename_all = "camelCase")]
106struct RawOpenCodeMessage {
107 id: String,
108 #[serde(rename = "sessionID")]
109 session_id: String,
110 role: String,
111 #[serde(default)]
112 time: Option<RawOpenCodeMessageTime>,
113 #[serde(default, rename = "modelID")]
114 model_id: Option<String>,
115 #[serde(default, rename = "providerID")]
117 #[allow(dead_code)]
118 provider_id: Option<String>,
119 #[serde(default)]
121 model: Option<RawOpenCodeModel>,
122}
123
124#[derive(Debug, Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct RawOpenCodeModel {
128 #[serde(default, rename = "modelID")]
129 model_id: Option<String>,
130}
131
132#[derive(Debug, Deserialize)]
134struct RawOpenCodeMessageTime {
135 created: i64,
136 #[serde(default)]
138 #[allow(dead_code)]
139 completed: Option<i64>,
140}
141
142#[derive(Debug, Deserialize)]
144#[serde(rename_all = "camelCase")]
145struct RawOpenCodePart {
146 #[serde(default)]
147 id: Option<String>,
148 #[serde(default, rename = "sessionID")]
150 #[allow(dead_code)]
151 session_id: Option<String>,
152 #[serde(default, rename = "messageID")]
154 #[allow(dead_code)]
155 message_id: Option<String>,
156 #[serde(rename = "type")]
157 part_type: String,
158 #[serde(default)]
159 text: Option<String>,
160 #[serde(default)]
161 tool: Option<String>,
162 #[serde(default)]
163 state: Option<RawOpenCodeToolState>,
164}
165
166#[derive(Debug, Deserialize)]
168struct RawOpenCodeToolState {
169 #[serde(default)]
170 status: Option<String>,
171}
172
173pub fn parse_opencode_session(session_path: &Path) -> Result<ParsedOpenCodeSession> {
182 let content =
183 fs::read_to_string(session_path).context("Failed to read OpenCode session file")?;
184 let raw_session: RawOpenCodeSession =
185 serde_json::from_str(&content).context("Failed to parse OpenCode session JSON")?;
186
187 let storage_dir = session_path
189 .parent() .and_then(|p| p.parent()) .and_then(|p| p.parent()) .unwrap_or_else(|| Path::new("."));
193
194 let created_at = raw_session
196 .time
197 .as_ref()
198 .and_then(|t| Utc.timestamp_millis_opt(t.created).single());
199
200 let updated_at = raw_session
201 .time
202 .as_ref()
203 .and_then(|t| t.updated)
204 .and_then(|ms| Utc.timestamp_millis_opt(ms).single());
205
206 let messages = load_session_messages(storage_dir, &raw_session.id)?;
208
209 let model = messages
211 .iter()
212 .find(|m| m.role == MessageRole::Assistant)
213 .and_then(|m| m.model.clone());
214
215 Ok(ParsedOpenCodeSession {
216 session_id: raw_session.id,
217 version: raw_session.version,
218 title: raw_session.title,
219 working_directory: raw_session.directory.unwrap_or_else(|| ".".to_string()),
220 created_at,
221 updated_at,
222 model,
223 messages,
224 source_path: session_path.to_string_lossy().to_string(),
225 })
226}
227
228fn load_session_messages(
230 storage_dir: &Path,
231 session_id: &str,
232) -> Result<Vec<ParsedOpenCodeMessage>> {
233 let message_dir = storage_dir.join("message").join(session_id);
234
235 if !message_dir.exists() {
236 return Ok(Vec::new());
237 }
238
239 let mut messages: Vec<(i64, ParsedOpenCodeMessage)> = Vec::new();
240
241 for entry in fs::read_dir(&message_dir)? {
242 let entry = entry?;
243 let path = entry.path();
244
245 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
246 if name.starts_with("msg_") && name.ends_with(".json") {
247 if let Ok(msg) = parse_message_file(&path, storage_dir) {
248 let sort_key = msg.timestamp.timestamp_millis();
250 messages.push((sort_key, msg));
251 }
252 }
253 }
254 }
255
256 messages.sort_by_key(|(ts, _)| *ts);
258
259 Ok(messages.into_iter().map(|(_, msg)| msg).collect())
260}
261
262fn parse_message_file(path: &Path, storage_dir: &Path) -> Result<ParsedOpenCodeMessage> {
264 let content = fs::read_to_string(path).context("Failed to read message file")?;
265 let raw: RawOpenCodeMessage =
266 serde_json::from_str(&content).context("Failed to parse message JSON")?;
267
268 let role = match raw.role.as_str() {
269 "user" => MessageRole::User,
270 "assistant" => MessageRole::Assistant,
271 "system" => MessageRole::System,
272 _ => MessageRole::User,
273 };
274
275 let timestamp = raw
276 .time
277 .as_ref()
278 .and_then(|t| Utc.timestamp_millis_opt(t.created).single())
279 .unwrap_or_else(Utc::now);
280
281 let model = raw
283 .model_id
284 .or_else(|| raw.model.as_ref().and_then(|m| m.model_id.clone()));
285
286 let content = load_message_parts(storage_dir, &raw.id)?;
288
289 Ok(ParsedOpenCodeMessage {
290 id: raw.id,
291 session_id: raw.session_id,
292 timestamp,
293 role,
294 content,
295 model,
296 })
297}
298
299fn load_message_parts(storage_dir: &Path, message_id: &str) -> Result<String> {
301 let part_dir = storage_dir.join("part").join(message_id);
302
303 if !part_dir.exists() {
304 return Ok(String::new());
305 }
306
307 let mut parts: Vec<(String, String)> = Vec::new();
308
309 for entry in fs::read_dir(&part_dir)? {
310 let entry = entry?;
311 let path = entry.path();
312
313 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
314 if name.starts_with("prt_") && name.ends_with(".json") {
315 if let Ok(part) = parse_part_file(&path) {
316 if let Some(id) = part.0 {
318 parts.push((id, part.1));
319 } else {
320 parts.push((String::new(), part.1));
322 }
323 }
324 }
325 }
326 }
327
328 parts.sort_by(|a, b| a.0.cmp(&b.0));
330
331 let content: Vec<String> = parts.into_iter().map(|(_, text)| text).collect();
333 Ok(content.join("\n"))
334}
335
336fn parse_part_file(path: &Path) -> Result<(Option<String>, String)> {
340 let content = fs::read_to_string(path).context("Failed to read part file")?;
341 let raw: RawOpenCodePart =
342 serde_json::from_str(&content).context("Failed to parse part JSON")?;
343
344 let text = match raw.part_type.as_str() {
345 "text" => raw.text.unwrap_or_default(),
346 "tool" => {
347 let tool_name = raw.tool.unwrap_or_else(|| "unknown".to_string());
349 let status = raw
350 .state
351 .as_ref()
352 .and_then(|s| s.status.clone())
353 .unwrap_or_else(|| "unknown".to_string());
354 format!("[tool: {tool_name} ({status})]")
355 }
356 _ => String::new(),
357 };
358
359 Ok((raw.id, text))
360}
361
362#[derive(Debug)]
364pub struct ParsedOpenCodeSession {
365 pub session_id: String,
366 pub version: Option<String>,
367 #[allow(dead_code)]
369 pub title: Option<String>,
370 pub working_directory: String,
371 pub created_at: Option<DateTime<Utc>>,
372 pub updated_at: Option<DateTime<Utc>>,
373 pub model: Option<String>,
374 pub messages: Vec<ParsedOpenCodeMessage>,
375 pub source_path: String,
376}
377
378impl ParsedOpenCodeSession {
379 pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
381 let session_uuid = generate_uuid_from_string(&self.session_id);
383
384 let started_at = self
385 .created_at
386 .or_else(|| self.messages.first().map(|m| m.timestamp))
387 .unwrap_or_else(Utc::now);
388
389 let ended_at = self
390 .updated_at
391 .or_else(|| self.messages.last().map(|m| m.timestamp));
392
393 let session = Session {
394 id: session_uuid,
395 tool: "opencode".to_string(),
396 tool_version: self.version.clone(),
397 started_at,
398 ended_at,
399 model: self.model.clone(),
400 working_directory: self.working_directory.clone(),
401 git_branch: None,
402 source_path: Some(self.source_path.clone()),
403 message_count: self.messages.len() as i32,
404 };
405
406 let message_uuid_map: HashMap<String, Uuid> = self
408 .messages
409 .iter()
410 .map(|m| (m.id.clone(), generate_uuid_from_string(&m.id)))
411 .collect();
412
413 let messages: Vec<Message> = self
414 .messages
415 .iter()
416 .enumerate()
417 .map(|(idx, m)| {
418 let id = *message_uuid_map.get(&m.id).unwrap_or(&Uuid::new_v4());
419
420 Message {
421 id,
422 session_id: session_uuid,
423 parent_id: None,
424 index: idx as i32,
425 timestamp: m.timestamp,
426 role: m.role.clone(),
427 content: MessageContent::Text(m.content.clone()),
428 model: m.model.clone(),
429 git_branch: None,
430 cwd: None,
431 }
432 })
433 .collect();
434
435 (session, messages)
436 }
437}
438
439fn generate_uuid_from_string(s: &str) -> Uuid {
445 if let Ok(uuid) = Uuid::parse_str(s) {
447 return uuid;
448 }
449
450 use std::collections::hash_map::DefaultHasher;
454 use std::hash::{Hash, Hasher};
455
456 let mut hasher = DefaultHasher::new();
457 s.hash(&mut hasher);
458 let hash1 = hasher.finish();
459
460 let mut hasher2 = DefaultHasher::new();
462 hash1.hash(&mut hasher2);
463 let hash2 = hasher2.finish();
464
465 let mut bytes = [0u8; 16];
467 bytes[0..8].copy_from_slice(&hash1.to_le_bytes());
468 bytes[8..16].copy_from_slice(&hash2.to_le_bytes());
469
470 bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; Uuid::from_bytes(bytes)
475}
476
477#[derive(Debug)]
479pub struct ParsedOpenCodeMessage {
480 pub id: String,
481 #[allow(dead_code)]
483 pub session_id: String,
484 pub timestamp: DateTime<Utc>,
485 pub role: MessageRole,
486 pub content: String,
487 pub model: Option<String>,
488}
489
490pub fn find_opencode_session_files() -> Result<Vec<PathBuf>> {
494 let storage_dir = opencode_storage_dir();
495 let session_dir = storage_dir.join("session");
496
497 if !session_dir.exists() {
498 return Ok(Vec::new());
499 }
500
501 let mut files = Vec::new();
502
503 for project_entry in fs::read_dir(&session_dir)? {
505 let project_entry = project_entry?;
506 let project_path = project_entry.path();
507 if !project_path.is_dir() {
508 continue;
509 }
510
511 for file_entry in fs::read_dir(&project_path)? {
512 let file_entry = file_entry?;
513 let file_path = file_entry.path();
514
515 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
516 if name.starts_with("ses_") && name.ends_with(".json") {
517 files.push(file_path);
518 }
519 }
520 }
521 }
522
523 Ok(files)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use tempfile::TempDir;
530
531 struct TestOpenCodeStorage {
533 _temp_dir: TempDir,
534 storage_dir: PathBuf,
535 }
536
537 impl TestOpenCodeStorage {
538 fn new() -> Self {
539 let temp_dir = TempDir::new().expect("Failed to create temp dir");
540 let storage_dir = temp_dir.path().join("storage");
541 fs::create_dir_all(&storage_dir).expect("Failed to create storage dir");
542 Self {
543 _temp_dir: temp_dir,
544 storage_dir,
545 }
546 }
547
548 fn create_session(
549 &self,
550 project_hash: &str,
551 session_id: &str,
552 directory: &str,
553 created_ms: i64,
554 ) -> PathBuf {
555 let session_dir = self.storage_dir.join("session").join(project_hash);
556 fs::create_dir_all(&session_dir).expect("Failed to create session dir");
557
558 let session_path = session_dir.join(format!("{session_id}.json"));
559 let session_json = format!(
560 r#"{{
561 "id": "{session_id}",
562 "version": "1.0.193",
563 "projectID": "{project_hash}",
564 "directory": "{directory}",
565 "title": "Test Session",
566 "time": {{
567 "created": {created_ms},
568 "updated": {updated_ms}
569 }}
570 }}"#,
571 updated_ms = created_ms + 10000
572 );
573 fs::write(&session_path, session_json).expect("Failed to write session file");
574 session_path
575 }
576
577 fn create_message(
578 &self,
579 session_id: &str,
580 message_id: &str,
581 role: &str,
582 created_ms: i64,
583 model_id: Option<&str>,
584 ) {
585 let message_dir = self.storage_dir.join("message").join(session_id);
586 fs::create_dir_all(&message_dir).expect("Failed to create message dir");
587
588 let model_field = model_id
589 .map(|m| format!(r#""modelID": "{m}","#))
590 .unwrap_or_default();
591
592 let message_json = format!(
593 r#"{{
594 "id": "{message_id}",
595 "sessionID": "{session_id}",
596 "role": "{role}",
597 {model_field}
598 "time": {{
599 "created": {created_ms}
600 }}
601 }}"#
602 );
603 let message_path = message_dir.join(format!("{message_id}.json"));
604 fs::write(message_path, message_json).expect("Failed to write message file");
605 }
606
607 fn create_text_part(&self, message_id: &str, part_id: &str, text: &str) {
608 let part_dir = self.storage_dir.join("part").join(message_id);
609 fs::create_dir_all(&part_dir).expect("Failed to create part dir");
610
611 let part_json = format!(
613 r#"{{
614 "id": "{part_id}",
615 "type": "text",
616 "text": "{text}"
617 }}"#
618 );
619 let part_path = part_dir.join(format!("{part_id}.json"));
620 fs::write(part_path, part_json).expect("Failed to write part file");
621 }
622
623 fn create_tool_part(&self, message_id: &str, part_id: &str, tool: &str, status: &str) {
624 let part_dir = self.storage_dir.join("part").join(message_id);
625 fs::create_dir_all(&part_dir).expect("Failed to create part dir");
626
627 let part_json = format!(
629 r#"{{
630 "id": "{part_id}",
631 "type": "tool",
632 "tool": "{tool}",
633 "state": {{
634 "status": "{status}"
635 }}
636 }}"#
637 );
638 let part_path = part_dir.join(format!("{part_id}.json"));
639 fs::write(part_path, part_json).expect("Failed to write part file");
640 }
641 }
642
643 #[test]
644 fn test_watcher_info() {
645 let watcher = OpenCodeWatcher;
646 let info = watcher.info();
647
648 assert_eq!(info.name, "opencode");
649 assert_eq!(info.description, "OpenCode CLI");
650 assert!(!info.default_paths.is_empty());
651 assert!(info.default_paths[0].to_string_lossy().contains("opencode"));
652 }
653
654 #[test]
655 fn test_watcher_watch_paths() {
656 let watcher = OpenCodeWatcher;
657 let paths = watcher.watch_paths();
658
659 assert!(!paths.is_empty());
660 assert!(paths[0].to_string_lossy().contains("opencode"));
661 assert!(paths[0].to_string_lossy().contains("storage"));
662 }
663
664 #[test]
665 fn test_parse_simple_session() {
666 let storage = TestOpenCodeStorage::new();
667 let session_path = storage.create_session(
668 "64ba75f0bc0e109e",
669 "ses_test123",
670 "/Users/test/project",
671 1766529546325,
672 );
673
674 storage.create_message("ses_test123", "msg_user1", "user", 1766529546342, None);
676 storage.create_text_part("msg_user1", "prt_user1", "Hello, OpenCode!");
677
678 storage.create_message(
680 "ses_test123",
681 "msg_asst1",
682 "assistant",
683 1766529550000,
684 Some("big-pickle"),
685 );
686 storage.create_text_part("msg_asst1", "prt_asst1", "Hello! How can I help you?");
687
688 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
689
690 assert_eq!(parsed.session_id, "ses_test123");
691 assert_eq!(parsed.version, Some("1.0.193".to_string()));
692 assert_eq!(parsed.working_directory, "/Users/test/project");
693 assert_eq!(parsed.messages.len(), 2);
694 assert_eq!(parsed.messages[0].role, MessageRole::User);
695 assert_eq!(parsed.messages[0].content, "Hello, OpenCode!");
696 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
697 assert_eq!(parsed.messages[1].model, Some("big-pickle".to_string()));
698 }
699
700 #[test]
701 fn test_parse_user_message() {
702 let storage = TestOpenCodeStorage::new();
703 let session_path =
704 storage.create_session("project123", "ses_user_test", "/test/path", 1766529546325);
705
706 storage.create_message("ses_user_test", "msg_u1", "user", 1766529546342, None);
707 storage.create_text_part("msg_u1", "prt_u1", "What is Rust?");
708
709 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
710
711 assert_eq!(parsed.messages.len(), 1);
712 assert_eq!(parsed.messages[0].role, MessageRole::User);
713 assert_eq!(parsed.messages[0].content, "What is Rust?");
714 }
715
716 #[test]
717 fn test_parse_assistant_message_with_model() {
718 let storage = TestOpenCodeStorage::new();
719 let session_path =
720 storage.create_session("project123", "ses_asst_test", "/test/path", 1766529546325);
721
722 storage.create_message(
723 "ses_asst_test",
724 "msg_a1",
725 "assistant",
726 1766529546342,
727 Some("claude-opus-4"),
728 );
729 storage.create_text_part(
730 "msg_a1",
731 "prt_a1",
732 "Rust is a systems programming language.",
733 );
734
735 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
736
737 assert_eq!(parsed.messages.len(), 1);
738 assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
739 assert_eq!(parsed.messages[0].model, Some("claude-opus-4".to_string()));
740 assert_eq!(parsed.model, Some("claude-opus-4".to_string()));
741 }
742
743 #[test]
744 fn test_parse_tool_parts() {
745 let storage = TestOpenCodeStorage::new();
746 let session_path =
747 storage.create_session("project123", "ses_tool_test", "/test/path", 1766529546325);
748
749 storage.create_message(
750 "ses_tool_test",
751 "msg_t1",
752 "assistant",
753 1766529546342,
754 Some("model"),
755 );
756 storage.create_text_part("msg_t1", "prt_t1a", "Let me read that file.");
757 storage.create_tool_part("msg_t1", "prt_t1b", "read", "completed");
758
759 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
760
761 assert_eq!(parsed.messages.len(), 1);
762 assert!(parsed.messages[0]
764 .content
765 .contains("Let me read that file."));
766 assert!(parsed.messages[0]
767 .content
768 .contains("[tool: read (completed)]"));
769 }
770
771 #[test]
772 fn test_messages_sorted_by_timestamp() {
773 let storage = TestOpenCodeStorage::new();
774 let session_path =
775 storage.create_session("project123", "ses_sort_test", "/test/path", 1766529546325);
776
777 storage.create_message(
779 "ses_sort_test",
780 "msg_second",
781 "assistant",
782 1766529550000,
783 None,
784 );
785 storage.create_text_part("msg_second", "prt_s", "Second message");
786
787 storage.create_message("ses_sort_test", "msg_first", "user", 1766529546342, None);
788 storage.create_text_part("msg_first", "prt_f", "First message");
789
790 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
791
792 assert_eq!(parsed.messages.len(), 2);
793 assert_eq!(parsed.messages[0].content, "First message");
794 assert_eq!(parsed.messages[1].content, "Second message");
795 }
796
797 #[test]
798 fn test_session_with_no_messages() {
799 let storage = TestOpenCodeStorage::new();
800 let session_path =
801 storage.create_session("project123", "ses_empty", "/test/path", 1766529546325);
802
803 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
804
805 assert!(parsed.messages.is_empty());
806 }
807
808 #[test]
809 fn test_to_storage_models() {
810 let storage = TestOpenCodeStorage::new();
811 let session_path = storage.create_session(
812 "project123",
813 "ses_storage_test",
814 "/Users/test/project",
815 1766529546325,
816 );
817
818 storage.create_message("ses_storage_test", "msg_u1", "user", 1766529546342, None);
819 storage.create_text_part("msg_u1", "prt_u1", "Hello");
820
821 storage.create_message(
822 "ses_storage_test",
823 "msg_a1",
824 "assistant",
825 1766529550000,
826 Some("test-model"),
827 );
828 storage.create_text_part("msg_a1", "prt_a1", "Hi there!");
829
830 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
831 let (session, messages) = parsed.to_storage_models();
832
833 assert_eq!(session.tool, "opencode");
834 assert_eq!(session.tool_version, Some("1.0.193".to_string()));
835 assert_eq!(session.working_directory, "/Users/test/project");
836 assert_eq!(session.model, Some("test-model".to_string()));
837 assert_eq!(session.message_count, 2);
838 assert!(session.source_path.is_some());
839
840 assert_eq!(messages.len(), 2);
841 assert_eq!(messages[0].role, MessageRole::User);
842 assert_eq!(messages[0].index, 0);
843 assert_eq!(messages[1].role, MessageRole::Assistant);
844 assert_eq!(messages[1].index, 1);
845 }
846
847 #[test]
848 fn test_generate_uuid_from_string() {
849 let valid_uuid = "550e8400-e29b-41d4-a716-446655440000";
851 let result = generate_uuid_from_string(valid_uuid);
852 assert_eq!(result.to_string(), valid_uuid);
853
854 let opencode_id = "ses_4b2a247aaffeEmXAKKN3BeRz2j";
856 let result1 = generate_uuid_from_string(opencode_id);
857 let result2 = generate_uuid_from_string(opencode_id);
858 assert_eq!(result1, result2);
859 assert!(!result1.is_nil());
860 }
861
862 #[test]
863 fn test_session_timestamps() {
864 let storage = TestOpenCodeStorage::new();
865 let session_path =
866 storage.create_session("project123", "ses_time_test", "/test/path", 1766529546325);
867
868 storage.create_message("ses_time_test", "msg_t1", "user", 1766529546342, None);
869 storage.create_text_part("msg_t1", "prt_t1", "Hello");
870
871 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
872 let (session, _) = parsed.to_storage_models();
873
874 assert!(session.started_at.timestamp_millis() > 0);
875 assert!(session.ended_at.is_some());
876 }
877
878 #[test]
879 fn test_watcher_parse_source() {
880 let watcher = OpenCodeWatcher;
881 let storage = TestOpenCodeStorage::new();
882 let session_path = storage.create_session(
883 "project123",
884 "ses_watcher_test",
885 "/test/path",
886 1766529546325,
887 );
888
889 storage.create_message("ses_watcher_test", "msg_w1", "user", 1766529546342, None);
890 storage.create_text_part("msg_w1", "prt_w1", "Hello");
891
892 let result = watcher
893 .parse_source(&session_path)
894 .expect("Should parse successfully");
895
896 assert_eq!(result.len(), 1);
897 let (session, messages) = &result[0];
898 assert_eq!(session.tool, "opencode");
899 assert_eq!(messages.len(), 1);
900 }
901
902 #[test]
903 fn test_watcher_parse_source_empty_session() {
904 let watcher = OpenCodeWatcher;
905 let storage = TestOpenCodeStorage::new();
906 let session_path =
907 storage.create_session("project123", "ses_empty_test", "/test/path", 1766529546325);
908
909 let result = watcher
910 .parse_source(&session_path)
911 .expect("Should parse successfully");
912
913 assert!(result.is_empty());
914 }
915
916 #[test]
917 fn test_find_session_files_returns_empty_when_missing() {
918 let result = find_opencode_session_files();
919 assert!(result.is_ok());
920 }
921
922 #[test]
923 fn test_multiple_text_parts_combined() {
924 let storage = TestOpenCodeStorage::new();
925 let session_path =
926 storage.create_session("project123", "ses_multi_part", "/test/path", 1766529546325);
927
928 storage.create_message("ses_multi_part", "msg_mp", "assistant", 1766529546342, None);
929 storage.create_text_part("msg_mp", "prt_mp1", "First part.");
930 storage.create_text_part("msg_mp", "prt_mp2", "Second part.");
931
932 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
933
934 assert_eq!(parsed.messages.len(), 1);
935 assert!(parsed.messages[0].content.contains("First part."));
937 assert!(parsed.messages[0].content.contains("Second part."));
938 }
939
940 #[test]
941 fn test_system_message() {
942 let storage = TestOpenCodeStorage::new();
943 let session_path =
944 storage.create_session("project123", "ses_system", "/test/path", 1766529546325);
945
946 storage.create_message("ses_system", "msg_sys", "system", 1766529546342, None);
947 storage.create_text_part("msg_sys", "prt_sys", "You are a helpful assistant.");
948
949 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
950
951 assert_eq!(parsed.messages.len(), 1);
952 assert_eq!(parsed.messages[0].role, MessageRole::System);
953 }
954
955 #[test]
956 fn test_message_with_empty_parts_dir() {
957 let storage = TestOpenCodeStorage::new();
958 let session_path =
959 storage.create_session("project123", "ses_no_parts", "/test/path", 1766529546325);
960
961 storage.create_message("ses_no_parts", "msg_np", "user", 1766529546342, None);
962 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
965
966 assert_eq!(parsed.messages.len(), 1);
967 assert_eq!(parsed.messages[0].content, "");
968 }
969
970 #[test]
971 fn test_session_without_optional_fields() {
972 let storage = TestOpenCodeStorage::new();
973
974 let session_dir = storage.storage_dir.join("session").join("minimal");
976 fs::create_dir_all(&session_dir).expect("Failed to create session dir");
977
978 let session_path = session_dir.join("ses_minimal.json");
979 let session_json = r#"{"id": "ses_minimal"}"#;
980 fs::write(&session_path, session_json).expect("Failed to write session file");
981
982 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
983
984 assert_eq!(parsed.session_id, "ses_minimal");
985 assert_eq!(parsed.working_directory, ".");
986 assert!(parsed.version.is_none());
987 }
988
989 #[test]
990 fn test_find_session_files_in_storage() {
991 let storage = TestOpenCodeStorage::new();
992
993 storage.create_session("project_a", "ses_a1", "/path/a", 1766529546325);
995 storage.create_session("project_a", "ses_a2", "/path/a", 1766529546325);
996 storage.create_session("project_b", "ses_b1", "/path/b", 1766529546325);
997
998 let session_dir = storage.storage_dir.join("session");
1000 assert!(session_dir.exists());
1001
1002 let mut count = 0;
1004 for project_entry in fs::read_dir(&session_dir).unwrap() {
1005 let project_path = project_entry.unwrap().path();
1006 if project_path.is_dir() {
1007 for file_entry in fs::read_dir(&project_path).unwrap() {
1008 let file_path = file_entry.unwrap().path();
1009 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
1010 if name.starts_with("ses_") && name.ends_with(".json") {
1011 count += 1;
1012 }
1013 }
1014 }
1015 }
1016 }
1017 assert_eq!(count, 3);
1018 }
1019}