lore_cli/capture/watchers/
opencode.rs

1//! OpenCode CLI session parser.
2//!
3//! Parses session files from the OpenCode CLI tool (opencode.ai). OpenCode uses a
4//! multi-file structure with separate directories for sessions, messages, and parts.
5//!
6//! Storage layout:
7//! - Sessions: `~/.local/share/opencode/storage/session/<project-hash>/<session-id>.json`
8//! - Messages: `~/.local/share/opencode/storage/message/<session-id>/msg_<id>.json`
9//! - Parts: `~/.local/share/opencode/storage/part/msg_<id>/prt_<id>.json`
10//!
11//! Each session file contains metadata including project directory and timestamps.
12//! Message files contain role and timing information. Part files contain the actual
13//! text content or tool call information.
14
15use 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
27/// Watcher for OpenCode CLI sessions.
28///
29/// Discovers and parses session files from the OpenCode CLI tool.
30/// Sessions are stored across multiple files in `~/.local/share/opencode/storage/`.
31pub 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
64/// Returns the path to the OpenCode storage directory.
65///
66/// OpenCode uses `~/.local/share/opencode/storage/` on all platforms.
67fn 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/// Raw session structure from OpenCode JSON files.
77#[derive(Debug, Deserialize)]
78#[serde(rename_all = "camelCase")]
79struct RawOpenCodeSession {
80    id: String,
81    #[serde(default)]
82    version: Option<String>,
83    // Parsed for potential use in project identification
84    #[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/// Raw time structure from OpenCode JSON files.
96#[derive(Debug, Deserialize)]
97struct RawOpenCodeTime {
98    created: i64,
99    #[serde(default)]
100    updated: Option<i64>,
101}
102
103/// Raw message structure from OpenCode JSON files.
104#[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    // Parsed for potential use in provider-specific handling
116    #[serde(default, rename = "providerID")]
117    #[allow(dead_code)]
118    provider_id: Option<String>,
119    // For assistant messages, model info may be nested under "model" field
120    #[serde(default)]
121    model: Option<RawOpenCodeModel>,
122}
123
124/// Raw model structure for user messages.
125#[derive(Debug, Deserialize)]
126#[serde(rename_all = "camelCase")]
127struct RawOpenCodeModel {
128    #[serde(default, rename = "modelID")]
129    model_id: Option<String>,
130}
131
132/// Raw message time structure from OpenCode JSON files.
133#[derive(Debug, Deserialize)]
134struct RawOpenCodeMessageTime {
135    created: i64,
136    // Parsed for potential use in duration calculation
137    #[serde(default)]
138    #[allow(dead_code)]
139    completed: Option<i64>,
140}
141
142/// Raw part structure from OpenCode JSON files.
143#[derive(Debug, Deserialize)]
144#[serde(rename_all = "camelCase")]
145struct RawOpenCodePart {
146    #[serde(default)]
147    id: Option<String>,
148    // Parsed for potential use in validation
149    #[serde(default, rename = "sessionID")]
150    #[allow(dead_code)]
151    session_id: Option<String>,
152    // Parsed for potential use in validation
153    #[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/// Raw tool state structure from OpenCode JSON files.
167#[derive(Debug, Deserialize)]
168struct RawOpenCodeToolState {
169    #[serde(default)]
170    status: Option<String>,
171}
172
173/// Parses an OpenCode session from its session file.
174///
175/// This function reads the session metadata and then loads all associated
176/// messages and their parts from the multi-file structure.
177///
178/// # Errors
179///
180/// Returns an error if the session file cannot be read or parsed.
181pub 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    // Get the storage base directory (parent of session/<project-hash>/<session>.json)
188    let storage_dir = session_path
189        .parent() // <project-hash> dir
190        .and_then(|p| p.parent()) // session dir
191        .and_then(|p| p.parent()) // storage dir
192        .unwrap_or_else(|| Path::new("."));
193
194    // Parse session timestamps
195    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    // Load messages for this session
207    let messages = load_session_messages(storage_dir, &raw_session.id)?;
208
209    // Extract model from first assistant message
210    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
228/// Loads all messages for a session from the message directory.
229fn 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                    // Use timestamp for sorting
249                    let sort_key = msg.timestamp.timestamp_millis();
250                    messages.push((sort_key, msg));
251                }
252            }
253        }
254    }
255
256    // Sort by timestamp
257    messages.sort_by_key(|(ts, _)| *ts);
258
259    Ok(messages.into_iter().map(|(_, msg)| msg).collect())
260}
261
262/// Parses a single message file and loads its parts.
263fn 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    // Get model from either top-level modelId or nested model.modelId
282    let model = raw
283        .model_id
284        .or_else(|| raw.model.as_ref().and_then(|m| m.model_id.clone()));
285
286    // Load parts for this message
287    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
299/// Loads all parts for a message and combines text parts into content.
300fn 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                    // Collect part ID and content for sorting
317                    if let Some(id) = part.0 {
318                        parts.push((id, part.1));
319                    } else {
320                        // No ID, just append with empty key
321                        parts.push((String::new(), part.1));
322                    }
323                }
324            }
325        }
326    }
327
328    // Sort by part ID to maintain order
329    parts.sort_by(|a, b| a.0.cmp(&b.0));
330
331    // Combine text content
332    let content: Vec<String> = parts.into_iter().map(|(_, text)| text).collect();
333    Ok(content.join("\n"))
334}
335
336/// Parses a single part file and extracts text content.
337///
338/// Returns (part_id, text_content). Tool parts are converted to a summary string.
339fn 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            // Include tool name and status as a summary
348            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/// Intermediate representation of a parsed OpenCode session.
363#[derive(Debug)]
364pub struct ParsedOpenCodeSession {
365    pub session_id: String,
366    pub version: Option<String>,
367    // Parsed for potential future use in session display
368    #[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    /// Converts this parsed session to storage-ready models.
380    pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
381        // Generate a deterministic UUID from the session ID string
382        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        // Build message ID map for consistent UUIDs
407        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
439/// Generates a deterministic UUID from a string by hashing it.
440///
441/// OpenCode uses identifiers like "ses_4b2a247aaffeEmXAKKN3BeRz2j" which are not
442/// valid UUIDs. This function generates a consistent UUID using a simple hash
443/// of the input string to ensure the same session ID always produces the same UUID.
444fn generate_uuid_from_string(s: &str) -> Uuid {
445    // First try to parse as a valid UUID
446    if let Ok(uuid) = Uuid::parse_str(s) {
447        return uuid;
448    }
449
450    // Generate a deterministic UUID by hashing the string.
451    // We use a simple approach: hash the bytes and construct a UUID from the result.
452    // This creates a "version 4 variant 1" UUID from the hash bytes.
453    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    // Hash again with a different seed for more bytes
461    let mut hasher2 = DefaultHasher::new();
462    hash1.hash(&mut hasher2);
463    let hash2 = hasher2.finish();
464
465    // Combine the two hashes into 16 bytes for a UUID
466    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    // Set version (4) and variant (1) bits to make it a valid UUID
471    bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
472    bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 1
473
474    Uuid::from_bytes(bytes)
475}
476
477/// Intermediate representation of a parsed OpenCode message.
478#[derive(Debug)]
479pub struct ParsedOpenCodeMessage {
480    pub id: String,
481    // Stored for potential validation but not used in conversion
482    #[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
490/// Discovers all OpenCode session files.
491///
492/// Scans `~/.local/share/opencode/storage/session/*/ses_*.json` for session files.
493pub 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    // Walk storage/session/<project-hash>/ses_*.json
504    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    /// Creates a test OpenCode storage structure with session, message, and part files.
532    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            // Note: OpenCode uses messageID with capital ID
612            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            // Note: OpenCode uses messageID with capital ID
628            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        // Create a user message
675        storage.create_message("ses_test123", "msg_user1", "user", 1766529546342, None);
676        storage.create_text_part("msg_user1", "prt_user1", "Hello, OpenCode!");
677
678        // Create an assistant message
679        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        // Content should include both text and tool summary
763        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        // Create messages out of order
778        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        // Valid UUID should pass through
850        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        // OpenCode-style ID should generate consistent UUID
855        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        // Parts should be combined with newlines
936        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        // Don't create any parts
963
964        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        // Create a minimal session file manually
975        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        // Create multiple sessions in different project directories
994        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        // Manually check the session directory exists
999        let session_dir = storage.storage_dir.join("session");
1000        assert!(session_dir.exists());
1001
1002        // Count session files
1003        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}