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 #[allow(dead_code)]
345 pub(crate) fn session_chain(
346 &self,
347 project_path: &str,
348 session_id: &str,
349 ) -> Result<Vec<String>> {
350 self.chain_for(project_path, session_id)
351 }
352
353 #[allow(dead_code)]
357 pub(crate) fn chain_head(&self, project_path: &str, session_id: &str) -> Result<String> {
358 let chain = self.session_chain(project_path, session_id)?;
359 Ok(chain
360 .into_iter()
361 .next()
362 .unwrap_or_else(|| session_id.to_string()))
363 }
364
365 fn chain_for(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
370 let mut cache = self.chain_cache.borrow_mut();
371 let index = cache
372 .entry(project_path.to_string())
373 .or_insert_with(chain::ChainIndex::new);
374 index.refresh(self.resolver(), project_path)?;
375 Ok(index.resolve_chain(session_id))
376 }
377
378 fn chain_heads(&self, project_path: &str) -> Result<Vec<String>> {
380 let mut cache = self.chain_cache.borrow_mut();
381 let index = cache
382 .entry(project_path.to_string())
383 .or_insert_with(chain::ChainIndex::new);
384 index.refresh(self.resolver(), project_path)?;
385 Ok(index.chain_heads())
386 }
387
388 pub fn find_conversations_with_text(
390 &self,
391 project_path: &str,
392 search_text: &str,
393 ) -> Result<Vec<Conversation>> {
394 let conversations = self.read_all_conversations(project_path)?;
395
396 Ok(conversations
397 .into_iter()
398 .filter(|convo| {
399 let query = ConversationQuery::new(convo);
400 !query.contains_text(search_text).is_empty()
401 })
402 .collect())
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use std::fs;
410 use tempfile::TempDir;
411
412 fn setup_test_manager() -> (TempDir, ClaudeConvo) {
413 let temp = TempDir::new().unwrap();
414 let claude_dir = temp.path().join(".claude");
415 fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
416
417 let resolver = PathResolver::new().with_claude_dir(claude_dir);
418 let manager = ClaudeConvo::with_resolver(resolver);
419
420 (temp, manager)
421 }
422
423 #[test]
424 fn test_basic_setup() {
425 let (_temp, manager) = setup_test_manager();
426 assert!(manager.exists());
427 }
428
429 #[test]
430 fn test_list_projects() {
431 let (_temp, manager) = setup_test_manager();
432 let projects = manager.list_projects().unwrap();
433 assert_eq!(projects.len(), 1);
434 assert_eq!(projects[0], "/test/project");
435 }
436
437 #[test]
438 fn test_project_exists() {
439 let (_temp, manager) = setup_test_manager();
440 assert!(manager.project_exists("/test/project"));
441 assert!(!manager.project_exists("/nonexistent"));
442 }
443
444 fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
445 let temp = TempDir::new().unwrap();
446 let claude_dir = temp.path().join(".claude");
447 let project_dir = claude_dir.join("projects/-test-project");
448 fs::create_dir_all(&project_dir).unwrap();
449
450 let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
451 let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
452 fs::write(
453 project_dir.join("session-abc.jsonl"),
454 format!("{}\n{}\n", entry1, entry2),
455 )
456 .unwrap();
457
458 let resolver = PathResolver::new().with_claude_dir(claude_dir);
459 let manager = ClaudeConvo::with_resolver(resolver);
460 (temp, manager)
461 }
462
463 #[test]
464 fn test_read_conversation() {
465 let (_temp, manager) = setup_test_with_conversation();
466 let convo = manager
467 .read_conversation("/test/project", "session-abc")
468 .unwrap();
469 assert_eq!(convo.entries.len(), 2);
470 assert_eq!(convo.message_count(), 2);
471 }
472
473 #[test]
474 fn test_read_conversation_metadata() {
475 let (_temp, manager) = setup_test_with_conversation();
476 let meta = manager
477 .read_conversation_metadata("/test/project", "session-abc")
478 .unwrap();
479 assert_eq!(meta.message_count, 2);
480 assert_eq!(meta.session_id, "session-abc");
481 }
482
483 #[test]
484 fn test_list_conversations() {
485 let (_temp, manager) = setup_test_with_conversation();
486 let sessions = manager.list_conversations("/test/project").unwrap();
487 assert_eq!(sessions.len(), 1);
488 assert_eq!(sessions[0], "session-abc");
489 }
490
491 #[test]
492 fn test_list_conversation_metadata() {
493 let (_temp, manager) = setup_test_with_conversation();
494 let metadata = manager.list_conversation_metadata("/test/project").unwrap();
495 assert_eq!(metadata.len(), 1);
496 assert_eq!(metadata[0].session_id, "session-abc");
497 }
498
499 #[test]
500 fn test_conversation_exists() {
501 let (_temp, manager) = setup_test_with_conversation();
502 assert!(
503 manager
504 .conversation_exists("/test/project", "session-abc")
505 .unwrap()
506 );
507 assert!(
508 !manager
509 .conversation_exists("/test/project", "nonexistent")
510 .unwrap()
511 );
512 }
513
514 #[test]
515 fn test_io_accessor() {
516 let (_temp, manager) = setup_test_with_conversation();
517 assert!(manager.io().exists());
518 }
519
520 #[test]
521 fn test_resolver_accessor() {
522 let (_temp, manager) = setup_test_with_conversation();
523 assert!(manager.resolver().exists());
524 }
525
526 #[test]
527 fn test_claude_dir_path() {
528 let (_temp, manager) = setup_test_with_conversation();
529 let path = manager.claude_dir_path().unwrap();
530 assert!(path.exists());
531 }
532
533 #[test]
534 fn test_read_all_conversations() {
535 let (_temp, manager) = setup_test_with_conversation();
536 let convos = manager.read_all_conversations("/test/project").unwrap();
537 assert_eq!(convos.len(), 1);
538 }
539
540 #[test]
541 fn test_most_recent_conversation() {
542 let (_temp, manager) = setup_test_with_conversation();
543 let convo = manager.most_recent_conversation("/test/project").unwrap();
544 assert!(convo.is_some());
545 }
546
547 #[test]
548 fn test_most_recent_conversation_empty() {
549 let (_temp, manager) = setup_test_manager();
550 let convo = manager.most_recent_conversation("/test/project").unwrap();
552 assert!(convo.is_none());
553 }
554
555 #[test]
556 fn test_find_conversations_with_text() {
557 let (_temp, manager) = setup_test_with_conversation();
558 let results = manager
559 .find_conversations_with_text("/test/project", "Hello")
560 .unwrap();
561 assert_eq!(results.len(), 1);
562
563 let no_results = manager
564 .find_conversations_with_text("/test/project", "nonexistent text xyz")
565 .unwrap();
566 assert!(no_results.is_empty());
567 }
568
569 #[test]
570 fn test_query_helper() {
571 let (_temp, manager) = setup_test_with_conversation();
572 let convo = manager
573 .read_conversation("/test/project", "session-abc")
574 .unwrap();
575 let q = manager.query(&convo);
576 let users = q.by_role(MessageRole::User);
577 assert_eq!(users.len(), 1);
578 }
579
580 #[test]
581 fn test_query_history_helper() {
582 let (_temp, manager) = setup_test_manager();
583 let history: Vec<HistoryEntry> = vec![];
584 let q = manager.query_history(&history);
585 let results = q.recent(5);
586 assert!(results.is_empty());
587 }
588
589 #[test]
590 fn test_read_history_no_file() {
591 let (_temp, manager) = setup_test_manager();
592 let history = manager.read_history().unwrap();
593 assert!(history.is_empty());
594 }
595
596 #[test]
597 fn test_default_impl() {
598 let _manager = ClaudeConvo::default();
600 }
601
602 fn setup_chained_conversations() -> (TempDir, ClaudeConvo) {
605 let temp = TempDir::new().unwrap();
606 let claude_dir = temp.path().join(".claude");
607 let project_dir = claude_dir.join("projects/-test-project");
608 fs::create_dir_all(&project_dir).unwrap();
609
610 fs::write(
612 project_dir.join("session-a.jsonl"),
613 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
614 ).unwrap();
615
616 let b = [
618 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
619 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#,
620 ];
621 fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
622
623 let c = [
625 r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#,
626 r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#,
627 ];
628 fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap();
629
630 let resolver = PathResolver::new().with_claude_dir(claude_dir);
631 (temp, ClaudeConvo::with_resolver(resolver))
632 }
633
634 #[test]
635 fn test_session_chain_full() {
636 let (_temp, manager) = setup_chained_conversations();
637 let chain = manager.session_chain("/test/project", "session-a").unwrap();
638 assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
639 }
640
641 #[test]
642 fn test_session_chain_from_middle() {
643 let (_temp, manager) = setup_chained_conversations();
644 let chain = manager.session_chain("/test/project", "session-b").unwrap();
645 assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
646 }
647
648 #[test]
649 fn test_session_chain_single() {
650 let (_temp, manager) = setup_test_with_conversation();
651 let chain = manager
652 .session_chain("/test/project", "session-abc")
653 .unwrap();
654 assert_eq!(chain, vec!["session-abc"]);
655 }
656
657 #[test]
658 fn test_chain_head_from_tail() {
659 let (_temp, manager) = setup_chained_conversations();
660 let head = manager.chain_head("/test/project", "session-c").unwrap();
661 assert_eq!(head, "session-a");
662 }
663
664 #[test]
665 fn test_chain_head_already_head() {
666 let (_temp, manager) = setup_chained_conversations();
667 let head = manager.chain_head("/test/project", "session-a").unwrap();
668 assert_eq!(head, "session-a");
669 }
670
671 #[test]
672 fn test_chain_head_single_session() {
673 let (_temp, manager) = setup_test_with_conversation();
674 let head = manager.chain_head("/test/project", "session-abc").unwrap();
675 assert_eq!(head, "session-abc");
676 }
677
678 #[test]
681 fn test_read_conversation_follows_chain() {
682 let (_temp, manager) = setup_chained_conversations();
683
684 let convo = manager
686 .read_conversation("/test/project", "session-a")
687 .unwrap();
688 assert_eq!(convo.session_id, "session-a");
689 assert_eq!(
690 convo.session_ids,
691 vec!["session-a", "session-b", "session-c"]
692 );
693 assert_eq!(convo.entries.len(), 3);
695 assert_eq!(convo.entries[0].uuid, "a1");
696 assert_eq!(convo.entries[1].uuid, "b1");
697 assert_eq!(convo.entries[2].uuid, "c1");
698
699 let convo_b = manager
701 .read_conversation("/test/project", "session-b")
702 .unwrap();
703 assert_eq!(
704 convo_b.session_ids,
705 vec!["session-a", "session-b", "session-c"]
706 );
707 assert_eq!(convo_b.entries.len(), 3);
708
709 let convo_c = manager
711 .read_conversation("/test/project", "session-c")
712 .unwrap();
713 assert_eq!(convo_c.entries.len(), 3);
714 }
715
716 #[test]
717 fn test_list_conversations_returns_chain_heads() {
718 let (_temp, manager) = setup_chained_conversations();
719
720 let sessions = manager.list_conversations("/test/project").unwrap();
721 assert_eq!(sessions.len(), 1);
723 assert!(sessions.contains(&"session-a".to_string()));
724 }
725
726 #[test]
727 fn test_read_segment_single_file() {
728 let (_temp, manager) = setup_chained_conversations();
729
730 let segment = manager.read_segment("/test/project", "session-b").unwrap();
732 assert_eq!(segment.session_id, "session-b");
733 assert_eq!(segment.entries.len(), 2); assert!(segment.session_ids.is_empty());
735 }
736
737 #[test]
738 fn test_list_segments_returns_all() {
739 let (_temp, manager) = setup_chained_conversations();
740
741 let mut segments = manager.list_segments("/test/project").unwrap();
742 segments.sort();
743 assert_eq!(segments, vec!["session-a", "session-b", "session-c"]);
744 }
745
746 #[test]
747 fn test_read_conversation_metadata_aggregates_chain() {
748 let (_temp, manager) = setup_chained_conversations();
749
750 let meta = manager
751 .read_conversation_metadata("/test/project", "session-a")
752 .unwrap();
753 assert_eq!(meta.session_id, "session-a");
754 assert_eq!(meta.message_count, 5);
756 assert!(meta.started_at.is_some());
758 assert!(meta.last_activity.is_some());
759 assert!(meta.last_activity > meta.started_at);
760 }
761}