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 machine_id: crate::storage::get_machine_id(),
405 };
406
407 let message_uuid_map: HashMap<String, Uuid> = self
409 .messages
410 .iter()
411 .map(|m| (m.id.clone(), generate_uuid_from_string(&m.id)))
412 .collect();
413
414 let messages: Vec<Message> = self
415 .messages
416 .iter()
417 .enumerate()
418 .map(|(idx, m)| {
419 let id = *message_uuid_map.get(&m.id).unwrap_or(&Uuid::new_v4());
420
421 Message {
422 id,
423 session_id: session_uuid,
424 parent_id: None,
425 index: idx as i32,
426 timestamp: m.timestamp,
427 role: m.role.clone(),
428 content: MessageContent::Text(m.content.clone()),
429 model: m.model.clone(),
430 git_branch: None,
431 cwd: None,
432 }
433 })
434 .collect();
435
436 (session, messages)
437 }
438}
439
440fn generate_uuid_from_string(s: &str) -> Uuid {
446 if let Ok(uuid) = Uuid::parse_str(s) {
448 return uuid;
449 }
450
451 use std::collections::hash_map::DefaultHasher;
455 use std::hash::{Hash, Hasher};
456
457 let mut hasher = DefaultHasher::new();
458 s.hash(&mut hasher);
459 let hash1 = hasher.finish();
460
461 let mut hasher2 = DefaultHasher::new();
463 hash1.hash(&mut hasher2);
464 let hash2 = hasher2.finish();
465
466 let mut bytes = [0u8; 16];
468 bytes[0..8].copy_from_slice(&hash1.to_le_bytes());
469 bytes[8..16].copy_from_slice(&hash2.to_le_bytes());
470
471 bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; Uuid::from_bytes(bytes)
476}
477
478#[derive(Debug)]
480pub struct ParsedOpenCodeMessage {
481 pub id: String,
482 #[allow(dead_code)]
484 pub session_id: String,
485 pub timestamp: DateTime<Utc>,
486 pub role: MessageRole,
487 pub content: String,
488 pub model: Option<String>,
489}
490
491pub fn find_opencode_session_files() -> Result<Vec<PathBuf>> {
495 let storage_dir = opencode_storage_dir();
496 let session_dir = storage_dir.join("session");
497
498 if !session_dir.exists() {
499 return Ok(Vec::new());
500 }
501
502 let mut files = Vec::new();
503
504 for project_entry in fs::read_dir(&session_dir)? {
506 let project_entry = project_entry?;
507 let project_path = project_entry.path();
508 if !project_path.is_dir() {
509 continue;
510 }
511
512 for file_entry in fs::read_dir(&project_path)? {
513 let file_entry = file_entry?;
514 let file_path = file_entry.path();
515
516 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
517 if name.starts_with("ses_") && name.ends_with(".json") {
518 files.push(file_path);
519 }
520 }
521 }
522 }
523
524 Ok(files)
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use tempfile::TempDir;
531
532 struct TestOpenCodeStorage {
534 _temp_dir: TempDir,
535 storage_dir: PathBuf,
536 }
537
538 impl TestOpenCodeStorage {
539 fn new() -> Self {
540 let temp_dir = TempDir::new().expect("Failed to create temp dir");
541 let storage_dir = temp_dir.path().join("storage");
542 fs::create_dir_all(&storage_dir).expect("Failed to create storage dir");
543 Self {
544 _temp_dir: temp_dir,
545 storage_dir,
546 }
547 }
548
549 fn create_session(
550 &self,
551 project_hash: &str,
552 session_id: &str,
553 directory: &str,
554 created_ms: i64,
555 ) -> PathBuf {
556 let session_dir = self.storage_dir.join("session").join(project_hash);
557 fs::create_dir_all(&session_dir).expect("Failed to create session dir");
558
559 let session_path = session_dir.join(format!("{session_id}.json"));
560 let session_json = format!(
561 r#"{{
562 "id": "{session_id}",
563 "version": "1.0.193",
564 "projectID": "{project_hash}",
565 "directory": "{directory}",
566 "title": "Test Session",
567 "time": {{
568 "created": {created_ms},
569 "updated": {updated_ms}
570 }}
571 }}"#,
572 updated_ms = created_ms + 10000
573 );
574 fs::write(&session_path, session_json).expect("Failed to write session file");
575 session_path
576 }
577
578 fn create_message(
579 &self,
580 session_id: &str,
581 message_id: &str,
582 role: &str,
583 created_ms: i64,
584 model_id: Option<&str>,
585 ) {
586 let message_dir = self.storage_dir.join("message").join(session_id);
587 fs::create_dir_all(&message_dir).expect("Failed to create message dir");
588
589 let model_field = model_id
590 .map(|m| format!(r#""modelID": "{m}","#))
591 .unwrap_or_default();
592
593 let message_json = format!(
594 r#"{{
595 "id": "{message_id}",
596 "sessionID": "{session_id}",
597 "role": "{role}",
598 {model_field}
599 "time": {{
600 "created": {created_ms}
601 }}
602 }}"#
603 );
604 let message_path = message_dir.join(format!("{message_id}.json"));
605 fs::write(message_path, message_json).expect("Failed to write message file");
606 }
607
608 fn create_text_part(&self, message_id: &str, part_id: &str, text: &str) {
609 let part_dir = self.storage_dir.join("part").join(message_id);
610 fs::create_dir_all(&part_dir).expect("Failed to create part dir");
611
612 let part_json = format!(
614 r#"{{
615 "id": "{part_id}",
616 "type": "text",
617 "text": "{text}"
618 }}"#
619 );
620 let part_path = part_dir.join(format!("{part_id}.json"));
621 fs::write(part_path, part_json).expect("Failed to write part file");
622 }
623
624 fn create_tool_part(&self, message_id: &str, part_id: &str, tool: &str, status: &str) {
625 let part_dir = self.storage_dir.join("part").join(message_id);
626 fs::create_dir_all(&part_dir).expect("Failed to create part dir");
627
628 let part_json = format!(
630 r#"{{
631 "id": "{part_id}",
632 "type": "tool",
633 "tool": "{tool}",
634 "state": {{
635 "status": "{status}"
636 }}
637 }}"#
638 );
639 let part_path = part_dir.join(format!("{part_id}.json"));
640 fs::write(part_path, part_json).expect("Failed to write part file");
641 }
642 }
643
644 #[test]
645 fn test_watcher_info() {
646 let watcher = OpenCodeWatcher;
647 let info = watcher.info();
648
649 assert_eq!(info.name, "opencode");
650 assert_eq!(info.description, "OpenCode CLI");
651 assert!(!info.default_paths.is_empty());
652 assert!(info.default_paths[0].to_string_lossy().contains("opencode"));
653 }
654
655 #[test]
656 fn test_watcher_watch_paths() {
657 let watcher = OpenCodeWatcher;
658 let paths = watcher.watch_paths();
659
660 assert!(!paths.is_empty());
661 assert!(paths[0].to_string_lossy().contains("opencode"));
662 assert!(paths[0].to_string_lossy().contains("storage"));
663 }
664
665 #[test]
666 fn test_parse_simple_session() {
667 let storage = TestOpenCodeStorage::new();
668 let session_path = storage.create_session(
669 "64ba75f0bc0e109e",
670 "ses_test123",
671 "/Users/test/project",
672 1766529546325,
673 );
674
675 storage.create_message("ses_test123", "msg_user1", "user", 1766529546342, None);
677 storage.create_text_part("msg_user1", "prt_user1", "Hello, OpenCode!");
678
679 storage.create_message(
681 "ses_test123",
682 "msg_asst1",
683 "assistant",
684 1766529550000,
685 Some("big-pickle"),
686 );
687 storage.create_text_part("msg_asst1", "prt_asst1", "Hello! How can I help you?");
688
689 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
690
691 assert_eq!(parsed.session_id, "ses_test123");
692 assert_eq!(parsed.version, Some("1.0.193".to_string()));
693 assert_eq!(parsed.working_directory, "/Users/test/project");
694 assert_eq!(parsed.messages.len(), 2);
695 assert_eq!(parsed.messages[0].role, MessageRole::User);
696 assert_eq!(parsed.messages[0].content, "Hello, OpenCode!");
697 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
698 assert_eq!(parsed.messages[1].model, Some("big-pickle".to_string()));
699 }
700
701 #[test]
702 fn test_parse_user_message() {
703 let storage = TestOpenCodeStorage::new();
704 let session_path =
705 storage.create_session("project123", "ses_user_test", "/test/path", 1766529546325);
706
707 storage.create_message("ses_user_test", "msg_u1", "user", 1766529546342, None);
708 storage.create_text_part("msg_u1", "prt_u1", "What is Rust?");
709
710 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
711
712 assert_eq!(parsed.messages.len(), 1);
713 assert_eq!(parsed.messages[0].role, MessageRole::User);
714 assert_eq!(parsed.messages[0].content, "What is Rust?");
715 }
716
717 #[test]
718 fn test_parse_assistant_message_with_model() {
719 let storage = TestOpenCodeStorage::new();
720 let session_path =
721 storage.create_session("project123", "ses_asst_test", "/test/path", 1766529546325);
722
723 storage.create_message(
724 "ses_asst_test",
725 "msg_a1",
726 "assistant",
727 1766529546342,
728 Some("claude-opus-4"),
729 );
730 storage.create_text_part(
731 "msg_a1",
732 "prt_a1",
733 "Rust is a systems programming language.",
734 );
735
736 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
737
738 assert_eq!(parsed.messages.len(), 1);
739 assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
740 assert_eq!(parsed.messages[0].model, Some("claude-opus-4".to_string()));
741 assert_eq!(parsed.model, Some("claude-opus-4".to_string()));
742 }
743
744 #[test]
745 fn test_parse_tool_parts() {
746 let storage = TestOpenCodeStorage::new();
747 let session_path =
748 storage.create_session("project123", "ses_tool_test", "/test/path", 1766529546325);
749
750 storage.create_message(
751 "ses_tool_test",
752 "msg_t1",
753 "assistant",
754 1766529546342,
755 Some("model"),
756 );
757 storage.create_text_part("msg_t1", "prt_t1a", "Let me read that file.");
758 storage.create_tool_part("msg_t1", "prt_t1b", "read", "completed");
759
760 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
761
762 assert_eq!(parsed.messages.len(), 1);
763 assert!(parsed.messages[0]
765 .content
766 .contains("Let me read that file."));
767 assert!(parsed.messages[0]
768 .content
769 .contains("[tool: read (completed)]"));
770 }
771
772 #[test]
773 fn test_messages_sorted_by_timestamp() {
774 let storage = TestOpenCodeStorage::new();
775 let session_path =
776 storage.create_session("project123", "ses_sort_test", "/test/path", 1766529546325);
777
778 storage.create_message(
780 "ses_sort_test",
781 "msg_second",
782 "assistant",
783 1766529550000,
784 None,
785 );
786 storage.create_text_part("msg_second", "prt_s", "Second message");
787
788 storage.create_message("ses_sort_test", "msg_first", "user", 1766529546342, None);
789 storage.create_text_part("msg_first", "prt_f", "First message");
790
791 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
792
793 assert_eq!(parsed.messages.len(), 2);
794 assert_eq!(parsed.messages[0].content, "First message");
795 assert_eq!(parsed.messages[1].content, "Second message");
796 }
797
798 #[test]
799 fn test_session_with_no_messages() {
800 let storage = TestOpenCodeStorage::new();
801 let session_path =
802 storage.create_session("project123", "ses_empty", "/test/path", 1766529546325);
803
804 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
805
806 assert!(parsed.messages.is_empty());
807 }
808
809 #[test]
810 fn test_to_storage_models() {
811 let storage = TestOpenCodeStorage::new();
812 let session_path = storage.create_session(
813 "project123",
814 "ses_storage_test",
815 "/Users/test/project",
816 1766529546325,
817 );
818
819 storage.create_message("ses_storage_test", "msg_u1", "user", 1766529546342, None);
820 storage.create_text_part("msg_u1", "prt_u1", "Hello");
821
822 storage.create_message(
823 "ses_storage_test",
824 "msg_a1",
825 "assistant",
826 1766529550000,
827 Some("test-model"),
828 );
829 storage.create_text_part("msg_a1", "prt_a1", "Hi there!");
830
831 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
832 let (session, messages) = parsed.to_storage_models();
833
834 assert_eq!(session.tool, "opencode");
835 assert_eq!(session.tool_version, Some("1.0.193".to_string()));
836 assert_eq!(session.working_directory, "/Users/test/project");
837 assert_eq!(session.model, Some("test-model".to_string()));
838 assert_eq!(session.message_count, 2);
839 assert!(session.source_path.is_some());
840
841 assert_eq!(messages.len(), 2);
842 assert_eq!(messages[0].role, MessageRole::User);
843 assert_eq!(messages[0].index, 0);
844 assert_eq!(messages[1].role, MessageRole::Assistant);
845 assert_eq!(messages[1].index, 1);
846 }
847
848 #[test]
849 fn test_generate_uuid_from_string() {
850 let valid_uuid = "550e8400-e29b-41d4-a716-446655440000";
852 let result = generate_uuid_from_string(valid_uuid);
853 assert_eq!(result.to_string(), valid_uuid);
854
855 let opencode_id = "ses_4b2a247aaffeEmXAKKN3BeRz2j";
857 let result1 = generate_uuid_from_string(opencode_id);
858 let result2 = generate_uuid_from_string(opencode_id);
859 assert_eq!(result1, result2);
860 assert!(!result1.is_nil());
861 }
862
863 #[test]
864 fn test_session_timestamps() {
865 let storage = TestOpenCodeStorage::new();
866 let session_path =
867 storage.create_session("project123", "ses_time_test", "/test/path", 1766529546325);
868
869 storage.create_message("ses_time_test", "msg_t1", "user", 1766529546342, None);
870 storage.create_text_part("msg_t1", "prt_t1", "Hello");
871
872 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
873 let (session, _) = parsed.to_storage_models();
874
875 assert!(session.started_at.timestamp_millis() > 0);
876 assert!(session.ended_at.is_some());
877 }
878
879 #[test]
880 fn test_watcher_parse_source() {
881 let watcher = OpenCodeWatcher;
882 let storage = TestOpenCodeStorage::new();
883 let session_path = storage.create_session(
884 "project123",
885 "ses_watcher_test",
886 "/test/path",
887 1766529546325,
888 );
889
890 storage.create_message("ses_watcher_test", "msg_w1", "user", 1766529546342, None);
891 storage.create_text_part("msg_w1", "prt_w1", "Hello");
892
893 let result = watcher
894 .parse_source(&session_path)
895 .expect("Should parse successfully");
896
897 assert_eq!(result.len(), 1);
898 let (session, messages) = &result[0];
899 assert_eq!(session.tool, "opencode");
900 assert_eq!(messages.len(), 1);
901 }
902
903 #[test]
904 fn test_watcher_parse_source_empty_session() {
905 let watcher = OpenCodeWatcher;
906 let storage = TestOpenCodeStorage::new();
907 let session_path =
908 storage.create_session("project123", "ses_empty_test", "/test/path", 1766529546325);
909
910 let result = watcher
911 .parse_source(&session_path)
912 .expect("Should parse successfully");
913
914 assert!(result.is_empty());
915 }
916
917 #[test]
918 fn test_find_session_files_returns_empty_when_missing() {
919 let result = find_opencode_session_files();
920 assert!(result.is_ok());
921 }
922
923 #[test]
924 fn test_multiple_text_parts_combined() {
925 let storage = TestOpenCodeStorage::new();
926 let session_path =
927 storage.create_session("project123", "ses_multi_part", "/test/path", 1766529546325);
928
929 storage.create_message("ses_multi_part", "msg_mp", "assistant", 1766529546342, None);
930 storage.create_text_part("msg_mp", "prt_mp1", "First part.");
931 storage.create_text_part("msg_mp", "prt_mp2", "Second part.");
932
933 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
934
935 assert_eq!(parsed.messages.len(), 1);
936 assert!(parsed.messages[0].content.contains("First part."));
938 assert!(parsed.messages[0].content.contains("Second part."));
939 }
940
941 #[test]
942 fn test_system_message() {
943 let storage = TestOpenCodeStorage::new();
944 let session_path =
945 storage.create_session("project123", "ses_system", "/test/path", 1766529546325);
946
947 storage.create_message("ses_system", "msg_sys", "system", 1766529546342, None);
948 storage.create_text_part("msg_sys", "prt_sys", "You are a helpful assistant.");
949
950 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
951
952 assert_eq!(parsed.messages.len(), 1);
953 assert_eq!(parsed.messages[0].role, MessageRole::System);
954 }
955
956 #[test]
957 fn test_message_with_empty_parts_dir() {
958 let storage = TestOpenCodeStorage::new();
959 let session_path =
960 storage.create_session("project123", "ses_no_parts", "/test/path", 1766529546325);
961
962 storage.create_message("ses_no_parts", "msg_np", "user", 1766529546342, None);
963 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
966
967 assert_eq!(parsed.messages.len(), 1);
968 assert_eq!(parsed.messages[0].content, "");
969 }
970
971 #[test]
972 fn test_session_without_optional_fields() {
973 let storage = TestOpenCodeStorage::new();
974
975 let session_dir = storage.storage_dir.join("session").join("minimal");
977 fs::create_dir_all(&session_dir).expect("Failed to create session dir");
978
979 let session_path = session_dir.join("ses_minimal.json");
980 let session_json = r#"{"id": "ses_minimal"}"#;
981 fs::write(&session_path, session_json).expect("Failed to write session file");
982
983 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
984
985 assert_eq!(parsed.session_id, "ses_minimal");
986 assert_eq!(parsed.working_directory, ".");
987 assert!(parsed.version.is_none());
988 }
989
990 #[test]
991 fn test_find_session_files_in_storage() {
992 let storage = TestOpenCodeStorage::new();
993
994 storage.create_session("project_a", "ses_a1", "/path/a", 1766529546325);
996 storage.create_session("project_a", "ses_a2", "/path/a", 1766529546325);
997 storage.create_session("project_b", "ses_b1", "/path/b", 1766529546325);
998
999 let session_dir = storage.storage_dir.join("session");
1001 assert!(session_dir.exists());
1002
1003 let mut count = 0;
1005 for project_entry in fs::read_dir(&session_dir).unwrap() {
1006 let project_path = project_entry.unwrap().path();
1007 if project_path.is_dir() {
1008 for file_entry in fs::read_dir(&project_path).unwrap() {
1009 let file_path = file_entry.unwrap().path();
1010 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
1011 if name.starts_with("ses_") && name.ends_with(".json") {
1012 count += 1;
1013 }
1014 }
1015 }
1016 }
1017 }
1018 assert_eq!(count, 3);
1019 }
1020}