1use 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
23pub 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
60fn claude_projects_dir() -> PathBuf {
64 dirs::home_dir()
65 .unwrap_or_else(|| PathBuf::from("."))
66 .join(".claude")
67 .join("projects")
68}
69
70#[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 #[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
146pub 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 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 if raw.msg_type != "user" && raw.msg_type != "assistant" {
184 continue;
185 }
186
187 if raw.is_sidechain.unwrap_or(false) {
189 continue;
190 }
191
192 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 if let Some(ref msg_content) = raw.message {
208 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 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#[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 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 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#[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
390pub 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 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 if name.starts_with("agent-") {
417 continue;
418 }
419 if !name.ends_with(".jsonl") {
420 continue;
421 }
422 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 assert!(
757 matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me run that command")
758 );
759
760 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 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 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 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 #[test]
871 fn test_find_session_files_returns_empty_when_claude_dir_missing() {
872 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 assert!(!fake_claude_path.exists());
882
883 let result = find_session_files();
887 assert!(result.is_ok());
889 }
890
891 #[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 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 assert!(session.started_at.to_rfc3339().contains("2025-01-15T10:00"));
926
927 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 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 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 assert!(messages[0].parent_id.is_none());
992
993 assert_eq!(messages[1].parent_id, Some(messages[0].id));
995
996 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 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 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 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 assert!(session.ended_at.is_none());
1032 }
1033
1034 #[test]
1035 fn test_session_id_from_filename_fallback() {
1036 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 assert!(!parsed.session_id.is_empty());
1044 assert_ne!(parsed.session_id, "");
1045 }
1046
1047 #[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 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 assert!(result.is_empty());
1108 }
1109}