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