1#[cfg(feature = "watcher")]
43pub mod async_watcher;
44pub mod derive;
45pub mod error;
46pub mod io;
47pub mod paths;
48pub mod query;
49pub mod reader;
50pub mod types;
51#[cfg(feature = "watcher")]
52pub mod watcher;
53
54#[cfg(feature = "watcher")]
55pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle};
56pub use error::{ConvoError, Result};
57pub use io::ConvoIO;
58pub use paths::PathResolver;
59pub use query::{ConversationQuery, HistoryQuery};
60pub use reader::ConversationReader;
61pub use types::{
62 CacheCreation, ContentPart, Conversation, ConversationEntry, ConversationMetadata,
63 HistoryEntry, Message, MessageContent, MessageRole, ToolResultContent, Usage,
64};
65#[cfg(feature = "watcher")]
66pub use watcher::ConversationWatcher;
67
68#[derive(Debug, Clone)]
94pub struct ClaudeConvo {
95 io: ConvoIO,
96}
97
98impl Default for ClaudeConvo {
99 fn default() -> Self {
100 Self::new()
101 }
102}
103
104impl ClaudeConvo {
105 pub fn new() -> Self {
107 Self { io: ConvoIO::new() }
108 }
109
110 pub fn with_resolver(resolver: PathResolver) -> Self {
126 Self {
127 io: ConvoIO::with_resolver(resolver),
128 }
129 }
130
131 pub fn io(&self) -> &ConvoIO {
133 &self.io
134 }
135
136 pub fn resolver(&self) -> &PathResolver {
138 self.io.resolver()
139 }
140
141 pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
152 self.io.read_conversation(project_path, session_id)
153 }
154
155 pub fn read_conversation_metadata(
159 &self,
160 project_path: &str,
161 session_id: &str,
162 ) -> Result<ConversationMetadata> {
163 self.io.read_conversation_metadata(project_path, session_id)
164 }
165
166 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
168 self.io.list_conversations(project_path)
169 }
170
171 pub fn list_conversation_metadata(
175 &self,
176 project_path: &str,
177 ) -> Result<Vec<ConversationMetadata>> {
178 self.io.list_conversation_metadata(project_path)
179 }
180
181 pub fn list_projects(&self) -> Result<Vec<String>> {
185 self.io.list_projects()
186 }
187
188 pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
192 self.io.read_history()
193 }
194
195 pub fn exists(&self) -> bool {
197 self.io.exists()
198 }
199
200 pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
202 self.io.claude_dir_path()
203 }
204
205 pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
207 self.io.conversation_exists(project_path, session_id)
208 }
209
210 pub fn project_exists(&self, project_path: &str) -> bool {
212 self.io.project_exists(project_path)
213 }
214
215 pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
217 ConversationQuery::new(conversation)
218 }
219
220 pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
222 HistoryQuery::new(history)
223 }
224
225 pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
229 let session_ids = self.list_conversations(project_path)?;
230 let mut conversations = Vec::new();
231
232 for session_id in session_ids {
233 match self.read_conversation(project_path, &session_id) {
234 Ok(convo) => conversations.push(convo),
235 Err(e) => {
236 eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
237 }
238 }
239 }
240
241 conversations.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
242 Ok(conversations)
243 }
244
245 pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
247 let metadata = self.list_conversation_metadata(project_path)?;
248
249 if let Some(latest) = metadata.first() {
250 Ok(Some(
251 self.read_conversation(project_path, &latest.session_id)?,
252 ))
253 } else {
254 Ok(None)
255 }
256 }
257
258 pub fn find_conversations_with_text(
260 &self,
261 project_path: &str,
262 search_text: &str,
263 ) -> Result<Vec<Conversation>> {
264 let conversations = self.read_all_conversations(project_path)?;
265
266 Ok(conversations
267 .into_iter()
268 .filter(|convo| {
269 let query = ConversationQuery::new(convo);
270 !query.contains_text(search_text).is_empty()
271 })
272 .collect())
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use std::fs;
280 use tempfile::TempDir;
281
282 fn setup_test_manager() -> (TempDir, ClaudeConvo) {
283 let temp = TempDir::new().unwrap();
284 let claude_dir = temp.path().join(".claude");
285 fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
286
287 let resolver = PathResolver::new().with_claude_dir(claude_dir);
288 let manager = ClaudeConvo::with_resolver(resolver);
289
290 (temp, manager)
291 }
292
293 #[test]
294 fn test_basic_setup() {
295 let (_temp, manager) = setup_test_manager();
296 assert!(manager.exists());
297 }
298
299 #[test]
300 fn test_list_projects() {
301 let (_temp, manager) = setup_test_manager();
302 let projects = manager.list_projects().unwrap();
303 assert_eq!(projects.len(), 1);
304 assert_eq!(projects[0], "/test/project");
305 }
306
307 #[test]
308 fn test_project_exists() {
309 let (_temp, manager) = setup_test_manager();
310 assert!(manager.project_exists("/test/project"));
311 assert!(!manager.project_exists("/nonexistent"));
312 }
313
314 fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
315 let temp = TempDir::new().unwrap();
316 let claude_dir = temp.path().join(".claude");
317 let project_dir = claude_dir.join("projects/-test-project");
318 fs::create_dir_all(&project_dir).unwrap();
319
320 let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
321 let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
322 fs::write(
323 project_dir.join("session-abc.jsonl"),
324 format!("{}\n{}\n", entry1, entry2),
325 )
326 .unwrap();
327
328 let resolver = PathResolver::new().with_claude_dir(claude_dir);
329 let manager = ClaudeConvo::with_resolver(resolver);
330 (temp, manager)
331 }
332
333 #[test]
334 fn test_read_conversation() {
335 let (_temp, manager) = setup_test_with_conversation();
336 let convo = manager
337 .read_conversation("/test/project", "session-abc")
338 .unwrap();
339 assert_eq!(convo.entries.len(), 2);
340 assert_eq!(convo.message_count(), 2);
341 }
342
343 #[test]
344 fn test_read_conversation_metadata() {
345 let (_temp, manager) = setup_test_with_conversation();
346 let meta = manager
347 .read_conversation_metadata("/test/project", "session-abc")
348 .unwrap();
349 assert_eq!(meta.message_count, 2);
350 assert_eq!(meta.session_id, "session-abc");
351 }
352
353 #[test]
354 fn test_list_conversations() {
355 let (_temp, manager) = setup_test_with_conversation();
356 let sessions = manager.list_conversations("/test/project").unwrap();
357 assert_eq!(sessions.len(), 1);
358 assert_eq!(sessions[0], "session-abc");
359 }
360
361 #[test]
362 fn test_list_conversation_metadata() {
363 let (_temp, manager) = setup_test_with_conversation();
364 let metadata = manager.list_conversation_metadata("/test/project").unwrap();
365 assert_eq!(metadata.len(), 1);
366 assert_eq!(metadata[0].session_id, "session-abc");
367 }
368
369 #[test]
370 fn test_conversation_exists() {
371 let (_temp, manager) = setup_test_with_conversation();
372 assert!(
373 manager
374 .conversation_exists("/test/project", "session-abc")
375 .unwrap()
376 );
377 assert!(
378 !manager
379 .conversation_exists("/test/project", "nonexistent")
380 .unwrap()
381 );
382 }
383
384 #[test]
385 fn test_io_accessor() {
386 let (_temp, manager) = setup_test_with_conversation();
387 assert!(manager.io().exists());
388 }
389
390 #[test]
391 fn test_resolver_accessor() {
392 let (_temp, manager) = setup_test_with_conversation();
393 assert!(manager.resolver().exists());
394 }
395
396 #[test]
397 fn test_claude_dir_path() {
398 let (_temp, manager) = setup_test_with_conversation();
399 let path = manager.claude_dir_path().unwrap();
400 assert!(path.exists());
401 }
402
403 #[test]
404 fn test_read_all_conversations() {
405 let (_temp, manager) = setup_test_with_conversation();
406 let convos = manager.read_all_conversations("/test/project").unwrap();
407 assert_eq!(convos.len(), 1);
408 }
409
410 #[test]
411 fn test_most_recent_conversation() {
412 let (_temp, manager) = setup_test_with_conversation();
413 let convo = manager.most_recent_conversation("/test/project").unwrap();
414 assert!(convo.is_some());
415 }
416
417 #[test]
418 fn test_most_recent_conversation_empty() {
419 let (_temp, manager) = setup_test_manager();
420 let convo = manager.most_recent_conversation("/test/project").unwrap();
422 assert!(convo.is_none());
423 }
424
425 #[test]
426 fn test_find_conversations_with_text() {
427 let (_temp, manager) = setup_test_with_conversation();
428 let results = manager
429 .find_conversations_with_text("/test/project", "Hello")
430 .unwrap();
431 assert_eq!(results.len(), 1);
432
433 let no_results = manager
434 .find_conversations_with_text("/test/project", "nonexistent text xyz")
435 .unwrap();
436 assert!(no_results.is_empty());
437 }
438
439 #[test]
440 fn test_query_helper() {
441 let (_temp, manager) = setup_test_with_conversation();
442 let convo = manager
443 .read_conversation("/test/project", "session-abc")
444 .unwrap();
445 let q = manager.query(&convo);
446 let users = q.by_role(MessageRole::User);
447 assert_eq!(users.len(), 1);
448 }
449
450 #[test]
451 fn test_query_history_helper() {
452 let (_temp, manager) = setup_test_manager();
453 let history: Vec<HistoryEntry> = vec![];
454 let q = manager.query_history(&history);
455 let results = q.recent(5);
456 assert!(results.is_empty());
457 }
458
459 #[test]
460 fn test_read_history_no_file() {
461 let (_temp, manager) = setup_test_manager();
462 let history = manager.read_history().unwrap();
463 assert!(history.is_empty());
464 }
465
466 #[test]
467 fn test_default_impl() {
468 let _manager = ClaudeConvo::default();
470 }
471}