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 };
332
333 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#[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
389pub 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 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 if name.starts_with("agent-") {
416 continue;
417 }
418 if !name.ends_with(".jsonl") {
419 continue;
420 }
421 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 #[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 assert!(
756 matches!(&blocks[0], ContentBlock::Text { text } if text == "Let me run that command")
757 );
758
759 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 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 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 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 #[test]
870 fn test_find_session_files_returns_empty_when_claude_dir_missing() {
871 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 assert!(!fake_claude_path.exists());
881
882 let result = find_session_files();
886 assert!(result.is_ok());
888 }
889
890 #[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 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 assert!(session.started_at.to_rfc3339().contains("2025-01-15T10:00"));
925
926 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 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 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 assert!(messages[0].parent_id.is_none());
991
992 assert_eq!(messages[1].parent_id, Some(messages[0].id));
994
995 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 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 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 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 assert!(session.ended_at.is_none());
1031 }
1032
1033 #[test]
1034 fn test_session_id_from_filename_fallback() {
1035 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 assert!(!parsed.session_id.is_empty());
1043 assert_ne!(parsed.session_id, "");
1044 }
1045
1046 #[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 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 assert!(result.is_empty());
1107 }
1108}