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            machine_id: crate::storage::get_machine_id(),
405        };
406
407        // Build message ID map for consistent UUIDs
408        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
440/// Generates a deterministic UUID from a string by hashing it.
441///
442/// OpenCode uses identifiers like "ses_4b2a247aaffeEmXAKKN3BeRz2j" which are not
443/// valid UUIDs. This function generates a consistent UUID using a simple hash
444/// of the input string to ensure the same session ID always produces the same UUID.
445fn generate_uuid_from_string(s: &str) -> Uuid {
446    // First try to parse as a valid UUID
447    if let Ok(uuid) = Uuid::parse_str(s) {
448        return uuid;
449    }
450
451    // Generate a deterministic UUID by hashing the string.
452    // We use a simple approach: hash the bytes and construct a UUID from the result.
453    // This creates a "version 4 variant 1" UUID from the hash bytes.
454    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    // Hash again with a different seed for more bytes
462    let mut hasher2 = DefaultHasher::new();
463    hash1.hash(&mut hasher2);
464    let hash2 = hasher2.finish();
465
466    // Combine the two hashes into 16 bytes for a UUID
467    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    // Set version (4) and variant (1) bits to make it a valid UUID
472    bytes[6] = (bytes[6] & 0x0f) | 0x40; // Version 4
473    bytes[8] = (bytes[8] & 0x3f) | 0x80; // Variant 1
474
475    Uuid::from_bytes(bytes)
476}
477
478/// Intermediate representation of a parsed OpenCode message.
479#[derive(Debug)]
480pub struct ParsedOpenCodeMessage {
481    pub id: String,
482    // Stored for potential validation but not used in conversion
483    #[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
491/// Discovers all OpenCode session files.
492///
493/// Scans `~/.local/share/opencode/storage/session/*/ses_*.json` for session files.
494pub 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    // Walk storage/session/<project-hash>/ses_*.json
505    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    /// Creates a test OpenCode storage structure with session, message, and part files.
533    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            // Note: OpenCode uses messageID with capital ID
613            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            // Note: OpenCode uses messageID with capital ID
629            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    // Note: Common watcher trait tests (info, watch_paths, find_sources) are in
645    // src/capture/watchers/test_common.rs to avoid duplication across all watchers.
646    // Only tool-specific parsing tests remain here.
647
648    #[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        // Create a user message
659        storage.create_message("ses_test123", "msg_user1", "user", 1766529546342, None);
660        storage.create_text_part("msg_user1", "prt_user1", "Hello, OpenCode!");
661
662        // Create an assistant message
663        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        // Content should include both text and tool summary
747        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        // Create messages out of order
762        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        // Valid UUID should pass through
834        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        // OpenCode-style ID should generate consistent UUID
839        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        // Parts should be combined with newlines
914        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        // Don't create any parts
941
942        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        // Create a minimal session file manually
953        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        // Create multiple sessions in different project directories
972        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        // Manually check the session directory exists
977        let session_dir = storage.storage_dir.join("session");
978        assert!(session_dir.exists());
979
980        // Count session files
981        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}