recall_cli/providers/
claude.rs1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4
5use crate::session::{Session, truncate};
6
7use super::Provider;
8
9pub struct ClaudeCodeProvider;
10
11impl ClaudeCodeProvider {
12 fn projects_dir() -> Option<PathBuf> {
13 dirs::home_dir().map(|h| h.join(".claude").join("projects"))
14 }
15
16 pub fn load_session(path: &std::path::Path) -> Option<Session> {
17 let content = std::fs::read_to_string(path).ok()?;
18
19 let mut session_id = None;
20 let mut cwd = None;
21 let mut first_user_content: Option<String> = None;
22 let mut user_messages = Vec::new();
23 let mut first_timestamp: Option<DateTime<Utc>> = None;
24 let mut last_timestamp: Option<DateTime<Utc>> = None;
25
26 for line in content.lines() {
27 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
28 continue;
29 };
30
31 let event_type = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
32
33 if let Some(ts_str) = value.get("timestamp").and_then(|v| v.as_str()) {
34 if let Ok(ts) = ts_str.parse::<DateTime<Utc>>() {
35 if first_timestamp.is_none() {
36 first_timestamp = Some(ts);
37 }
38 last_timestamp = Some(ts);
39 }
40 }
41
42 if event_type == "user" {
43 if session_id.is_none() {
44 session_id = value
45 .get("sessionId")
46 .and_then(|v| v.as_str())
47 .map(String::from);
48 }
49 if cwd.is_none() {
50 cwd = value.get("cwd").and_then(|v| v.as_str()).map(String::from);
51 }
52
53 let msg_content = value
54 .get("message")
55 .and_then(|m| m.get("content"))
56 .and_then(|c| c.as_str());
57
58 if let Some(text) = msg_content {
59 let trimmed = text.trim();
60 if !trimmed.is_empty() {
61 if first_user_content.is_none() {
62 first_user_content = Some(trimmed.to_string());
63 }
64 user_messages.push(truncate(trimmed, 200));
65 }
66 }
67 }
68 }
69
70 if user_messages.is_empty() {
71 return None;
72 }
73
74 let id =
75 session_id.or_else(|| path.file_stem().and_then(|s| s.to_str()).map(String::from))?;
76
77 let summary = first_user_content
78 .map(|s| truncate(&s, 80))
79 .unwrap_or_default();
80
81 Some(Session {
82 id,
83 provider: String::new(),
84 summary,
85 cwd: cwd.unwrap_or_default(),
86 created_at: first_timestamp.unwrap_or_default(),
87 updated_at: last_timestamp.unwrap_or_default(),
88 checkpoints: Vec::new(),
89 user_messages,
90 task_summaries: Vec::new(),
91 path: path.to_path_buf(),
92 })
93 }
94
95 pub fn discover_in(projects_dir: &std::path::Path) -> Vec<Session> {
96 let Ok(project_entries) = std::fs::read_dir(projects_dir) else {
97 return Vec::new();
98 };
99
100 let mut sessions = Vec::new();
101
102 for project_entry in project_entries.filter_map(|e| e.ok()) {
103 let project_path = project_entry.path();
104 if !project_path.is_dir() {
105 continue;
106 }
107
108 let Ok(session_files) = std::fs::read_dir(&project_path) else {
109 continue;
110 };
111
112 for file_entry in session_files.filter_map(|e| e.ok()) {
113 let file_path = file_entry.path();
114 let Some(name) = file_path.file_name().and_then(|n| n.to_str()) else {
115 continue;
116 };
117
118 if !name.ends_with(".jsonl") || name.starts_with("agent-") {
119 continue;
120 }
121
122 if let Some(session) = Self::load_session(&file_path) {
123 sessions.push(session);
124 }
125 }
126 }
127
128 sessions
129 }
130}
131
132impl Provider for ClaudeCodeProvider {
133 fn name(&self) -> &str {
134 "Claude Code"
135 }
136
137 fn discover_sessions(&self) -> Vec<Session> {
138 let Some(projects_dir) = Self::projects_dir() else {
139 return Vec::new();
140 };
141
142 Self::discover_in(&projects_dir)
143 }
144}