Skip to main content

recall_cli/providers/
claude.rs

1use 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}