1#![doc = include_str!("../README.md")]
2
3#[cfg(feature = "watcher")]
4pub mod async_watcher;
5pub(crate) mod chain;
6pub mod derive;
7pub mod error;
8pub mod io;
9pub mod paths;
10pub mod project;
11pub mod provider;
12pub mod query;
13pub mod reader;
14pub mod types;
15#[cfg(feature = "watcher")]
16pub mod watcher;
17
18#[cfg(feature = "watcher")]
19pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle};
20pub use error::{ConvoError, Result};
21pub use io::ConvoIO;
22pub use paths::PathResolver;
23pub use project::ClaudeProjector;
24pub use query::{ConversationQuery, HistoryQuery};
25pub use reader::ConversationReader;
26pub use types::{
27 CacheCreation, ContentPart, Conversation, ConversationEntry, ConversationMetadata,
28 HistoryEntry, Message, MessageContent, MessageRole, ToolResultContent, ToolResultRef,
29 ToolUseRef, Usage,
30};
31#[cfg(feature = "watcher")]
32pub use watcher::ConversationWatcher;
33
34#[derive(Debug)]
64pub struct ClaudeConvo {
65 io: ConvoIO,
66 chain_cache: std::cell::RefCell<std::collections::HashMap<String, chain::ChainIndex>>,
67}
68
69impl Clone for ClaudeConvo {
70 fn clone(&self) -> Self {
71 Self {
72 io: self.io.clone(),
73 chain_cache: std::cell::RefCell::new(self.chain_cache.borrow().clone()),
74 }
75 }
76}
77
78impl Default for ClaudeConvo {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl ClaudeConvo {
85 pub fn new() -> Self {
87 Self {
88 io: ConvoIO::new(),
89 chain_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
90 }
91 }
92
93 pub fn with_resolver(resolver: PathResolver) -> Self {
109 Self {
110 io: ConvoIO::with_resolver(resolver),
111 chain_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
112 }
113 }
114
115 pub fn io(&self) -> &ConvoIO {
117 &self.io
118 }
119
120 pub fn resolver(&self) -> &PathResolver {
122 self.io.resolver()
123 }
124
125 pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
133 let chain = self.chain_for(project_path, session_id)?;
134
135 if chain.len() <= 1 {
136 return self.io.read_conversation(project_path, session_id);
137 }
138
139 let head = &chain[0];
141 let mut merged = Conversation::new(head.clone());
142
143 for segment_id in &chain {
144 let convo = self.io.read_conversation(project_path, segment_id)?;
145
146 if merged.started_at.is_none() {
147 merged.started_at = convo.started_at;
148 }
149 merged.last_activity = convo.last_activity.or(merged.last_activity);
150 if merged.project_path.is_none() {
151 merged.project_path = convo.project_path.clone();
152 }
153
154 for entry in &convo.entries {
155 if chain::is_bridge_entry(entry, segment_id) {
156 continue;
157 }
158 merged.add_entry(entry.clone());
159 }
160 }
161
162 merged.session_ids = chain;
163 Ok(merged)
164 }
165
166 pub fn read_conversation_metadata(
171 &self,
172 project_path: &str,
173 session_id: &str,
174 ) -> Result<ConversationMetadata> {
175 let chain = self.chain_for(project_path, session_id)?;
176
177 if chain.len() <= 1 {
178 return self.io.read_conversation_metadata(project_path, session_id);
179 }
180
181 let head = &chain[0];
182 let mut total_messages = 0usize;
183 let mut started_at = None;
184 let mut last_activity = None;
185 let mut file_path = std::path::PathBuf::new();
186 let mut first_user_message: Option<String> = None;
187
188 for (i, segment_id) in chain.iter().enumerate() {
189 let meta = self
190 .io
191 .read_conversation_metadata(project_path, segment_id)?;
192 total_messages += meta.message_count;
193
194 if started_at.is_none() || meta.started_at < started_at {
195 started_at = meta.started_at;
196 }
197 if last_activity.is_none() || meta.last_activity > last_activity {
198 last_activity = meta.last_activity;
199 }
200 if i == 0 {
201 file_path = meta.file_path;
202 }
203 if first_user_message.is_none() && meta.first_user_message.is_some() {
205 first_user_message = meta.first_user_message;
206 }
207 }
208
209 Ok(ConversationMetadata {
210 session_id: head.clone(),
211 project_path: project_path.to_string(),
212 file_path,
213 message_count: total_messages,
214 started_at,
215 last_activity,
216 first_user_message,
217 })
218 }
219
220 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
225 self.chain_heads(project_path)
226 }
227
228 pub fn list_conversation_metadata(
232 &self,
233 project_path: &str,
234 ) -> Result<Vec<ConversationMetadata>> {
235 let heads = self.chain_heads(project_path)?;
236 let mut metadata = Vec::new();
237
238 for session_id in heads {
239 match self.read_conversation_metadata(project_path, &session_id) {
240 Ok(meta) => metadata.push(meta),
241 Err(e) => {
242 eprintln!("Warning: Failed to read metadata for {}: {}", session_id, e);
243 }
244 }
245 }
246
247 metadata.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
248 Ok(metadata)
249 }
250
251 pub fn read_segment(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
255 self.io.read_conversation(project_path, session_id)
256 }
257
258 pub fn list_segments(&self, project_path: &str) -> Result<Vec<String>> {
260 self.io.list_conversations(project_path)
261 }
262
263 pub fn list_projects(&self) -> Result<Vec<String>> {
267 self.io.list_projects()
268 }
269
270 pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
274 self.io.read_history()
275 }
276
277 pub fn exists(&self) -> bool {
279 self.io.exists()
280 }
281
282 pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
284 self.io.claude_dir_path()
285 }
286
287 pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
289 self.io.conversation_exists(project_path, session_id)
290 }
291
292 pub fn project_exists(&self, project_path: &str) -> bool {
294 self.io.project_exists(project_path)
295 }
296
297 pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
299 ConversationQuery::new(conversation)
300 }
301
302 pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
304 HistoryQuery::new(history)
305 }
306
307 pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
311 let session_ids = self.list_conversations(project_path)?;
312 let mut conversations = Vec::new();
313
314 for session_id in session_ids {
315 match self.read_conversation(project_path, &session_id) {
316 Ok(convo) => conversations.push(convo),
317 Err(e) => {
318 eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
319 }
320 }
321 }
322
323 conversations.sort_by_key(|c| std::cmp::Reverse(c.last_activity));
324 Ok(conversations)
325 }
326
327 pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
329 let metadata = self.list_conversation_metadata(project_path)?;
330
331 if let Some(latest) = metadata.first() {
332 Ok(Some(
333 self.read_conversation(project_path, &latest.session_id)?,
334 ))
335 } else {
336 Ok(None)
337 }
338 }
339
340 pub fn session_chain(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
351 self.chain_for(project_path, session_id)
352 }
353
354 #[allow(dead_code)]
358 pub(crate) fn chain_head(&self, project_path: &str, session_id: &str) -> Result<String> {
359 let chain = self.session_chain(project_path, session_id)?;
360 Ok(chain
361 .into_iter()
362 .next()
363 .unwrap_or_else(|| session_id.to_string()))
364 }
365
366 fn chain_for(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
371 let mut cache = self.chain_cache.borrow_mut();
372 let index = cache
373 .entry(project_path.to_string())
374 .or_insert_with(chain::ChainIndex::new);
375 index.refresh(self.resolver(), project_path)?;
376 Ok(index.resolve_chain(session_id))
377 }
378
379 fn chain_heads(&self, project_path: &str) -> Result<Vec<String>> {
381 let mut cache = self.chain_cache.borrow_mut();
382 let index = cache
383 .entry(project_path.to_string())
384 .or_insert_with(chain::ChainIndex::new);
385 index.refresh(self.resolver(), project_path)?;
386 Ok(index.chain_heads())
387 }
388
389 pub fn find_conversations_with_text(
391 &self,
392 project_path: &str,
393 search_text: &str,
394 ) -> Result<Vec<Conversation>> {
395 let conversations = self.read_all_conversations(project_path)?;
396
397 Ok(conversations
398 .into_iter()
399 .filter(|convo| {
400 let query = ConversationQuery::new(convo);
401 !query.contains_text(search_text).is_empty()
402 })
403 .collect())
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use std::fs;
411 use tempfile::TempDir;
412
413 fn setup_test_manager() -> (TempDir, ClaudeConvo) {
414 let temp = TempDir::new().unwrap();
415 let claude_dir = temp.path().join(".claude");
416 fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
417
418 let resolver = PathResolver::new().with_claude_dir(claude_dir);
419 let manager = ClaudeConvo::with_resolver(resolver);
420
421 (temp, manager)
422 }
423
424 #[test]
425 fn test_basic_setup() {
426 let (_temp, manager) = setup_test_manager();
427 assert!(manager.exists());
428 }
429
430 #[test]
431 fn test_list_projects() {
432 let (_temp, manager) = setup_test_manager();
433 let projects = manager.list_projects().unwrap();
434 assert_eq!(projects.len(), 1);
435 assert_eq!(projects[0], "/test/project");
436 }
437
438 #[test]
439 fn test_project_exists() {
440 let (_temp, manager) = setup_test_manager();
441 assert!(manager.project_exists("/test/project"));
442 assert!(!manager.project_exists("/nonexistent"));
443 }
444
445 fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
446 let temp = TempDir::new().unwrap();
447 let claude_dir = temp.path().join(".claude");
448 let project_dir = claude_dir.join("projects/-test-project");
449 fs::create_dir_all(&project_dir).unwrap();
450
451 let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
452 let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
453 fs::write(
454 project_dir.join("session-abc.jsonl"),
455 format!("{}\n{}\n", entry1, entry2),
456 )
457 .unwrap();
458
459 let resolver = PathResolver::new().with_claude_dir(claude_dir);
460 let manager = ClaudeConvo::with_resolver(resolver);
461 (temp, manager)
462 }
463
464 #[test]
465 fn test_read_conversation() {
466 let (_temp, manager) = setup_test_with_conversation();
467 let convo = manager
468 .read_conversation("/test/project", "session-abc")
469 .unwrap();
470 assert_eq!(convo.entries.len(), 2);
471 assert_eq!(convo.message_count(), 2);
472 }
473
474 #[test]
475 fn test_read_conversation_metadata() {
476 let (_temp, manager) = setup_test_with_conversation();
477 let meta = manager
478 .read_conversation_metadata("/test/project", "session-abc")
479 .unwrap();
480 assert_eq!(meta.message_count, 2);
481 assert_eq!(meta.session_id, "session-abc");
482 }
483
484 #[test]
485 fn test_list_conversations() {
486 let (_temp, manager) = setup_test_with_conversation();
487 let sessions = manager.list_conversations("/test/project").unwrap();
488 assert_eq!(sessions.len(), 1);
489 assert_eq!(sessions[0], "session-abc");
490 }
491
492 #[test]
493 fn test_list_conversation_metadata() {
494 let (_temp, manager) = setup_test_with_conversation();
495 let metadata = manager.list_conversation_metadata("/test/project").unwrap();
496 assert_eq!(metadata.len(), 1);
497 assert_eq!(metadata[0].session_id, "session-abc");
498 }
499
500 #[test]
501 fn test_conversation_exists() {
502 let (_temp, manager) = setup_test_with_conversation();
503 assert!(
504 manager
505 .conversation_exists("/test/project", "session-abc")
506 .unwrap()
507 );
508 assert!(
509 !manager
510 .conversation_exists("/test/project", "nonexistent")
511 .unwrap()
512 );
513 }
514
515 #[test]
516 fn test_io_accessor() {
517 let (_temp, manager) = setup_test_with_conversation();
518 assert!(manager.io().exists());
519 }
520
521 #[test]
522 fn test_resolver_accessor() {
523 let (_temp, manager) = setup_test_with_conversation();
524 assert!(manager.resolver().exists());
525 }
526
527 #[test]
528 fn test_claude_dir_path() {
529 let (_temp, manager) = setup_test_with_conversation();
530 let path = manager.claude_dir_path().unwrap();
531 assert!(path.exists());
532 }
533
534 #[test]
535 fn test_read_all_conversations() {
536 let (_temp, manager) = setup_test_with_conversation();
537 let convos = manager.read_all_conversations("/test/project").unwrap();
538 assert_eq!(convos.len(), 1);
539 }
540
541 #[test]
542 fn test_most_recent_conversation() {
543 let (_temp, manager) = setup_test_with_conversation();
544 let convo = manager.most_recent_conversation("/test/project").unwrap();
545 assert!(convo.is_some());
546 }
547
548 #[test]
549 fn test_most_recent_conversation_empty() {
550 let (_temp, manager) = setup_test_manager();
551 let convo = manager.most_recent_conversation("/test/project").unwrap();
553 assert!(convo.is_none());
554 }
555
556 #[test]
557 fn test_find_conversations_with_text() {
558 let (_temp, manager) = setup_test_with_conversation();
559 let results = manager
560 .find_conversations_with_text("/test/project", "Hello")
561 .unwrap();
562 assert_eq!(results.len(), 1);
563
564 let no_results = manager
565 .find_conversations_with_text("/test/project", "nonexistent text xyz")
566 .unwrap();
567 assert!(no_results.is_empty());
568 }
569
570 #[test]
571 fn test_query_helper() {
572 let (_temp, manager) = setup_test_with_conversation();
573 let convo = manager
574 .read_conversation("/test/project", "session-abc")
575 .unwrap();
576 let q = manager.query(&convo);
577 let users = q.by_role(MessageRole::User);
578 assert_eq!(users.len(), 1);
579 }
580
581 #[test]
582 fn test_query_history_helper() {
583 let (_temp, manager) = setup_test_manager();
584 let history: Vec<HistoryEntry> = vec![];
585 let q = manager.query_history(&history);
586 let results = q.recent(5);
587 assert!(results.is_empty());
588 }
589
590 #[test]
591 fn test_read_history_no_file() {
592 let (_temp, manager) = setup_test_manager();
593 let history = manager.read_history().unwrap();
594 assert!(history.is_empty());
595 }
596
597 #[test]
598 fn test_default_impl() {
599 let _manager = ClaudeConvo::default();
601 }
602
603 fn setup_chained_conversations() -> (TempDir, ClaudeConvo) {
606 let temp = TempDir::new().unwrap();
607 let claude_dir = temp.path().join(".claude");
608 let project_dir = claude_dir.join("projects/-test-project");
609 fs::create_dir_all(&project_dir).unwrap();
610
611 fs::write(
613 project_dir.join("session-a.jsonl"),
614 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
615 ).unwrap();
616
617 let b = [
619 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
620 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#,
621 ];
622 fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
623
624 let c = [
626 r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#,
627 r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#,
628 ];
629 fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap();
630
631 let resolver = PathResolver::new().with_claude_dir(claude_dir);
632 (temp, ClaudeConvo::with_resolver(resolver))
633 }
634
635 #[test]
636 fn test_session_chain_full() {
637 let (_temp, manager) = setup_chained_conversations();
638 let chain = manager.session_chain("/test/project", "session-a").unwrap();
639 assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
640 }
641
642 #[test]
643 fn test_session_chain_from_middle() {
644 let (_temp, manager) = setup_chained_conversations();
645 let chain = manager.session_chain("/test/project", "session-b").unwrap();
646 assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
647 }
648
649 #[test]
650 fn test_session_chain_single() {
651 let (_temp, manager) = setup_test_with_conversation();
652 let chain = manager
653 .session_chain("/test/project", "session-abc")
654 .unwrap();
655 assert_eq!(chain, vec!["session-abc"]);
656 }
657
658 #[test]
659 fn test_chain_head_from_tail() {
660 let (_temp, manager) = setup_chained_conversations();
661 let head = manager.chain_head("/test/project", "session-c").unwrap();
662 assert_eq!(head, "session-a");
663 }
664
665 #[test]
666 fn test_chain_head_already_head() {
667 let (_temp, manager) = setup_chained_conversations();
668 let head = manager.chain_head("/test/project", "session-a").unwrap();
669 assert_eq!(head, "session-a");
670 }
671
672 #[test]
673 fn test_chain_head_single_session() {
674 let (_temp, manager) = setup_test_with_conversation();
675 let head = manager.chain_head("/test/project", "session-abc").unwrap();
676 assert_eq!(head, "session-abc");
677 }
678
679 #[test]
682 fn test_read_conversation_follows_chain() {
683 let (_temp, manager) = setup_chained_conversations();
684
685 let convo = manager
687 .read_conversation("/test/project", "session-a")
688 .unwrap();
689 assert_eq!(convo.session_id, "session-a");
690 assert_eq!(
691 convo.session_ids,
692 vec!["session-a", "session-b", "session-c"]
693 );
694 assert_eq!(convo.entries.len(), 3);
696 assert_eq!(convo.entries[0].uuid, "a1");
697 assert_eq!(convo.entries[1].uuid, "b1");
698 assert_eq!(convo.entries[2].uuid, "c1");
699
700 let convo_b = manager
702 .read_conversation("/test/project", "session-b")
703 .unwrap();
704 assert_eq!(
705 convo_b.session_ids,
706 vec!["session-a", "session-b", "session-c"]
707 );
708 assert_eq!(convo_b.entries.len(), 3);
709
710 let convo_c = manager
712 .read_conversation("/test/project", "session-c")
713 .unwrap();
714 assert_eq!(convo_c.entries.len(), 3);
715 }
716
717 #[test]
718 fn test_list_conversations_returns_chain_heads() {
719 let (_temp, manager) = setup_chained_conversations();
720
721 let sessions = manager.list_conversations("/test/project").unwrap();
722 assert_eq!(sessions.len(), 1);
724 assert!(sessions.contains(&"session-a".to_string()));
725 }
726
727 #[test]
728 fn test_read_segment_single_file() {
729 let (_temp, manager) = setup_chained_conversations();
730
731 let segment = manager.read_segment("/test/project", "session-b").unwrap();
733 assert_eq!(segment.session_id, "session-b");
734 assert_eq!(segment.entries.len(), 2); assert!(segment.session_ids.is_empty());
736 }
737
738 #[test]
739 fn test_list_segments_returns_all() {
740 let (_temp, manager) = setup_chained_conversations();
741
742 let mut segments = manager.list_segments("/test/project").unwrap();
743 segments.sort();
744 assert_eq!(segments, vec!["session-a", "session-b", "session-c"]);
745 }
746
747 #[test]
748 fn test_read_conversation_metadata_aggregates_chain() {
749 let (_temp, manager) = setup_chained_conversations();
750
751 let meta = manager
752 .read_conversation_metadata("/test/project", "session-a")
753 .unwrap();
754 assert_eq!(meta.session_id, "session-a");
755 assert_eq!(meta.message_count, 5);
757 assert!(meta.started_at.is_some());
759 assert!(meta.last_activity.is_some());
760 assert!(meta.last_activity > meta.started_at);
761 }
762}