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