1#![doc = include_str!("../README.md")]
2
3#[cfg(feature = "watcher")]
4pub mod async_watcher;
5pub mod derive;
6pub mod error;
7pub mod io;
8pub mod paths;
9pub mod provider;
10pub mod query;
11pub mod reader;
12pub mod types;
13#[cfg(feature = "watcher")]
14pub mod watcher;
15
16#[cfg(feature = "watcher")]
17pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle};
18pub use error::{ConvoError, Result};
19pub use io::ConvoIO;
20pub use paths::PathResolver;
21pub use query::{ConversationQuery, HistoryQuery};
22pub use reader::ConversationReader;
23pub use types::{
24 CacheCreation, ContentPart, Conversation, ConversationEntry, ConversationMetadata,
25 HistoryEntry, Message, MessageContent, MessageRole, ToolResultContent, ToolResultRef,
26 ToolUseRef, Usage,
27};
28#[cfg(feature = "watcher")]
29pub use watcher::ConversationWatcher;
30
31#[derive(Debug, Clone)]
57pub struct ClaudeConvo {
58 io: ConvoIO,
59}
60
61impl Default for ClaudeConvo {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67impl ClaudeConvo {
68 pub fn new() -> Self {
70 Self { io: ConvoIO::new() }
71 }
72
73 pub fn with_resolver(resolver: PathResolver) -> Self {
89 Self {
90 io: ConvoIO::with_resolver(resolver),
91 }
92 }
93
94 pub fn io(&self) -> &ConvoIO {
96 &self.io
97 }
98
99 pub fn resolver(&self) -> &PathResolver {
101 self.io.resolver()
102 }
103
104 pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
115 self.io.read_conversation(project_path, session_id)
116 }
117
118 pub fn read_conversation_metadata(
122 &self,
123 project_path: &str,
124 session_id: &str,
125 ) -> Result<ConversationMetadata> {
126 self.io.read_conversation_metadata(project_path, session_id)
127 }
128
129 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
131 self.io.list_conversations(project_path)
132 }
133
134 pub fn list_conversation_metadata(
138 &self,
139 project_path: &str,
140 ) -> Result<Vec<ConversationMetadata>> {
141 self.io.list_conversation_metadata(project_path)
142 }
143
144 pub fn list_projects(&self) -> Result<Vec<String>> {
148 self.io.list_projects()
149 }
150
151 pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
155 self.io.read_history()
156 }
157
158 pub fn exists(&self) -> bool {
160 self.io.exists()
161 }
162
163 pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
165 self.io.claude_dir_path()
166 }
167
168 pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
170 self.io.conversation_exists(project_path, session_id)
171 }
172
173 pub fn project_exists(&self, project_path: &str) -> bool {
175 self.io.project_exists(project_path)
176 }
177
178 pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
180 ConversationQuery::new(conversation)
181 }
182
183 pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
185 HistoryQuery::new(history)
186 }
187
188 pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
192 let session_ids = self.list_conversations(project_path)?;
193 let mut conversations = Vec::new();
194
195 for session_id in session_ids {
196 match self.read_conversation(project_path, &session_id) {
197 Ok(convo) => conversations.push(convo),
198 Err(e) => {
199 eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
200 }
201 }
202 }
203
204 conversations.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
205 Ok(conversations)
206 }
207
208 pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
210 let metadata = self.list_conversation_metadata(project_path)?;
211
212 if let Some(latest) = metadata.first() {
213 Ok(Some(
214 self.read_conversation(project_path, &latest.session_id)?,
215 ))
216 } else {
217 Ok(None)
218 }
219 }
220
221 pub fn find_conversations_with_text(
223 &self,
224 project_path: &str,
225 search_text: &str,
226 ) -> Result<Vec<Conversation>> {
227 let conversations = self.read_all_conversations(project_path)?;
228
229 Ok(conversations
230 .into_iter()
231 .filter(|convo| {
232 let query = ConversationQuery::new(convo);
233 !query.contains_text(search_text).is_empty()
234 })
235 .collect())
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use std::fs;
243 use tempfile::TempDir;
244
245 fn setup_test_manager() -> (TempDir, ClaudeConvo) {
246 let temp = TempDir::new().unwrap();
247 let claude_dir = temp.path().join(".claude");
248 fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
249
250 let resolver = PathResolver::new().with_claude_dir(claude_dir);
251 let manager = ClaudeConvo::with_resolver(resolver);
252
253 (temp, manager)
254 }
255
256 #[test]
257 fn test_basic_setup() {
258 let (_temp, manager) = setup_test_manager();
259 assert!(manager.exists());
260 }
261
262 #[test]
263 fn test_list_projects() {
264 let (_temp, manager) = setup_test_manager();
265 let projects = manager.list_projects().unwrap();
266 assert_eq!(projects.len(), 1);
267 assert_eq!(projects[0], "/test/project");
268 }
269
270 #[test]
271 fn test_project_exists() {
272 let (_temp, manager) = setup_test_manager();
273 assert!(manager.project_exists("/test/project"));
274 assert!(!manager.project_exists("/nonexistent"));
275 }
276
277 fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
278 let temp = TempDir::new().unwrap();
279 let claude_dir = temp.path().join(".claude");
280 let project_dir = claude_dir.join("projects/-test-project");
281 fs::create_dir_all(&project_dir).unwrap();
282
283 let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
284 let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
285 fs::write(
286 project_dir.join("session-abc.jsonl"),
287 format!("{}\n{}\n", entry1, entry2),
288 )
289 .unwrap();
290
291 let resolver = PathResolver::new().with_claude_dir(claude_dir);
292 let manager = ClaudeConvo::with_resolver(resolver);
293 (temp, manager)
294 }
295
296 #[test]
297 fn test_read_conversation() {
298 let (_temp, manager) = setup_test_with_conversation();
299 let convo = manager
300 .read_conversation("/test/project", "session-abc")
301 .unwrap();
302 assert_eq!(convo.entries.len(), 2);
303 assert_eq!(convo.message_count(), 2);
304 }
305
306 #[test]
307 fn test_read_conversation_metadata() {
308 let (_temp, manager) = setup_test_with_conversation();
309 let meta = manager
310 .read_conversation_metadata("/test/project", "session-abc")
311 .unwrap();
312 assert_eq!(meta.message_count, 2);
313 assert_eq!(meta.session_id, "session-abc");
314 }
315
316 #[test]
317 fn test_list_conversations() {
318 let (_temp, manager) = setup_test_with_conversation();
319 let sessions = manager.list_conversations("/test/project").unwrap();
320 assert_eq!(sessions.len(), 1);
321 assert_eq!(sessions[0], "session-abc");
322 }
323
324 #[test]
325 fn test_list_conversation_metadata() {
326 let (_temp, manager) = setup_test_with_conversation();
327 let metadata = manager.list_conversation_metadata("/test/project").unwrap();
328 assert_eq!(metadata.len(), 1);
329 assert_eq!(metadata[0].session_id, "session-abc");
330 }
331
332 #[test]
333 fn test_conversation_exists() {
334 let (_temp, manager) = setup_test_with_conversation();
335 assert!(
336 manager
337 .conversation_exists("/test/project", "session-abc")
338 .unwrap()
339 );
340 assert!(
341 !manager
342 .conversation_exists("/test/project", "nonexistent")
343 .unwrap()
344 );
345 }
346
347 #[test]
348 fn test_io_accessor() {
349 let (_temp, manager) = setup_test_with_conversation();
350 assert!(manager.io().exists());
351 }
352
353 #[test]
354 fn test_resolver_accessor() {
355 let (_temp, manager) = setup_test_with_conversation();
356 assert!(manager.resolver().exists());
357 }
358
359 #[test]
360 fn test_claude_dir_path() {
361 let (_temp, manager) = setup_test_with_conversation();
362 let path = manager.claude_dir_path().unwrap();
363 assert!(path.exists());
364 }
365
366 #[test]
367 fn test_read_all_conversations() {
368 let (_temp, manager) = setup_test_with_conversation();
369 let convos = manager.read_all_conversations("/test/project").unwrap();
370 assert_eq!(convos.len(), 1);
371 }
372
373 #[test]
374 fn test_most_recent_conversation() {
375 let (_temp, manager) = setup_test_with_conversation();
376 let convo = manager.most_recent_conversation("/test/project").unwrap();
377 assert!(convo.is_some());
378 }
379
380 #[test]
381 fn test_most_recent_conversation_empty() {
382 let (_temp, manager) = setup_test_manager();
383 let convo = manager.most_recent_conversation("/test/project").unwrap();
385 assert!(convo.is_none());
386 }
387
388 #[test]
389 fn test_find_conversations_with_text() {
390 let (_temp, manager) = setup_test_with_conversation();
391 let results = manager
392 .find_conversations_with_text("/test/project", "Hello")
393 .unwrap();
394 assert_eq!(results.len(), 1);
395
396 let no_results = manager
397 .find_conversations_with_text("/test/project", "nonexistent text xyz")
398 .unwrap();
399 assert!(no_results.is_empty());
400 }
401
402 #[test]
403 fn test_query_helper() {
404 let (_temp, manager) = setup_test_with_conversation();
405 let convo = manager
406 .read_conversation("/test/project", "session-abc")
407 .unwrap();
408 let q = manager.query(&convo);
409 let users = q.by_role(MessageRole::User);
410 assert_eq!(users.len(), 1);
411 }
412
413 #[test]
414 fn test_query_history_helper() {
415 let (_temp, manager) = setup_test_manager();
416 let history: Vec<HistoryEntry> = vec![];
417 let q = manager.query_history(&history);
418 let results = q.recent(5);
419 assert!(results.is_empty());
420 }
421
422 #[test]
423 fn test_read_history_no_file() {
424 let (_temp, manager) = setup_test_manager();
425 let history = manager.read_history().unwrap();
426 assert!(history.is_empty());
427 }
428
429 #[test]
430 fn test_default_impl() {
431 let _manager = ClaudeConvo::default();
433 }
434}