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