lore_cli/capture/watchers/
claude_code.rs

1//! Claude Code session parser.
2//!
3//! Parses the JSONL format used by Claude Code (as of version 2.0.72, December 2025).
4//! Session files are stored in `~/.claude/projects/<project-hash>/<session-uuid>.jsonl`.
5//!
6//! Each line in a JSONL file represents a message or system event. This parser
7//! extracts user and assistant messages while skipping file history snapshots
8//! and sidechain (agent) messages.
9
10use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::fs::File;
15use std::io::{BufRead, BufReader};
16use std::path::{Path, PathBuf};
17use uuid::Uuid;
18
19use crate::storage::models::{ContentBlock, Message, MessageContent, MessageRole, Session};
20
21use super::{Watcher, WatcherInfo};
22
23/// Watcher for Claude Code sessions.
24///
25/// Discovers and parses JSONL session files from the Claude Code CLI tool.
26/// Sessions are stored in `~/.claude/projects/<project-hash>/<session-uuid>.jsonl`.
27pub struct ClaudeCodeWatcher;
28
29impl Watcher for ClaudeCodeWatcher {
30    fn info(&self) -> WatcherInfo {
31        WatcherInfo {
32            name: "claude-code",
33            description: "Claude Code CLI sessions",
34            default_paths: vec![claude_projects_dir()],
35        }
36    }
37
38    fn is_available(&self) -> bool {
39        claude_projects_dir().exists()
40    }
41
42    fn find_sources(&self) -> Result<Vec<PathBuf>> {
43        find_session_files()
44    }
45
46    fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
47        let parsed = parse_session_file(path)?;
48        if parsed.messages.is_empty() {
49            return Ok(vec![]);
50        }
51        let (session, messages) = parsed.to_storage_models();
52        Ok(vec![(session, messages)])
53    }
54
55    fn watch_paths(&self) -> Vec<PathBuf> {
56        vec![claude_projects_dir()]
57    }
58}
59
60/// Returns the path to the Claude Code projects directory.
61///
62/// This is typically `~/.claude/projects/`.
63fn claude_projects_dir() -> PathBuf {
64    dirs::home_dir()
65        .unwrap_or_else(|| PathBuf::from("."))
66        .join(".claude")
67        .join("projects")
68}
69
70/// Raw message as stored in Claude Code JSONL files
71#[derive(Debug, Deserialize)]
72#[serde(rename_all = "camelCase")]
73struct RawMessage {
74    #[serde(rename = "type")]
75    msg_type: String,
76
77    session_id: String,
78    uuid: String,
79    parent_uuid: Option<String>,
80    timestamp: String,
81
82    #[serde(default)]
83    cwd: Option<String>,
84
85    #[serde(default)]
86    git_branch: Option<String>,
87
88    #[serde(default)]
89    version: Option<String>,
90
91    #[serde(default)]
92    message: Option<RawMessageContent>,
93
94    // For agent/sidechain messages
95    #[serde(default)]
96    #[allow(dead_code)]
97    agent_id: Option<String>,
98
99    #[serde(default)]
100    is_sidechain: Option<bool>,
101}
102
103#[derive(Debug, Deserialize)]
104#[serde(rename_all = "camelCase")]
105struct RawMessageContent {
106    role: String,
107
108    #[serde(default)]
109    model: Option<String>,
110
111    content: RawContent,
112}
113
114#[derive(Debug, Deserialize)]
115#[serde(untagged)]
116enum RawContent {
117    Text(String),
118    Blocks(Vec<RawContentBlock>),
119}
120
121#[derive(Debug, Deserialize)]
122#[serde(tag = "type", rename_all = "snake_case")]
123enum RawContentBlock {
124    Text {
125        text: String,
126    },
127    Thinking {
128        thinking: String,
129        #[serde(default)]
130        #[allow(dead_code)]
131        signature: Option<String>,
132    },
133    ToolUse {
134        id: String,
135        name: String,
136        input: serde_json::Value,
137    },
138    ToolResult {
139        tool_use_id: String,
140        content: String,
141        #[serde(default)]
142        is_error: bool,
143    },
144}
145
146/// Parses a Claude Code JSONL session file.
147///
148/// Reads each line of the file and extracts user and assistant messages.
149/// Skips file history snapshots, sidechain messages, and malformed lines.
150///
151/// # Errors
152///
153/// Returns an error if the file cannot be opened. Individual malformed
154/// lines are logged and skipped rather than causing a parse failure.
155pub fn parse_session_file(path: &Path) -> Result<ParsedSession> {
156    let file = File::open(path).context("Failed to open session file")?;
157    let reader = BufReader::new(file);
158
159    let mut messages: Vec<ParsedMessage> = Vec::new();
160    let mut session_id: Option<String> = None;
161    let mut tool_version: Option<String> = None;
162    let mut cwd: Option<String> = None;
163    let mut git_branch: Option<String> = None;
164    let mut model: Option<String> = None;
165
166    for (line_num, line) in reader.lines().enumerate() {
167        let line = line.context(format!("Failed to read line {}", line_num + 1))?;
168
169        if line.trim().is_empty() {
170            continue;
171        }
172
173        // Try to parse as a message
174        let raw: RawMessage = match serde_json::from_str(&line) {
175            Ok(m) => m,
176            Err(e) => {
177                tracing::debug!("Skipping unparseable line {}: {}", line_num + 1, e);
178                continue;
179            }
180        };
181
182        // Skip file-history-snapshot and other non-message types
183        if raw.msg_type != "user" && raw.msg_type != "assistant" {
184            continue;
185        }
186
187        // Skip sidechain/agent messages for now (they're in separate files anyway)
188        if raw.is_sidechain.unwrap_or(false) {
189            continue;
190        }
191
192        // Extract session metadata from first message
193        if session_id.is_none() {
194            session_id = Some(raw.session_id.clone());
195        }
196        if tool_version.is_none() {
197            tool_version = raw.version.clone();
198        }
199        if cwd.is_none() {
200            cwd = raw.cwd.clone();
201        }
202        if git_branch.is_none() {
203            git_branch = raw.git_branch.clone();
204        }
205
206        // Parse the message content
207        if let Some(ref msg_content) = raw.message {
208            // Capture model from first assistant message
209            if model.is_none() && msg_content.role == "assistant" {
210                model = msg_content.model.clone();
211            }
212
213            let content = parse_content(&msg_content.content);
214            let role = match msg_content.role.as_str() {
215                "user" => MessageRole::User,
216                "assistant" => MessageRole::Assistant,
217                "system" => MessageRole::System,
218                _ => MessageRole::User,
219            };
220
221            let timestamp = DateTime::parse_from_rfc3339(&raw.timestamp)
222                .map(|t| t.with_timezone(&Utc))
223                .unwrap_or_else(|_| Utc::now());
224
225            messages.push(ParsedMessage {
226                uuid: raw.uuid,
227                parent_uuid: raw.parent_uuid,
228                timestamp,
229                role,
230                content,
231                model: msg_content.model.clone(),
232                git_branch: raw.git_branch,
233                cwd: raw.cwd,
234            });
235        }
236    }
237
238    Ok(ParsedSession {
239        session_id: session_id.unwrap_or_else(|| {
240            // Try to get from filename
241            path.file_stem()
242                .and_then(|s| s.to_str())
243                .unwrap_or("unknown")
244                .to_string()
245        }),
246        tool_version,
247        cwd: cwd.unwrap_or_else(|| ".".to_string()),
248        git_branch,
249        model,
250        messages,
251        source_path: path.to_string_lossy().to_string(),
252    })
253}
254
255fn parse_content(raw: &RawContent) -> MessageContent {
256    match raw {
257        RawContent::Text(s) => MessageContent::Text(s.clone()),
258        RawContent::Blocks(blocks) => {
259            let parsed: Vec<ContentBlock> = blocks
260                .iter()
261                .map(|b| match b {
262                    RawContentBlock::Text { text } => ContentBlock::Text { text: text.clone() },
263                    RawContentBlock::Thinking { thinking, .. } => ContentBlock::Thinking {
264                        thinking: thinking.clone(),
265                    },
266                    RawContentBlock::ToolUse { id, name, input } => ContentBlock::ToolUse {
267                        id: id.clone(),
268                        name: name.clone(),
269                        input: input.clone(),
270                    },
271                    RawContentBlock::ToolResult {
272                        tool_use_id,
273                        content,
274                        is_error,
275                    } => ContentBlock::ToolResult {
276                        tool_use_id: tool_use_id.clone(),
277                        content: content.clone(),
278                        is_error: *is_error,
279                    },
280                })
281                .collect();
282            MessageContent::Blocks(parsed)
283        }
284    }
285}
286
287/// Intermediate representation of a parsed session.
288///
289/// Contains all extracted data from a Claude Code session file before
290/// conversion to storage models. Use [`to_storage_models`](Self::to_storage_models)
291/// to convert to database-ready structures.
292#[derive(Debug)]
293pub struct ParsedSession {
294    pub session_id: String,
295    pub tool_version: Option<String>,
296    pub cwd: String,
297    pub git_branch: Option<String>,
298    pub model: Option<String>,
299    pub messages: Vec<ParsedMessage>,
300    pub source_path: String,
301}
302
303impl ParsedSession {
304    /// Converts this parsed session to storage-ready models.
305    ///
306    /// Returns a tuple of `(Session, Vec<Message>)` suitable for database insertion.
307    /// Generates UUIDs from the session ID string if valid, otherwise creates new ones.
308    /// Also builds parent-child relationships between messages.
309    pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
310        let session_uuid = Uuid::parse_str(&self.session_id).unwrap_or_else(|_| Uuid::new_v4());
311
312        let started_at = self
313            .messages
314            .first()
315            .map(|m| m.timestamp)
316            .unwrap_or_else(Utc::now);
317
318        let ended_at = self.messages.last().map(|m| m.timestamp);
319
320        let session = Session {
321            id: session_uuid,
322            tool: "claude-code".to_string(),
323            tool_version: self.tool_version.clone(),
324            started_at,
325            ended_at,
326            model: self.model.clone(),
327            working_directory: self.cwd.clone(),
328            git_branch: self.git_branch.clone(),
329            source_path: Some(self.source_path.clone()),
330            message_count: self.messages.len() as i32,
331            machine_id: crate::storage::get_machine_id(),
332        };
333
334        // Build UUID map for parent lookups
335        let uuid_map: HashMap<String, Uuid> = self
336            .messages
337            .iter()
338            .map(|m| {
339                let uuid = Uuid::parse_str(&m.uuid).unwrap_or_else(|_| Uuid::new_v4());
340                (m.uuid.clone(), uuid)
341            })
342            .collect();
343
344        let messages: Vec<Message> = self
345            .messages
346            .iter()
347            .enumerate()
348            .map(|(idx, m)| {
349                let id = *uuid_map.get(&m.uuid).unwrap();
350                let parent_id = m
351                    .parent_uuid
352                    .as_ref()
353                    .and_then(|p| uuid_map.get(p).copied());
354
355                Message {
356                    id,
357                    session_id: session_uuid,
358                    parent_id,
359                    index: idx as i32,
360                    timestamp: m.timestamp,
361                    role: m.role.clone(),
362                    content: m.content.clone(),
363                    model: m.model.clone(),
364                    git_branch: m.git_branch.clone(),
365                    cwd: m.cwd.clone(),
366                }
367            })
368            .collect();
369
370        (session, messages)
371    }
372}
373
374/// Intermediate representation of a parsed message.
375///
376/// Contains message data extracted from a Claude Code JSONL line.
377/// Converted to the storage Message type via ParsedSession::to_storage_models.
378#[derive(Debug)]
379pub struct ParsedMessage {
380    pub uuid: String,
381    pub parent_uuid: Option<String>,
382    pub timestamp: DateTime<Utc>,
383    pub role: MessageRole,
384    pub content: MessageContent,
385    pub model: Option<String>,
386    pub git_branch: Option<String>,
387    pub cwd: Option<String>,
388}
389
390/// Discovers all Claude Code session files in `~/.claude/projects/`.
391///
392/// Scans project directories for UUID-named JSONL files, excluding
393/// agent sidechain files. Returns an empty vector if the Claude
394/// directory does not exist.
395pub fn find_session_files() -> Result<Vec<PathBuf>> {
396    let claude_dir = claude_projects_dir();
397
398    if !claude_dir.exists() {
399        return Ok(Vec::new());
400    }
401
402    let mut files = Vec::new();
403
404    for entry in std::fs::read_dir(&claude_dir)? {
405        let entry = entry?;
406        let path = entry.path();
407
408        if path.is_dir() {
409            // Look for UUID-named JSONL files (not agent-* files for now)
410            for file_entry in std::fs::read_dir(&path)? {
411                let file_entry = file_entry?;
412                let file_path = file_entry.path();
413
414                if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
415                    // Skip agent files and non-jsonl files
416                    if name.starts_with("agent-") {
417                        continue;
418                    }
419                    if !name.ends_with(".jsonl") {
420                        continue;
421                    }
422                    // Check if it looks like a UUID
423                    if name.len() > 40 {
424                        files.push(file_path);
425                    }
426                }
427            }
428        }
429    }
430
431    Ok(files)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use std::io::Write;
438    use tempfile::NamedTempFile;
439
440    // =========================================================================
441    // Helper functions to generate test JSONL lines
442    // =========================================================================
443
444    /// Generate a valid user message JSONL line
445    fn make_user_message(
446        session_id: &str,
447        uuid: &str,
448        parent_uuid: Option<&str>,
449        content: &str,
450    ) -> String {
451        let parent = parent_uuid
452            .map(|p| format!(r#""parentUuid":"{p}","#))
453            .unwrap_or_default();
454        format!(
455            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{uuid}",{parent}"timestamp":"2025-01-15T10:00:00.000Z","cwd":"/test/project","gitBranch":"main","version":"2.0.72","message":{{"role":"user","content":"{content}"}}}}"#
456        )
457    }
458
459    /// Generate a valid assistant message JSONL line
460    fn make_assistant_message(
461        session_id: &str,
462        uuid: &str,
463        parent_uuid: Option<&str>,
464        model: &str,
465        content: &str,
466    ) -> String {
467        let parent = parent_uuid
468            .map(|p| format!(r#""parentUuid": "{p}","#))
469            .unwrap_or_default();
470        format!(
471            r#"{{"type":"assistant","sessionId":"{session_id}","uuid":"{uuid}",{parent}"timestamp":"2025-01-15T10:01:00.000Z","cwd":"/test/project","gitBranch":"main","message":{{"role":"assistant","model":"{model}","content":"{content}"}}}}"#
472        )
473    }
474
475    /// Generate an assistant message with complex content blocks
476    fn make_assistant_message_with_blocks(
477        session_id: &str,
478        uuid: &str,
479        parent_uuid: Option<&str>,
480        model: &str,
481        blocks_json: &str,
482    ) -> String {
483        let parent = parent_uuid
484            .map(|p| format!(r#""parentUuid": "{p}","#))
485            .unwrap_or_default();
486        format!(
487            r#"{{"type":"assistant","sessionId":"{session_id}","uuid":"{uuid}",{parent}"timestamp":"2025-01-15T10:01:00.000Z","cwd":"/test/project","gitBranch":"main","message":{{"role":"assistant","model":"{model}","content":{blocks_json}}}}}"#
488        )
489    }
490
491    /// Generate a system message JSONL line
492    fn make_system_message(session_id: &str, uuid: &str, content: &str) -> String {
493        format!(
494            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T09:59:00.000Z","cwd":"/test/project","message":{{"role":"system","content":"{content}"}}}}"#
495        )
496    }
497
498    /// Generate a file-history-snapshot line (should be skipped)
499    fn make_file_history_snapshot(session_id: &str, uuid: &str) -> String {
500        format!(
501            r#"{{"type":"file-history-snapshot","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T10:00:00.000Z","files":[]}}"#
502        )
503    }
504
505    /// Generate a sidechain message (should be skipped)
506    fn make_sidechain_message(session_id: &str, uuid: &str) -> String {
507        format!(
508            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T10:00:00.000Z","isSidechain":true,"agentId":"agent-123","message":{{"role":"user","content":"sidechain message"}}}}"#
509        )
510    }
511
512    /// Create a temporary JSONL file with given lines
513    fn create_temp_session_file(lines: &[&str]) -> NamedTempFile {
514        let mut file = NamedTempFile::new().expect("Failed to create temp file");
515        for line in lines {
516            writeln!(file, "{line}").expect("Failed to write line");
517        }
518        file.flush().expect("Failed to flush");
519        file
520    }
521
522    // =========================================================================
523    // Existing tests
524    // =========================================================================
525
526    #[test]
527    fn test_parse_raw_content_text() {
528        let raw = RawContent::Text("hello world".to_string());
529        let content = parse_content(&raw);
530        assert!(matches!(content, MessageContent::Text(s) if s == "hello world"));
531    }
532
533    #[test]
534    fn test_parse_raw_content_blocks() {
535        let json = r#"[{"type": "text", "text": "hello"}, {"type": "tool_use", "id": "123", "name": "Bash", "input": {"command": "ls"}}]"#;
536        let blocks: Vec<RawContentBlock> = serde_json::from_str(json).unwrap();
537        let raw = RawContent::Blocks(blocks);
538        let content = parse_content(&raw);
539
540        if let MessageContent::Blocks(blocks) = content {
541            assert_eq!(blocks.len(), 2);
542        } else {
543            panic!("Expected blocks");
544        }
545    }
546
547    // =========================================================================
548    // Unit tests for JSONL parsing (valid input)
549    // =========================================================================
550
551    #[test]
552    fn test_parse_valid_user_message() {
553        let session_id = "550e8400-e29b-41d4-a716-446655440000";
554        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
555        let user_line = make_user_message(session_id, user_uuid, None, "Hello, Claude!");
556
557        let file = create_temp_session_file(&[&user_line]);
558        let parsed = parse_session_file(file.path()).expect("Failed to parse");
559
560        assert_eq!(parsed.messages.len(), 1);
561        assert_eq!(parsed.messages[0].role, MessageRole::User);
562        assert!(
563            matches!(&parsed.messages[0].content, MessageContent::Text(s) if s == "Hello, Claude!")
564        );
565        assert_eq!(parsed.messages[0].uuid, user_uuid);
566    }
567
568    #[test]
569    fn test_parse_valid_assistant_message() {
570        let session_id = "550e8400-e29b-41d4-a716-446655440000";
571        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
572        let assistant_line = make_assistant_message(
573            session_id,
574            assistant_uuid,
575            None,
576            "claude-3-opus",
577            "Hello! How can I help you?",
578        );
579
580        let file = create_temp_session_file(&[&assistant_line]);
581        let parsed = parse_session_file(file.path()).expect("Failed to parse");
582
583        assert_eq!(parsed.messages.len(), 1);
584        assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
585        assert!(
586            matches!(&parsed.messages[0].content, MessageContent::Text(s) if s == "Hello! How can I help you?")
587        );
588        assert_eq!(parsed.messages[0].model, Some("claude-3-opus".to_string()));
589    }
590
591    #[test]
592    fn test_session_metadata_extraction() {
593        let session_id = "550e8400-e29b-41d4-a716-446655440000";
594        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
595        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
596
597        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
598        let assistant_line = make_assistant_message(
599            session_id,
600            assistant_uuid,
601            Some(user_uuid),
602            "claude-opus-4",
603            "Hi there!",
604        );
605
606        let file = create_temp_session_file(&[&user_line, &assistant_line]);
607        let parsed = parse_session_file(file.path()).expect("Failed to parse");
608
609        assert_eq!(parsed.session_id, session_id);
610        assert_eq!(parsed.tool_version, Some("2.0.72".to_string()));
611        assert_eq!(parsed.cwd, "/test/project");
612        assert_eq!(parsed.git_branch, Some("main".to_string()));
613        assert_eq!(parsed.model, Some("claude-opus-4".to_string()));
614    }
615
616    // =========================================================================
617    // Unit tests for malformed JSONL handling
618    // =========================================================================
619
620    #[test]
621    fn test_empty_lines_are_skipped() {
622        let session_id = "550e8400-e29b-41d4-a716-446655440000";
623        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
624        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
625
626        let file = create_temp_session_file(&["", &user_line, "   ", ""]);
627        let parsed = parse_session_file(file.path()).expect("Failed to parse");
628
629        assert_eq!(parsed.messages.len(), 1);
630        assert_eq!(parsed.messages[0].uuid, user_uuid);
631    }
632
633    #[test]
634    fn test_invalid_json_is_gracefully_skipped() {
635        let session_id = "550e8400-e29b-41d4-a716-446655440000";
636        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
637        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
638
639        let invalid_json = r#"{"this is not valid json"#;
640        let another_invalid = r#"just plain text"#;
641        let malformed_structure = r#"{"type": "user", "missing": "fields"}"#;
642
643        let file = create_temp_session_file(&[
644            invalid_json,
645            &user_line,
646            another_invalid,
647            malformed_structure,
648        ]);
649        let parsed = parse_session_file(file.path()).expect("Failed to parse");
650
651        // Should still have parsed the valid message
652        assert_eq!(parsed.messages.len(), 1);
653        assert_eq!(parsed.messages[0].uuid, user_uuid);
654    }
655
656    #[test]
657    fn test_unknown_message_types_are_skipped() {
658        let session_id = "550e8400-e29b-41d4-a716-446655440000";
659        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
660        let snapshot_uuid = "770e8400-e29b-41d4-a716-446655440003";
661
662        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
663        let snapshot_line = make_file_history_snapshot(session_id, snapshot_uuid);
664
665        let file = create_temp_session_file(&[&snapshot_line, &user_line]);
666        let parsed = parse_session_file(file.path()).expect("Failed to parse");
667
668        // Only the user message should be parsed
669        assert_eq!(parsed.messages.len(), 1);
670        assert_eq!(parsed.messages[0].uuid, user_uuid);
671    }
672
673    #[test]
674    fn test_sidechain_messages_are_skipped() {
675        let session_id = "550e8400-e29b-41d4-a716-446655440000";
676        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
677        let sidechain_uuid = "880e8400-e29b-41d4-a716-446655440004";
678
679        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
680        let sidechain_line = make_sidechain_message(session_id, sidechain_uuid);
681
682        let file = create_temp_session_file(&[&user_line, &sidechain_line]);
683        let parsed = parse_session_file(file.path()).expect("Failed to parse");
684
685        assert_eq!(parsed.messages.len(), 1);
686        assert_eq!(parsed.messages[0].uuid, user_uuid);
687    }
688
689    // =========================================================================
690    // Unit tests for message type parsing
691    // =========================================================================
692
693    #[test]
694    fn test_parse_human_user_role() {
695        let session_id = "550e8400-e29b-41d4-a716-446655440000";
696        let uuid = "660e8400-e29b-41d4-a716-446655440001";
697        let user_line = make_user_message(session_id, uuid, None, "User message");
698
699        let file = create_temp_session_file(&[&user_line]);
700        let parsed = parse_session_file(file.path()).expect("Failed to parse");
701
702        assert_eq!(parsed.messages[0].role, MessageRole::User);
703    }
704
705    #[test]
706    fn test_parse_assistant_role_with_model() {
707        let session_id = "550e8400-e29b-41d4-a716-446655440000";
708        let uuid = "660e8400-e29b-41d4-a716-446655440002";
709        let assistant_line =
710            make_assistant_message(session_id, uuid, None, "claude-opus-4-5", "Response");
711
712        let file = create_temp_session_file(&[&assistant_line]);
713        let parsed = parse_session_file(file.path()).expect("Failed to parse");
714
715        assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
716        assert_eq!(
717            parsed.messages[0].model,
718            Some("claude-opus-4-5".to_string())
719        );
720    }
721
722    #[test]
723    fn test_parse_system_role() {
724        let session_id = "550e8400-e29b-41d4-a716-446655440000";
725        let uuid = "660e8400-e29b-41d4-a716-446655440001";
726        let system_line = make_system_message(session_id, uuid, "System instructions");
727
728        let file = create_temp_session_file(&[&system_line]);
729        let parsed = parse_session_file(file.path()).expect("Failed to parse");
730
731        assert_eq!(parsed.messages[0].role, MessageRole::System);
732    }
733
734    #[test]
735    fn test_tool_use_blocks_parsed_correctly() {
736        let session_id = "550e8400-e29b-41d4-a716-446655440000";
737        let uuid = "660e8400-e29b-41d4-a716-446655440002";
738
739        let blocks_json = r#"[{"type":"text","text":"Let me run that command"},{"type":"tool_use","id":"tool_123","name":"Bash","input":{"command":"ls -la"}}]"#;
740        let assistant_line = make_assistant_message_with_blocks(
741            session_id,
742            uuid,
743            None,
744            "claude-opus-4",
745            blocks_json,
746        );
747
748        let file = create_temp_session_file(&[&assistant_line]);
749        let parsed = parse_session_file(file.path()).expect("Failed to parse");
750
751        assert_eq!(parsed.messages.len(), 1);
752        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
753            assert_eq!(blocks.len(), 2);
754
755            // Check text block
756            assert!(
757                matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me run that command")
758            );
759
760            // Check tool_use block
761            if let ContentBlock::ToolUse { id, name, input } = &blocks[1] {
762                assert_eq!(id, "tool_123");
763                assert_eq!(name, "Bash");
764                assert_eq!(input["command"], "ls -la");
765            } else {
766                panic!("Expected ToolUse block");
767            }
768        } else {
769            panic!("Expected Blocks content");
770        }
771    }
772
773    #[test]
774    fn test_tool_result_blocks_parsed_correctly() {
775        let session_id = "550e8400-e29b-41d4-a716-446655440000";
776        let uuid = "660e8400-e29b-41d4-a716-446655440001";
777
778        // User messages can contain tool_result blocks
779        let user_line = format!(
780            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T10:00:00.000Z","cwd":"/test","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"tool_123","content":"file1.txt\nfile2.txt","is_error":false}}]}}}}"#
781        );
782
783        let file = create_temp_session_file(&[&user_line]);
784        let parsed = parse_session_file(file.path()).expect("Failed to parse");
785
786        assert_eq!(parsed.messages.len(), 1);
787        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
788            assert_eq!(blocks.len(), 1);
789
790            if let ContentBlock::ToolResult {
791                tool_use_id,
792                content,
793                is_error,
794            } = &blocks[0]
795            {
796                assert_eq!(tool_use_id, "tool_123");
797                assert_eq!(content, "file1.txt\nfile2.txt");
798                assert!(!is_error);
799            } else {
800                panic!("Expected ToolResult block");
801            }
802        } else {
803            panic!("Expected Blocks content");
804        }
805    }
806
807    #[test]
808    fn test_tool_result_with_error() {
809        let session_id = "550e8400-e29b-41d4-a716-446655440000";
810        let uuid = "660e8400-e29b-41d4-a716-446655440001";
811
812        let user_line = format!(
813            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T10:00:00.000Z","cwd":"/test","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"tool_456","content":"Command failed: permission denied","is_error":true}}]}}}}"#
814        );
815
816        let file = create_temp_session_file(&[&user_line]);
817        let parsed = parse_session_file(file.path()).expect("Failed to parse");
818
819        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
820            if let ContentBlock::ToolResult { is_error, .. } = &blocks[0] {
821                assert!(*is_error);
822            } else {
823                panic!("Expected ToolResult block");
824            }
825        } else {
826            panic!("Expected Blocks content");
827        }
828    }
829
830    #[test]
831    fn test_thinking_blocks_parsed_correctly() {
832        let session_id = "550e8400-e29b-41d4-a716-446655440000";
833        let uuid = "660e8400-e29b-41d4-a716-446655440002";
834
835        let blocks_json = r#"[{"type":"thinking","thinking":"Let me analyze this problem...","signature":"abc123"},{"type":"text","text":"Here is my answer"}]"#;
836        let assistant_line = make_assistant_message_with_blocks(
837            session_id,
838            uuid,
839            None,
840            "claude-opus-4",
841            blocks_json,
842        );
843
844        let file = create_temp_session_file(&[&assistant_line]);
845        let parsed = parse_session_file(file.path()).expect("Failed to parse");
846
847        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
848            assert_eq!(blocks.len(), 2);
849
850            // Check thinking block
851            if let ContentBlock::Thinking { thinking } = &blocks[0] {
852                assert_eq!(thinking, "Let me analyze this problem...");
853            } else {
854                panic!("Expected Thinking block");
855            }
856
857            // Check text block
858            assert!(
859                matches!(&blocks[1], ContentBlock::Text { text } if text == "Here is my answer")
860            );
861        } else {
862            panic!("Expected Blocks content");
863        }
864    }
865
866    // =========================================================================
867    // Unit tests for session file discovery
868    // =========================================================================
869
870    #[test]
871    fn test_find_session_files_returns_empty_when_claude_dir_missing() {
872        // This test verifies the behavior when ~/.claude doesn't exist
873        // In CI or clean environments, this should return an empty vec
874        // We can't mock the home directory easily, so we test the logic path
875        // by creating a temp directory that doesn't have the expected structure
876
877        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
878        let fake_claude_path = temp_dir.path().join(".claude").join("projects");
879
880        // The directory doesn't exist, so checking it should return false
881        assert!(!fake_claude_path.exists());
882
883        // The actual find_session_files uses dirs::home_dir(), so we verify
884        // the empty return logic exists by calling it - if ~/.claude doesn't
885        // exist it should return Ok(empty vec), not error
886        let result = find_session_files();
887        // This should not error even if ~/.claude doesn't exist
888        assert!(result.is_ok());
889    }
890
891    // =========================================================================
892    // Test to_storage_models conversion
893    // =========================================================================
894
895    #[test]
896    fn test_to_storage_models_creates_correct_session() {
897        let session_id = "550e8400-e29b-41d4-a716-446655440000";
898        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
899        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
900
901        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
902        let assistant_line = make_assistant_message(
903            session_id,
904            assistant_uuid,
905            Some(user_uuid),
906            "claude-opus-4",
907            "Hi there!",
908        );
909
910        let file = create_temp_session_file(&[&user_line, &assistant_line]);
911        let parsed = parse_session_file(file.path()).expect("Failed to parse");
912        let (session, _messages) = parsed.to_storage_models();
913
914        // Verify session
915        assert_eq!(session.id.to_string(), session_id);
916        assert_eq!(session.tool, "claude-code");
917        assert_eq!(session.tool_version, Some("2.0.72".to_string()));
918        assert_eq!(session.model, Some("claude-opus-4".to_string()));
919        assert_eq!(session.working_directory, "/test/project");
920        assert_eq!(session.git_branch, Some("main".to_string()));
921        assert_eq!(session.message_count, 2);
922        assert!(session.source_path.is_some());
923
924        // Verify started_at is from first message
925        assert!(session.started_at.to_rfc3339().contains("2025-01-15T10:00"));
926
927        // Verify ended_at is from last message
928        assert!(session.ended_at.is_some());
929        assert!(session
930            .ended_at
931            .unwrap()
932            .to_rfc3339()
933            .contains("2025-01-15T10:01"));
934    }
935
936    #[test]
937    fn test_to_storage_models_creates_correct_messages() {
938        let session_id = "550e8400-e29b-41d4-a716-446655440000";
939        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
940        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
941
942        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
943        let assistant_line = make_assistant_message(
944            session_id,
945            assistant_uuid,
946            Some(user_uuid),
947            "claude-opus-4",
948            "Hi there!",
949        );
950
951        let file = create_temp_session_file(&[&user_line, &assistant_line]);
952        let parsed = parse_session_file(file.path()).expect("Failed to parse");
953        let (session, messages) = parsed.to_storage_models();
954
955        assert_eq!(messages.len(), 2);
956
957        // Verify first message (user)
958        let user_msg = &messages[0];
959        assert_eq!(user_msg.id.to_string(), user_uuid);
960        assert_eq!(user_msg.session_id, session.id);
961        assert!(user_msg.parent_id.is_none());
962        assert_eq!(user_msg.index, 0);
963        assert_eq!(user_msg.role, MessageRole::User);
964        assert!(user_msg.model.is_none());
965
966        // Verify second message (assistant)
967        let assistant_msg = &messages[1];
968        assert_eq!(assistant_msg.id.to_string(), assistant_uuid);
969        assert_eq!(assistant_msg.session_id, session.id);
970        assert_eq!(assistant_msg.index, 1);
971        assert_eq!(assistant_msg.role, MessageRole::Assistant);
972        assert_eq!(assistant_msg.model, Some("claude-opus-4".to_string()));
973    }
974
975    #[test]
976    fn test_to_storage_models_parent_id_linking() {
977        let session_id = "550e8400-e29b-41d4-a716-446655440000";
978        let uuid1 = "660e8400-e29b-41d4-a716-446655440001";
979        let uuid2 = "660e8400-e29b-41d4-a716-446655440002";
980        let uuid3 = "660e8400-e29b-41d4-a716-446655440003";
981
982        let msg1 = make_user_message(session_id, uuid1, None, "First message");
983        let msg2 = make_assistant_message(session_id, uuid2, Some(uuid1), "claude-opus-4", "Reply");
984        let msg3 = make_user_message(session_id, uuid3, Some(uuid2), "Follow up");
985
986        let file = create_temp_session_file(&[&msg1, &msg2, &msg3]);
987        let parsed = parse_session_file(file.path()).expect("Failed to parse");
988        let (_, messages) = parsed.to_storage_models();
989
990        // First message has no parent
991        assert!(messages[0].parent_id.is_none());
992
993        // Second message's parent is first message
994        assert_eq!(messages[1].parent_id, Some(messages[0].id));
995
996        // Third message's parent is second message
997        assert_eq!(messages[2].parent_id, Some(messages[1].id));
998    }
999
1000    #[test]
1001    fn test_to_storage_models_with_invalid_uuid_generates_new() {
1002        // Test that invalid UUIDs are handled gracefully
1003        let session_id = "not-a-valid-uuid";
1004        let user_uuid = "also-not-valid";
1005
1006        let user_line = format!(
1007            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{user_uuid}","timestamp":"2025-01-15T10:00:00.000Z","cwd":"/test","message":{{"role":"user","content":"Hello"}}}}"#
1008        );
1009
1010        let file = create_temp_session_file(&[&user_line]);
1011        let parsed = parse_session_file(file.path()).expect("Failed to parse");
1012        let (session, messages) = parsed.to_storage_models();
1013
1014        // Should still work with generated UUIDs
1015        assert!(!session.id.is_nil());
1016        assert_eq!(messages.len(), 1);
1017        assert!(!messages[0].id.is_nil());
1018    }
1019
1020    #[test]
1021    fn test_to_storage_models_empty_session() {
1022        // Create a session file with no valid messages
1023        let file = create_temp_session_file(&["", "  ", "invalid json"]);
1024        let parsed = parse_session_file(file.path()).expect("Failed to parse");
1025        let (session, messages) = parsed.to_storage_models();
1026
1027        assert!(messages.is_empty());
1028        assert_eq!(session.message_count, 0);
1029        // When no messages, started_at should be set to now (approximately)
1030        // and ended_at should be None
1031        assert!(session.ended_at.is_none());
1032    }
1033
1034    #[test]
1035    fn test_session_id_from_filename_fallback() {
1036        // When session_id is not in any message, it should use the filename
1037        let invalid_line = r#"{"type":"unknown","sessionId":"","uuid":"test"}"#;
1038
1039        let file = create_temp_session_file(&[invalid_line]);
1040        let parsed = parse_session_file(file.path()).expect("Failed to parse");
1041
1042        // The session_id should be derived from the temp file name
1043        assert!(!parsed.session_id.is_empty());
1044        assert_ne!(parsed.session_id, "");
1045    }
1046
1047    // =========================================================================
1048    // Tests for ClaudeCodeWatcher trait implementation
1049    // =========================================================================
1050
1051    #[test]
1052    fn test_watcher_info() {
1053        use super::Watcher;
1054        let watcher = ClaudeCodeWatcher;
1055        let info = watcher.info();
1056
1057        assert_eq!(info.name, "claude-code");
1058        assert_eq!(info.description, "Claude Code CLI sessions");
1059        assert!(!info.default_paths.is_empty());
1060        assert!(info.default_paths[0].to_string_lossy().contains(".claude"));
1061    }
1062
1063    #[test]
1064    fn test_watcher_watch_paths() {
1065        use super::Watcher;
1066        let watcher = ClaudeCodeWatcher;
1067        let paths = watcher.watch_paths();
1068
1069        assert!(!paths.is_empty());
1070        assert!(paths[0].to_string_lossy().contains(".claude"));
1071    }
1072
1073    #[test]
1074    fn test_watcher_parse_source() {
1075        use super::Watcher;
1076        let watcher = ClaudeCodeWatcher;
1077
1078        let session_id = "550e8400-e29b-41d4-a716-446655440000";
1079        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
1080        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
1081
1082        let file = create_temp_session_file(&[&user_line]);
1083        let path = file.path().to_path_buf();
1084        let result = watcher
1085            .parse_source(&path)
1086            .expect("Should parse successfully");
1087
1088        assert_eq!(result.len(), 1);
1089        let (session, messages) = &result[0];
1090        assert_eq!(session.tool, "claude-code");
1091        assert_eq!(messages.len(), 1);
1092    }
1093
1094    #[test]
1095    fn test_watcher_parse_source_empty_session() {
1096        use super::Watcher;
1097        let watcher = ClaudeCodeWatcher;
1098
1099        // Create a file with no valid messages
1100        let file = create_temp_session_file(&["", "invalid json"]);
1101        let path = file.path().to_path_buf();
1102        let result = watcher
1103            .parse_source(&path)
1104            .expect("Should parse successfully");
1105
1106        // Empty sessions should return empty vec
1107        assert!(result.is_empty());
1108    }
1109}