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