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        };
332
333        // Build UUID map for parent lookups
334        let uuid_map: HashMap<String, Uuid> = self
335            .messages
336            .iter()
337            .map(|m| {
338                let uuid = Uuid::parse_str(&m.uuid).unwrap_or_else(|_| Uuid::new_v4());
339                (m.uuid.clone(), uuid)
340            })
341            .collect();
342
343        let messages: Vec<Message> = self
344            .messages
345            .iter()
346            .enumerate()
347            .map(|(idx, m)| {
348                let id = *uuid_map.get(&m.uuid).unwrap();
349                let parent_id = m
350                    .parent_uuid
351                    .as_ref()
352                    .and_then(|p| uuid_map.get(p).copied());
353
354                Message {
355                    id,
356                    session_id: session_uuid,
357                    parent_id,
358                    index: idx as i32,
359                    timestamp: m.timestamp,
360                    role: m.role.clone(),
361                    content: m.content.clone(),
362                    model: m.model.clone(),
363                    git_branch: m.git_branch.clone(),
364                    cwd: m.cwd.clone(),
365                }
366            })
367            .collect();
368
369        (session, messages)
370    }
371}
372
373/// Intermediate representation of a parsed message.
374///
375/// Contains message data extracted from a Claude Code JSONL line.
376/// Converted to the storage Message type via ParsedSession::to_storage_models.
377#[derive(Debug)]
378pub struct ParsedMessage {
379    pub uuid: String,
380    pub parent_uuid: Option<String>,
381    pub timestamp: DateTime<Utc>,
382    pub role: MessageRole,
383    pub content: MessageContent,
384    pub model: Option<String>,
385    pub git_branch: Option<String>,
386    pub cwd: Option<String>,
387}
388
389/// Discovers all Claude Code session files in `~/.claude/projects/`.
390///
391/// Scans project directories for UUID-named JSONL files, excluding
392/// agent sidechain files. Returns an empty vector if the Claude
393/// directory does not exist.
394pub fn find_session_files() -> Result<Vec<PathBuf>> {
395    let claude_dir = claude_projects_dir();
396
397    if !claude_dir.exists() {
398        return Ok(Vec::new());
399    }
400
401    let mut files = Vec::new();
402
403    for entry in std::fs::read_dir(&claude_dir)? {
404        let entry = entry?;
405        let path = entry.path();
406
407        if path.is_dir() {
408            // Look for UUID-named JSONL files (not agent-* files for now)
409            for file_entry in std::fs::read_dir(&path)? {
410                let file_entry = file_entry?;
411                let file_path = file_entry.path();
412
413                if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
414                    // Skip agent files and non-jsonl files
415                    if name.starts_with("agent-") {
416                        continue;
417                    }
418                    if !name.ends_with(".jsonl") {
419                        continue;
420                    }
421                    // Check if it looks like a UUID
422                    if name.len() > 40 {
423                        files.push(file_path);
424                    }
425                }
426            }
427        }
428    }
429
430    Ok(files)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::io::Write;
437    use tempfile::NamedTempFile;
438
439    // =========================================================================
440    // Helper functions to generate test JSONL lines
441    // =========================================================================
442
443    /// Generate a valid user message JSONL line
444    fn make_user_message(
445        session_id: &str,
446        uuid: &str,
447        parent_uuid: Option<&str>,
448        content: &str,
449    ) -> String {
450        let parent = parent_uuid
451            .map(|p| format!(r#""parentUuid":"{p}","#))
452            .unwrap_or_default();
453        format!(
454            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}"}}}}"#
455        )
456    }
457
458    /// Generate a valid assistant message JSONL line
459    fn make_assistant_message(
460        session_id: &str,
461        uuid: &str,
462        parent_uuid: Option<&str>,
463        model: &str,
464        content: &str,
465    ) -> String {
466        let parent = parent_uuid
467            .map(|p| format!(r#""parentUuid": "{p}","#))
468            .unwrap_or_default();
469        format!(
470            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}"}}}}"#
471        )
472    }
473
474    /// Generate an assistant message with complex content blocks
475    fn make_assistant_message_with_blocks(
476        session_id: &str,
477        uuid: &str,
478        parent_uuid: Option<&str>,
479        model: &str,
480        blocks_json: &str,
481    ) -> String {
482        let parent = parent_uuid
483            .map(|p| format!(r#""parentUuid": "{p}","#))
484            .unwrap_or_default();
485        format!(
486            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}}}}}"#
487        )
488    }
489
490    /// Generate a system message JSONL line
491    fn make_system_message(session_id: &str, uuid: &str, content: &str) -> String {
492        format!(
493            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T09:59:00.000Z","cwd":"/test/project","message":{{"role":"system","content":"{content}"}}}}"#
494        )
495    }
496
497    /// Generate a file-history-snapshot line (should be skipped)
498    fn make_file_history_snapshot(session_id: &str, uuid: &str) -> String {
499        format!(
500            r#"{{"type":"file-history-snapshot","sessionId":"{session_id}","uuid":"{uuid}","timestamp":"2025-01-15T10:00:00.000Z","files":[]}}"#
501        )
502    }
503
504    /// Generate a sidechain message (should be skipped)
505    fn make_sidechain_message(session_id: &str, uuid: &str) -> String {
506        format!(
507            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"}}}}"#
508        )
509    }
510
511    /// Create a temporary JSONL file with given lines
512    fn create_temp_session_file(lines: &[&str]) -> NamedTempFile {
513        let mut file = NamedTempFile::new().expect("Failed to create temp file");
514        for line in lines {
515            writeln!(file, "{line}").expect("Failed to write line");
516        }
517        file.flush().expect("Failed to flush");
518        file
519    }
520
521    // =========================================================================
522    // Existing tests
523    // =========================================================================
524
525    #[test]
526    fn test_parse_raw_content_text() {
527        let raw = RawContent::Text("hello world".to_string());
528        let content = parse_content(&raw);
529        assert!(matches!(content, MessageContent::Text(s) if s == "hello world"));
530    }
531
532    #[test]
533    fn test_parse_raw_content_blocks() {
534        let json = r#"[{"type": "text", "text": "hello"}, {"type": "tool_use", "id": "123", "name": "Bash", "input": {"command": "ls"}}]"#;
535        let blocks: Vec<RawContentBlock> = serde_json::from_str(json).unwrap();
536        let raw = RawContent::Blocks(blocks);
537        let content = parse_content(&raw);
538
539        if let MessageContent::Blocks(blocks) = content {
540            assert_eq!(blocks.len(), 2);
541        } else {
542            panic!("Expected blocks");
543        }
544    }
545
546    // =========================================================================
547    // Unit tests for JSONL parsing (valid input)
548    // =========================================================================
549
550    #[test]
551    fn test_parse_valid_user_message() {
552        let session_id = "550e8400-e29b-41d4-a716-446655440000";
553        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
554        let user_line = make_user_message(session_id, user_uuid, None, "Hello, Claude!");
555
556        let file = create_temp_session_file(&[&user_line]);
557        let parsed = parse_session_file(file.path()).expect("Failed to parse");
558
559        assert_eq!(parsed.messages.len(), 1);
560        assert_eq!(parsed.messages[0].role, MessageRole::User);
561        assert!(
562            matches!(&parsed.messages[0].content, MessageContent::Text(s) if s == "Hello, Claude!")
563        );
564        assert_eq!(parsed.messages[0].uuid, user_uuid);
565    }
566
567    #[test]
568    fn test_parse_valid_assistant_message() {
569        let session_id = "550e8400-e29b-41d4-a716-446655440000";
570        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
571        let assistant_line = make_assistant_message(
572            session_id,
573            assistant_uuid,
574            None,
575            "claude-3-opus",
576            "Hello! How can I help you?",
577        );
578
579        let file = create_temp_session_file(&[&assistant_line]);
580        let parsed = parse_session_file(file.path()).expect("Failed to parse");
581
582        assert_eq!(parsed.messages.len(), 1);
583        assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
584        assert!(
585            matches!(&parsed.messages[0].content, MessageContent::Text(s) if s == "Hello! How can I help you?")
586        );
587        assert_eq!(parsed.messages[0].model, Some("claude-3-opus".to_string()));
588    }
589
590    #[test]
591    fn test_session_metadata_extraction() {
592        let session_id = "550e8400-e29b-41d4-a716-446655440000";
593        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
594        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
595
596        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
597        let assistant_line = make_assistant_message(
598            session_id,
599            assistant_uuid,
600            Some(user_uuid),
601            "claude-opus-4",
602            "Hi there!",
603        );
604
605        let file = create_temp_session_file(&[&user_line, &assistant_line]);
606        let parsed = parse_session_file(file.path()).expect("Failed to parse");
607
608        assert_eq!(parsed.session_id, session_id);
609        assert_eq!(parsed.tool_version, Some("2.0.72".to_string()));
610        assert_eq!(parsed.cwd, "/test/project");
611        assert_eq!(parsed.git_branch, Some("main".to_string()));
612        assert_eq!(parsed.model, Some("claude-opus-4".to_string()));
613    }
614
615    // =========================================================================
616    // Unit tests for malformed JSONL handling
617    // =========================================================================
618
619    #[test]
620    fn test_empty_lines_are_skipped() {
621        let session_id = "550e8400-e29b-41d4-a716-446655440000";
622        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
623        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
624
625        let file = create_temp_session_file(&["", &user_line, "   ", ""]);
626        let parsed = parse_session_file(file.path()).expect("Failed to parse");
627
628        assert_eq!(parsed.messages.len(), 1);
629        assert_eq!(parsed.messages[0].uuid, user_uuid);
630    }
631
632    #[test]
633    fn test_invalid_json_is_gracefully_skipped() {
634        let session_id = "550e8400-e29b-41d4-a716-446655440000";
635        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
636        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
637
638        let invalid_json = r#"{"this is not valid json"#;
639        let another_invalid = r#"just plain text"#;
640        let malformed_structure = r#"{"type": "user", "missing": "fields"}"#;
641
642        let file = create_temp_session_file(&[
643            invalid_json,
644            &user_line,
645            another_invalid,
646            malformed_structure,
647        ]);
648        let parsed = parse_session_file(file.path()).expect("Failed to parse");
649
650        // Should still have parsed the valid message
651        assert_eq!(parsed.messages.len(), 1);
652        assert_eq!(parsed.messages[0].uuid, user_uuid);
653    }
654
655    #[test]
656    fn test_unknown_message_types_are_skipped() {
657        let session_id = "550e8400-e29b-41d4-a716-446655440000";
658        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
659        let snapshot_uuid = "770e8400-e29b-41d4-a716-446655440003";
660
661        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
662        let snapshot_line = make_file_history_snapshot(session_id, snapshot_uuid);
663
664        let file = create_temp_session_file(&[&snapshot_line, &user_line]);
665        let parsed = parse_session_file(file.path()).expect("Failed to parse");
666
667        // Only the user message should be parsed
668        assert_eq!(parsed.messages.len(), 1);
669        assert_eq!(parsed.messages[0].uuid, user_uuid);
670    }
671
672    #[test]
673    fn test_sidechain_messages_are_skipped() {
674        let session_id = "550e8400-e29b-41d4-a716-446655440000";
675        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
676        let sidechain_uuid = "880e8400-e29b-41d4-a716-446655440004";
677
678        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
679        let sidechain_line = make_sidechain_message(session_id, sidechain_uuid);
680
681        let file = create_temp_session_file(&[&user_line, &sidechain_line]);
682        let parsed = parse_session_file(file.path()).expect("Failed to parse");
683
684        assert_eq!(parsed.messages.len(), 1);
685        assert_eq!(parsed.messages[0].uuid, user_uuid);
686    }
687
688    // =========================================================================
689    // Unit tests for message type parsing
690    // =========================================================================
691
692    #[test]
693    fn test_parse_human_user_role() {
694        let session_id = "550e8400-e29b-41d4-a716-446655440000";
695        let uuid = "660e8400-e29b-41d4-a716-446655440001";
696        let user_line = make_user_message(session_id, uuid, None, "User message");
697
698        let file = create_temp_session_file(&[&user_line]);
699        let parsed = parse_session_file(file.path()).expect("Failed to parse");
700
701        assert_eq!(parsed.messages[0].role, MessageRole::User);
702    }
703
704    #[test]
705    fn test_parse_assistant_role_with_model() {
706        let session_id = "550e8400-e29b-41d4-a716-446655440000";
707        let uuid = "660e8400-e29b-41d4-a716-446655440002";
708        let assistant_line =
709            make_assistant_message(session_id, uuid, None, "claude-opus-4-5", "Response");
710
711        let file = create_temp_session_file(&[&assistant_line]);
712        let parsed = parse_session_file(file.path()).expect("Failed to parse");
713
714        assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
715        assert_eq!(
716            parsed.messages[0].model,
717            Some("claude-opus-4-5".to_string())
718        );
719    }
720
721    #[test]
722    fn test_parse_system_role() {
723        let session_id = "550e8400-e29b-41d4-a716-446655440000";
724        let uuid = "660e8400-e29b-41d4-a716-446655440001";
725        let system_line = make_system_message(session_id, uuid, "System instructions");
726
727        let file = create_temp_session_file(&[&system_line]);
728        let parsed = parse_session_file(file.path()).expect("Failed to parse");
729
730        assert_eq!(parsed.messages[0].role, MessageRole::System);
731    }
732
733    #[test]
734    fn test_tool_use_blocks_parsed_correctly() {
735        let session_id = "550e8400-e29b-41d4-a716-446655440000";
736        let uuid = "660e8400-e29b-41d4-a716-446655440002";
737
738        let blocks_json = r#"[{"type":"text","text":"Let me run that command"},{"type":"tool_use","id":"tool_123","name":"Bash","input":{"command":"ls -la"}}]"#;
739        let assistant_line = make_assistant_message_with_blocks(
740            session_id,
741            uuid,
742            None,
743            "claude-opus-4",
744            blocks_json,
745        );
746
747        let file = create_temp_session_file(&[&assistant_line]);
748        let parsed = parse_session_file(file.path()).expect("Failed to parse");
749
750        assert_eq!(parsed.messages.len(), 1);
751        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
752            assert_eq!(blocks.len(), 2);
753
754            // Check text block
755            assert!(
756                matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me run that command")
757            );
758
759            // Check tool_use block
760            if let ContentBlock::ToolUse { id, name, input } = &blocks[1] {
761                assert_eq!(id, "tool_123");
762                assert_eq!(name, "Bash");
763                assert_eq!(input["command"], "ls -la");
764            } else {
765                panic!("Expected ToolUse block");
766            }
767        } else {
768            panic!("Expected Blocks content");
769        }
770    }
771
772    #[test]
773    fn test_tool_result_blocks_parsed_correctly() {
774        let session_id = "550e8400-e29b-41d4-a716-446655440000";
775        let uuid = "660e8400-e29b-41d4-a716-446655440001";
776
777        // User messages can contain tool_result blocks
778        let user_line = format!(
779            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}}]}}}}"#
780        );
781
782        let file = create_temp_session_file(&[&user_line]);
783        let parsed = parse_session_file(file.path()).expect("Failed to parse");
784
785        assert_eq!(parsed.messages.len(), 1);
786        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
787            assert_eq!(blocks.len(), 1);
788
789            if let ContentBlock::ToolResult {
790                tool_use_id,
791                content,
792                is_error,
793            } = &blocks[0]
794            {
795                assert_eq!(tool_use_id, "tool_123");
796                assert_eq!(content, "file1.txt\nfile2.txt");
797                assert!(!is_error);
798            } else {
799                panic!("Expected ToolResult block");
800            }
801        } else {
802            panic!("Expected Blocks content");
803        }
804    }
805
806    #[test]
807    fn test_tool_result_with_error() {
808        let session_id = "550e8400-e29b-41d4-a716-446655440000";
809        let uuid = "660e8400-e29b-41d4-a716-446655440001";
810
811        let user_line = format!(
812            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}}]}}}}"#
813        );
814
815        let file = create_temp_session_file(&[&user_line]);
816        let parsed = parse_session_file(file.path()).expect("Failed to parse");
817
818        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
819            if let ContentBlock::ToolResult { is_error, .. } = &blocks[0] {
820                assert!(*is_error);
821            } else {
822                panic!("Expected ToolResult block");
823            }
824        } else {
825            panic!("Expected Blocks content");
826        }
827    }
828
829    #[test]
830    fn test_thinking_blocks_parsed_correctly() {
831        let session_id = "550e8400-e29b-41d4-a716-446655440000";
832        let uuid = "660e8400-e29b-41d4-a716-446655440002";
833
834        let blocks_json = r#"[{"type":"thinking","thinking":"Let me analyze this problem...","signature":"abc123"},{"type":"text","text":"Here is my answer"}]"#;
835        let assistant_line = make_assistant_message_with_blocks(
836            session_id,
837            uuid,
838            None,
839            "claude-opus-4",
840            blocks_json,
841        );
842
843        let file = create_temp_session_file(&[&assistant_line]);
844        let parsed = parse_session_file(file.path()).expect("Failed to parse");
845
846        if let MessageContent::Blocks(blocks) = &parsed.messages[0].content {
847            assert_eq!(blocks.len(), 2);
848
849            // Check thinking block
850            if let ContentBlock::Thinking { thinking } = &blocks[0] {
851                assert_eq!(thinking, "Let me analyze this problem...");
852            } else {
853                panic!("Expected Thinking block");
854            }
855
856            // Check text block
857            assert!(
858                matches!(&blocks[1], ContentBlock::Text { text } if text == "Here is my answer")
859            );
860        } else {
861            panic!("Expected Blocks content");
862        }
863    }
864
865    // =========================================================================
866    // Unit tests for session file discovery
867    // =========================================================================
868
869    #[test]
870    fn test_find_session_files_returns_empty_when_claude_dir_missing() {
871        // This test verifies the behavior when ~/.claude doesn't exist
872        // In CI or clean environments, this should return an empty vec
873        // We can't mock the home directory easily, so we test the logic path
874        // by creating a temp directory that doesn't have the expected structure
875
876        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
877        let fake_claude_path = temp_dir.path().join(".claude").join("projects");
878
879        // The directory doesn't exist, so checking it should return false
880        assert!(!fake_claude_path.exists());
881
882        // The actual find_session_files uses dirs::home_dir(), so we verify
883        // the empty return logic exists by calling it - if ~/.claude doesn't
884        // exist it should return Ok(empty vec), not error
885        let result = find_session_files();
886        // This should not error even if ~/.claude doesn't exist
887        assert!(result.is_ok());
888    }
889
890    // =========================================================================
891    // Test to_storage_models conversion
892    // =========================================================================
893
894    #[test]
895    fn test_to_storage_models_creates_correct_session() {
896        let session_id = "550e8400-e29b-41d4-a716-446655440000";
897        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
898        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
899
900        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
901        let assistant_line = make_assistant_message(
902            session_id,
903            assistant_uuid,
904            Some(user_uuid),
905            "claude-opus-4",
906            "Hi there!",
907        );
908
909        let file = create_temp_session_file(&[&user_line, &assistant_line]);
910        let parsed = parse_session_file(file.path()).expect("Failed to parse");
911        let (session, _messages) = parsed.to_storage_models();
912
913        // Verify session
914        assert_eq!(session.id.to_string(), session_id);
915        assert_eq!(session.tool, "claude-code");
916        assert_eq!(session.tool_version, Some("2.0.72".to_string()));
917        assert_eq!(session.model, Some("claude-opus-4".to_string()));
918        assert_eq!(session.working_directory, "/test/project");
919        assert_eq!(session.git_branch, Some("main".to_string()));
920        assert_eq!(session.message_count, 2);
921        assert!(session.source_path.is_some());
922
923        // Verify started_at is from first message
924        assert!(session.started_at.to_rfc3339().contains("2025-01-15T10:00"));
925
926        // Verify ended_at is from last message
927        assert!(session.ended_at.is_some());
928        assert!(session
929            .ended_at
930            .unwrap()
931            .to_rfc3339()
932            .contains("2025-01-15T10:01"));
933    }
934
935    #[test]
936    fn test_to_storage_models_creates_correct_messages() {
937        let session_id = "550e8400-e29b-41d4-a716-446655440000";
938        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
939        let assistant_uuid = "660e8400-e29b-41d4-a716-446655440002";
940
941        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
942        let assistant_line = make_assistant_message(
943            session_id,
944            assistant_uuid,
945            Some(user_uuid),
946            "claude-opus-4",
947            "Hi there!",
948        );
949
950        let file = create_temp_session_file(&[&user_line, &assistant_line]);
951        let parsed = parse_session_file(file.path()).expect("Failed to parse");
952        let (session, messages) = parsed.to_storage_models();
953
954        assert_eq!(messages.len(), 2);
955
956        // Verify first message (user)
957        let user_msg = &messages[0];
958        assert_eq!(user_msg.id.to_string(), user_uuid);
959        assert_eq!(user_msg.session_id, session.id);
960        assert!(user_msg.parent_id.is_none());
961        assert_eq!(user_msg.index, 0);
962        assert_eq!(user_msg.role, MessageRole::User);
963        assert!(user_msg.model.is_none());
964
965        // Verify second message (assistant)
966        let assistant_msg = &messages[1];
967        assert_eq!(assistant_msg.id.to_string(), assistant_uuid);
968        assert_eq!(assistant_msg.session_id, session.id);
969        assert_eq!(assistant_msg.index, 1);
970        assert_eq!(assistant_msg.role, MessageRole::Assistant);
971        assert_eq!(assistant_msg.model, Some("claude-opus-4".to_string()));
972    }
973
974    #[test]
975    fn test_to_storage_models_parent_id_linking() {
976        let session_id = "550e8400-e29b-41d4-a716-446655440000";
977        let uuid1 = "660e8400-e29b-41d4-a716-446655440001";
978        let uuid2 = "660e8400-e29b-41d4-a716-446655440002";
979        let uuid3 = "660e8400-e29b-41d4-a716-446655440003";
980
981        let msg1 = make_user_message(session_id, uuid1, None, "First message");
982        let msg2 = make_assistant_message(session_id, uuid2, Some(uuid1), "claude-opus-4", "Reply");
983        let msg3 = make_user_message(session_id, uuid3, Some(uuid2), "Follow up");
984
985        let file = create_temp_session_file(&[&msg1, &msg2, &msg3]);
986        let parsed = parse_session_file(file.path()).expect("Failed to parse");
987        let (_, messages) = parsed.to_storage_models();
988
989        // First message has no parent
990        assert!(messages[0].parent_id.is_none());
991
992        // Second message's parent is first message
993        assert_eq!(messages[1].parent_id, Some(messages[0].id));
994
995        // Third message's parent is second message
996        assert_eq!(messages[2].parent_id, Some(messages[1].id));
997    }
998
999    #[test]
1000    fn test_to_storage_models_with_invalid_uuid_generates_new() {
1001        // Test that invalid UUIDs are handled gracefully
1002        let session_id = "not-a-valid-uuid";
1003        let user_uuid = "also-not-valid";
1004
1005        let user_line = format!(
1006            r#"{{"type":"user","sessionId":"{session_id}","uuid":"{user_uuid}","timestamp":"2025-01-15T10:00:00.000Z","cwd":"/test","message":{{"role":"user","content":"Hello"}}}}"#
1007        );
1008
1009        let file = create_temp_session_file(&[&user_line]);
1010        let parsed = parse_session_file(file.path()).expect("Failed to parse");
1011        let (session, messages) = parsed.to_storage_models();
1012
1013        // Should still work with generated UUIDs
1014        assert!(!session.id.is_nil());
1015        assert_eq!(messages.len(), 1);
1016        assert!(!messages[0].id.is_nil());
1017    }
1018
1019    #[test]
1020    fn test_to_storage_models_empty_session() {
1021        // Create a session file with no valid messages
1022        let file = create_temp_session_file(&["", "  ", "invalid json"]);
1023        let parsed = parse_session_file(file.path()).expect("Failed to parse");
1024        let (session, messages) = parsed.to_storage_models();
1025
1026        assert!(messages.is_empty());
1027        assert_eq!(session.message_count, 0);
1028        // When no messages, started_at should be set to now (approximately)
1029        // and ended_at should be None
1030        assert!(session.ended_at.is_none());
1031    }
1032
1033    #[test]
1034    fn test_session_id_from_filename_fallback() {
1035        // When session_id is not in any message, it should use the filename
1036        let invalid_line = r#"{"type":"unknown","sessionId":"","uuid":"test"}"#;
1037
1038        let file = create_temp_session_file(&[invalid_line]);
1039        let parsed = parse_session_file(file.path()).expect("Failed to parse");
1040
1041        // The session_id should be derived from the temp file name
1042        assert!(!parsed.session_id.is_empty());
1043        assert_ne!(parsed.session_id, "");
1044    }
1045
1046    // =========================================================================
1047    // Tests for ClaudeCodeWatcher trait implementation
1048    // =========================================================================
1049
1050    #[test]
1051    fn test_watcher_info() {
1052        use super::Watcher;
1053        let watcher = ClaudeCodeWatcher;
1054        let info = watcher.info();
1055
1056        assert_eq!(info.name, "claude-code");
1057        assert_eq!(info.description, "Claude Code CLI sessions");
1058        assert!(!info.default_paths.is_empty());
1059        assert!(info.default_paths[0].to_string_lossy().contains(".claude"));
1060    }
1061
1062    #[test]
1063    fn test_watcher_watch_paths() {
1064        use super::Watcher;
1065        let watcher = ClaudeCodeWatcher;
1066        let paths = watcher.watch_paths();
1067
1068        assert!(!paths.is_empty());
1069        assert!(paths[0].to_string_lossy().contains(".claude"));
1070    }
1071
1072    #[test]
1073    fn test_watcher_parse_source() {
1074        use super::Watcher;
1075        let watcher = ClaudeCodeWatcher;
1076
1077        let session_id = "550e8400-e29b-41d4-a716-446655440000";
1078        let user_uuid = "660e8400-e29b-41d4-a716-446655440001";
1079        let user_line = make_user_message(session_id, user_uuid, None, "Hello");
1080
1081        let file = create_temp_session_file(&[&user_line]);
1082        let path = file.path().to_path_buf();
1083        let result = watcher
1084            .parse_source(&path)
1085            .expect("Should parse successfully");
1086
1087        assert_eq!(result.len(), 1);
1088        let (session, messages) = &result[0];
1089        assert_eq!(session.tool, "claude-code");
1090        assert_eq!(messages.len(), 1);
1091    }
1092
1093    #[test]
1094    fn test_watcher_parse_source_empty_session() {
1095        use super::Watcher;
1096        let watcher = ClaudeCodeWatcher;
1097
1098        // Create a file with no valid messages
1099        let file = create_temp_session_file(&["", "invalid json"]);
1100        let path = file.path().to_path_buf();
1101        let result = watcher
1102            .parse_source(&path)
1103            .expect("Should parse successfully");
1104
1105        // Empty sessions should return empty vec
1106        assert!(result.is_empty());
1107    }
1108}