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]
649 fn test_parse_simple_session() {
650 let storage = TestOpenCodeStorage::new();
651 let session_path = storage.create_session(
652 "64ba75f0bc0e109e",
653 "ses_test123",
654 "/Users/test/project",
655 1766529546325,
656 );
657
658 storage.create_message("ses_test123", "msg_user1", "user", 1766529546342, None);
660 storage.create_text_part("msg_user1", "prt_user1", "Hello, OpenCode!");
661
662 storage.create_message(
664 "ses_test123",
665 "msg_asst1",
666 "assistant",
667 1766529550000,
668 Some("big-pickle"),
669 );
670 storage.create_text_part("msg_asst1", "prt_asst1", "Hello! How can I help you?");
671
672 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
673
674 assert_eq!(parsed.session_id, "ses_test123");
675 assert_eq!(parsed.version, Some("1.0.193".to_string()));
676 assert_eq!(parsed.working_directory, "/Users/test/project");
677 assert_eq!(parsed.messages.len(), 2);
678 assert_eq!(parsed.messages[0].role, MessageRole::User);
679 assert_eq!(parsed.messages[0].content, "Hello, OpenCode!");
680 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
681 assert_eq!(parsed.messages[1].model, Some("big-pickle".to_string()));
682 }
683
684 #[test]
685 fn test_parse_user_message() {
686 let storage = TestOpenCodeStorage::new();
687 let session_path =
688 storage.create_session("project123", "ses_user_test", "/test/path", 1766529546325);
689
690 storage.create_message("ses_user_test", "msg_u1", "user", 1766529546342, None);
691 storage.create_text_part("msg_u1", "prt_u1", "What is Rust?");
692
693 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
694
695 assert_eq!(parsed.messages.len(), 1);
696 assert_eq!(parsed.messages[0].role, MessageRole::User);
697 assert_eq!(parsed.messages[0].content, "What is Rust?");
698 }
699
700 #[test]
701 fn test_parse_assistant_message_with_model() {
702 let storage = TestOpenCodeStorage::new();
703 let session_path =
704 storage.create_session("project123", "ses_asst_test", "/test/path", 1766529546325);
705
706 storage.create_message(
707 "ses_asst_test",
708 "msg_a1",
709 "assistant",
710 1766529546342,
711 Some("claude-opus-4"),
712 );
713 storage.create_text_part(
714 "msg_a1",
715 "prt_a1",
716 "Rust is a systems programming language.",
717 );
718
719 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
720
721 assert_eq!(parsed.messages.len(), 1);
722 assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
723 assert_eq!(parsed.messages[0].model, Some("claude-opus-4".to_string()));
724 assert_eq!(parsed.model, Some("claude-opus-4".to_string()));
725 }
726
727 #[test]
728 fn test_parse_tool_parts() {
729 let storage = TestOpenCodeStorage::new();
730 let session_path =
731 storage.create_session("project123", "ses_tool_test", "/test/path", 1766529546325);
732
733 storage.create_message(
734 "ses_tool_test",
735 "msg_t1",
736 "assistant",
737 1766529546342,
738 Some("model"),
739 );
740 storage.create_text_part("msg_t1", "prt_t1a", "Let me read that file.");
741 storage.create_tool_part("msg_t1", "prt_t1b", "read", "completed");
742
743 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
744
745 assert_eq!(parsed.messages.len(), 1);
746 assert!(parsed.messages[0]
748 .content
749 .contains("Let me read that file."));
750 assert!(parsed.messages[0]
751 .content
752 .contains("[tool: read (completed)]"));
753 }
754
755 #[test]
756 fn test_messages_sorted_by_timestamp() {
757 let storage = TestOpenCodeStorage::new();
758 let session_path =
759 storage.create_session("project123", "ses_sort_test", "/test/path", 1766529546325);
760
761 storage.create_message(
763 "ses_sort_test",
764 "msg_second",
765 "assistant",
766 1766529550000,
767 None,
768 );
769 storage.create_text_part("msg_second", "prt_s", "Second message");
770
771 storage.create_message("ses_sort_test", "msg_first", "user", 1766529546342, None);
772 storage.create_text_part("msg_first", "prt_f", "First message");
773
774 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
775
776 assert_eq!(parsed.messages.len(), 2);
777 assert_eq!(parsed.messages[0].content, "First message");
778 assert_eq!(parsed.messages[1].content, "Second message");
779 }
780
781 #[test]
782 fn test_session_with_no_messages() {
783 let storage = TestOpenCodeStorage::new();
784 let session_path =
785 storage.create_session("project123", "ses_empty", "/test/path", 1766529546325);
786
787 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
788
789 assert!(parsed.messages.is_empty());
790 }
791
792 #[test]
793 fn test_to_storage_models() {
794 let storage = TestOpenCodeStorage::new();
795 let session_path = storage.create_session(
796 "project123",
797 "ses_storage_test",
798 "/Users/test/project",
799 1766529546325,
800 );
801
802 storage.create_message("ses_storage_test", "msg_u1", "user", 1766529546342, None);
803 storage.create_text_part("msg_u1", "prt_u1", "Hello");
804
805 storage.create_message(
806 "ses_storage_test",
807 "msg_a1",
808 "assistant",
809 1766529550000,
810 Some("test-model"),
811 );
812 storage.create_text_part("msg_a1", "prt_a1", "Hi there!");
813
814 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
815 let (session, messages) = parsed.to_storage_models();
816
817 assert_eq!(session.tool, "opencode");
818 assert_eq!(session.tool_version, Some("1.0.193".to_string()));
819 assert_eq!(session.working_directory, "/Users/test/project");
820 assert_eq!(session.model, Some("test-model".to_string()));
821 assert_eq!(session.message_count, 2);
822 assert!(session.source_path.is_some());
823
824 assert_eq!(messages.len(), 2);
825 assert_eq!(messages[0].role, MessageRole::User);
826 assert_eq!(messages[0].index, 0);
827 assert_eq!(messages[1].role, MessageRole::Assistant);
828 assert_eq!(messages[1].index, 1);
829 }
830
831 #[test]
832 fn test_generate_uuid_from_string() {
833 let valid_uuid = "550e8400-e29b-41d4-a716-446655440000";
835 let result = generate_uuid_from_string(valid_uuid);
836 assert_eq!(result.to_string(), valid_uuid);
837
838 let opencode_id = "ses_4b2a247aaffeEmXAKKN3BeRz2j";
840 let result1 = generate_uuid_from_string(opencode_id);
841 let result2 = generate_uuid_from_string(opencode_id);
842 assert_eq!(result1, result2);
843 assert!(!result1.is_nil());
844 }
845
846 #[test]
847 fn test_session_timestamps() {
848 let storage = TestOpenCodeStorage::new();
849 let session_path =
850 storage.create_session("project123", "ses_time_test", "/test/path", 1766529546325);
851
852 storage.create_message("ses_time_test", "msg_t1", "user", 1766529546342, None);
853 storage.create_text_part("msg_t1", "prt_t1", "Hello");
854
855 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
856 let (session, _) = parsed.to_storage_models();
857
858 assert!(session.started_at.timestamp_millis() > 0);
859 assert!(session.ended_at.is_some());
860 }
861
862 #[test]
863 fn test_watcher_parse_source() {
864 let watcher = OpenCodeWatcher;
865 let storage = TestOpenCodeStorage::new();
866 let session_path = storage.create_session(
867 "project123",
868 "ses_watcher_test",
869 "/test/path",
870 1766529546325,
871 );
872
873 storage.create_message("ses_watcher_test", "msg_w1", "user", 1766529546342, None);
874 storage.create_text_part("msg_w1", "prt_w1", "Hello");
875
876 let result = watcher
877 .parse_source(&session_path)
878 .expect("Should parse successfully");
879
880 assert_eq!(result.len(), 1);
881 let (session, messages) = &result[0];
882 assert_eq!(session.tool, "opencode");
883 assert_eq!(messages.len(), 1);
884 }
885
886 #[test]
887 fn test_watcher_parse_source_empty_session() {
888 let watcher = OpenCodeWatcher;
889 let storage = TestOpenCodeStorage::new();
890 let session_path =
891 storage.create_session("project123", "ses_empty_test", "/test/path", 1766529546325);
892
893 let result = watcher
894 .parse_source(&session_path)
895 .expect("Should parse successfully");
896
897 assert!(result.is_empty());
898 }
899
900 #[test]
901 fn test_multiple_text_parts_combined() {
902 let storage = TestOpenCodeStorage::new();
903 let session_path =
904 storage.create_session("project123", "ses_multi_part", "/test/path", 1766529546325);
905
906 storage.create_message("ses_multi_part", "msg_mp", "assistant", 1766529546342, None);
907 storage.create_text_part("msg_mp", "prt_mp1", "First part.");
908 storage.create_text_part("msg_mp", "prt_mp2", "Second part.");
909
910 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
911
912 assert_eq!(parsed.messages.len(), 1);
913 assert!(parsed.messages[0].content.contains("First part."));
915 assert!(parsed.messages[0].content.contains("Second part."));
916 }
917
918 #[test]
919 fn test_system_message() {
920 let storage = TestOpenCodeStorage::new();
921 let session_path =
922 storage.create_session("project123", "ses_system", "/test/path", 1766529546325);
923
924 storage.create_message("ses_system", "msg_sys", "system", 1766529546342, None);
925 storage.create_text_part("msg_sys", "prt_sys", "You are a helpful assistant.");
926
927 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
928
929 assert_eq!(parsed.messages.len(), 1);
930 assert_eq!(parsed.messages[0].role, MessageRole::System);
931 }
932
933 #[test]
934 fn test_message_with_empty_parts_dir() {
935 let storage = TestOpenCodeStorage::new();
936 let session_path =
937 storage.create_session("project123", "ses_no_parts", "/test/path", 1766529546325);
938
939 storage.create_message("ses_no_parts", "msg_np", "user", 1766529546342, None);
940 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
943
944 assert_eq!(parsed.messages.len(), 1);
945 assert_eq!(parsed.messages[0].content, "");
946 }
947
948 #[test]
949 fn test_session_without_optional_fields() {
950 let storage = TestOpenCodeStorage::new();
951
952 let session_dir = storage.storage_dir.join("session").join("minimal");
954 fs::create_dir_all(&session_dir).expect("Failed to create session dir");
955
956 let session_path = session_dir.join("ses_minimal.json");
957 let session_json = r#"{"id": "ses_minimal"}"#;
958 fs::write(&session_path, session_json).expect("Failed to write session file");
959
960 let parsed = parse_opencode_session(&session_path).expect("Failed to parse");
961
962 assert_eq!(parsed.session_id, "ses_minimal");
963 assert_eq!(parsed.working_directory, ".");
964 assert!(parsed.version.is_none());
965 }
966
967 #[test]
968 fn test_find_session_files_in_storage() {
969 let storage = TestOpenCodeStorage::new();
970
971 storage.create_session("project_a", "ses_a1", "/path/a", 1766529546325);
973 storage.create_session("project_a", "ses_a2", "/path/a", 1766529546325);
974 storage.create_session("project_b", "ses_b1", "/path/b", 1766529546325);
975
976 let session_dir = storage.storage_dir.join("session");
978 assert!(session_dir.exists());
979
980 let mut count = 0;
982 for project_entry in fs::read_dir(&session_dir).unwrap() {
983 let project_path = project_entry.unwrap().path();
984 if project_path.is_dir() {
985 for file_entry in fs::read_dir(&project_path).unwrap() {
986 let file_path = file_entry.unwrap().path();
987 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
988 if name.starts_with("ses_") && name.ends_with(".json") {
989 count += 1;
990 }
991 }
992 }
993 }
994 }
995 assert_eq!(count, 3);
996 }
997}