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 project_path_val = String::new();
186 let mut file_path = std::path::PathBuf::new();
187 let mut first_user_message: Option<String> = None;
188
189 for (i, segment_id) in chain.iter().enumerate() {
190 let meta = self
191 .io
192 .read_conversation_metadata(project_path, segment_id)?;
193 total_messages += meta.message_count;
194
195 if started_at.is_none() || meta.started_at < started_at {
196 started_at = meta.started_at;
197 }
198 if last_activity.is_none() || meta.last_activity > last_activity {
199 last_activity = meta.last_activity;
200 }
201 if project_path_val.is_empty() {
202 project_path_val = meta.project_path;
203 }
204 if i == 0 {
205 file_path = meta.file_path;
206 }
207 if first_user_message.is_none() && meta.first_user_message.is_some() {
209 first_user_message = meta.first_user_message;
210 }
211 }
212
213 Ok(ConversationMetadata {
214 session_id: head.clone(),
215 project_path: project_path_val,
216 file_path,
217 message_count: total_messages,
218 started_at,
219 last_activity,
220 first_user_message,
221 })
222 }
223
224 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
229 self.chain_heads(project_path)
230 }
231
232 pub fn list_conversation_metadata(
236 &self,
237 project_path: &str,
238 ) -> Result<Vec<ConversationMetadata>> {
239 let heads = self.chain_heads(project_path)?;
240 let mut metadata = Vec::new();
241
242 for session_id in heads {
243 match self.read_conversation_metadata(project_path, &session_id) {
244 Ok(meta) => metadata.push(meta),
245 Err(e) => {
246 eprintln!("Warning: Failed to read metadata for {}: {}", session_id, e);
247 }
248 }
249 }
250
251 metadata.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
252 Ok(metadata)
253 }
254
255 pub fn read_segment(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
259 self.io.read_conversation(project_path, session_id)
260 }
261
262 pub fn list_segments(&self, project_path: &str) -> Result<Vec<String>> {
264 self.io.list_conversations(project_path)
265 }
266
267 pub fn list_projects(&self) -> Result<Vec<String>> {
271 self.io.list_projects()
272 }
273
274 pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
278 self.io.read_history()
279 }
280
281 pub fn exists(&self) -> bool {
283 self.io.exists()
284 }
285
286 pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
288 self.io.claude_dir_path()
289 }
290
291 pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
293 self.io.conversation_exists(project_path, session_id)
294 }
295
296 pub fn project_exists(&self, project_path: &str) -> bool {
298 self.io.project_exists(project_path)
299 }
300
301 pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
303 ConversationQuery::new(conversation)
304 }
305
306 pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
308 HistoryQuery::new(history)
309 }
310
311 pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
315 let session_ids = self.list_conversations(project_path)?;
316 let mut conversations = Vec::new();
317
318 for session_id in session_ids {
319 match self.read_conversation(project_path, &session_id) {
320 Ok(convo) => conversations.push(convo),
321 Err(e) => {
322 eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
323 }
324 }
325 }
326
327 conversations.sort_by_key(|c| std::cmp::Reverse(c.last_activity));
328 Ok(conversations)
329 }
330
331 pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
333 let metadata = self.list_conversation_metadata(project_path)?;
334
335 if let Some(latest) = metadata.first() {
336 Ok(Some(
337 self.read_conversation(project_path, &latest.session_id)?,
338 ))
339 } else {
340 Ok(None)
341 }
342 }
343
344 #[allow(dead_code)]
349 pub(crate) fn session_chain(
350 &self,
351 project_path: &str,
352 session_id: &str,
353 ) -> Result<Vec<String>> {
354 self.chain_for(project_path, session_id)
355 }
356
357 #[allow(dead_code)]
361 pub(crate) fn chain_head(&self, project_path: &str, session_id: &str) -> Result<String> {
362 let chain = self.session_chain(project_path, session_id)?;
363 Ok(chain
364 .into_iter()
365 .next()
366 .unwrap_or_else(|| session_id.to_string()))
367 }
368
369 fn chain_for(&self, project_path: &str, session_id: &str) -> Result<Vec<String>> {
374 let mut cache = self.chain_cache.borrow_mut();
375 let index = cache
376 .entry(project_path.to_string())
377 .or_insert_with(chain::ChainIndex::new);
378 index.refresh(self.resolver(), project_path)?;
379 Ok(index.resolve_chain(session_id))
380 }
381
382 fn chain_heads(&self, project_path: &str) -> Result<Vec<String>> {
384 let mut cache = self.chain_cache.borrow_mut();
385 let index = cache
386 .entry(project_path.to_string())
387 .or_insert_with(chain::ChainIndex::new);
388 index.refresh(self.resolver(), project_path)?;
389 Ok(index.chain_heads())
390 }
391
392 pub fn find_conversations_with_text(
394 &self,
395 project_path: &str,
396 search_text: &str,
397 ) -> Result<Vec<Conversation>> {
398 let conversations = self.read_all_conversations(project_path)?;
399
400 Ok(conversations
401 .into_iter()
402 .filter(|convo| {
403 let query = ConversationQuery::new(convo);
404 !query.contains_text(search_text).is_empty()
405 })
406 .collect())
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::fs;
414 use tempfile::TempDir;
415
416 fn setup_test_manager() -> (TempDir, ClaudeConvo) {
417 let temp = TempDir::new().unwrap();
418 let claude_dir = temp.path().join(".claude");
419 fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
420
421 let resolver = PathResolver::new().with_claude_dir(claude_dir);
422 let manager = ClaudeConvo::with_resolver(resolver);
423
424 (temp, manager)
425 }
426
427 #[test]
428 fn test_basic_setup() {
429 let (_temp, manager) = setup_test_manager();
430 assert!(manager.exists());
431 }
432
433 #[test]
434 fn test_list_projects() {
435 let (_temp, manager) = setup_test_manager();
436 let projects = manager.list_projects().unwrap();
437 assert_eq!(projects.len(), 1);
438 assert_eq!(projects[0], "/test/project");
439 }
440
441 #[test]
442 fn test_project_exists() {
443 let (_temp, manager) = setup_test_manager();
444 assert!(manager.project_exists("/test/project"));
445 assert!(!manager.project_exists("/nonexistent"));
446 }
447
448 fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
449 let temp = TempDir::new().unwrap();
450 let claude_dir = temp.path().join(".claude");
451 let project_dir = claude_dir.join("projects/-test-project");
452 fs::create_dir_all(&project_dir).unwrap();
453
454 let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
455 let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
456 fs::write(
457 project_dir.join("session-abc.jsonl"),
458 format!("{}\n{}\n", entry1, entry2),
459 )
460 .unwrap();
461
462 let resolver = PathResolver::new().with_claude_dir(claude_dir);
463 let manager = ClaudeConvo::with_resolver(resolver);
464 (temp, manager)
465 }
466
467 #[test]
468 fn test_read_conversation() {
469 let (_temp, manager) = setup_test_with_conversation();
470 let convo = manager
471 .read_conversation("/test/project", "session-abc")
472 .unwrap();
473 assert_eq!(convo.entries.len(), 2);
474 assert_eq!(convo.message_count(), 2);
475 }
476
477 #[test]
478 fn test_read_conversation_metadata() {
479 let (_temp, manager) = setup_test_with_conversation();
480 let meta = manager
481 .read_conversation_metadata("/test/project", "session-abc")
482 .unwrap();
483 assert_eq!(meta.message_count, 2);
484 assert_eq!(meta.session_id, "session-abc");
485 }
486
487 #[test]
488 fn test_list_conversations() {
489 let (_temp, manager) = setup_test_with_conversation();
490 let sessions = manager.list_conversations("/test/project").unwrap();
491 assert_eq!(sessions.len(), 1);
492 assert_eq!(sessions[0], "session-abc");
493 }
494
495 #[test]
496 fn test_list_conversation_metadata() {
497 let (_temp, manager) = setup_test_with_conversation();
498 let metadata = manager.list_conversation_metadata("/test/project").unwrap();
499 assert_eq!(metadata.len(), 1);
500 assert_eq!(metadata[0].session_id, "session-abc");
501 }
502
503 #[test]
504 fn test_conversation_exists() {
505 let (_temp, manager) = setup_test_with_conversation();
506 assert!(
507 manager
508 .conversation_exists("/test/project", "session-abc")
509 .unwrap()
510 );
511 assert!(
512 !manager
513 .conversation_exists("/test/project", "nonexistent")
514 .unwrap()
515 );
516 }
517
518 #[test]
519 fn test_io_accessor() {
520 let (_temp, manager) = setup_test_with_conversation();
521 assert!(manager.io().exists());
522 }
523
524 #[test]
525 fn test_resolver_accessor() {
526 let (_temp, manager) = setup_test_with_conversation();
527 assert!(manager.resolver().exists());
528 }
529
530 #[test]
531 fn test_claude_dir_path() {
532 let (_temp, manager) = setup_test_with_conversation();
533 let path = manager.claude_dir_path().unwrap();
534 assert!(path.exists());
535 }
536
537 #[test]
538 fn test_read_all_conversations() {
539 let (_temp, manager) = setup_test_with_conversation();
540 let convos = manager.read_all_conversations("/test/project").unwrap();
541 assert_eq!(convos.len(), 1);
542 }
543
544 #[test]
545 fn test_most_recent_conversation() {
546 let (_temp, manager) = setup_test_with_conversation();
547 let convo = manager.most_recent_conversation("/test/project").unwrap();
548 assert!(convo.is_some());
549 }
550
551 #[test]
552 fn test_most_recent_conversation_empty() {
553 let (_temp, manager) = setup_test_manager();
554 let convo = manager.most_recent_conversation("/test/project").unwrap();
556 assert!(convo.is_none());
557 }
558
559 #[test]
560 fn test_find_conversations_with_text() {
561 let (_temp, manager) = setup_test_with_conversation();
562 let results = manager
563 .find_conversations_with_text("/test/project", "Hello")
564 .unwrap();
565 assert_eq!(results.len(), 1);
566
567 let no_results = manager
568 .find_conversations_with_text("/test/project", "nonexistent text xyz")
569 .unwrap();
570 assert!(no_results.is_empty());
571 }
572
573 #[test]
574 fn test_query_helper() {
575 let (_temp, manager) = setup_test_with_conversation();
576 let convo = manager
577 .read_conversation("/test/project", "session-abc")
578 .unwrap();
579 let q = manager.query(&convo);
580 let users = q.by_role(MessageRole::User);
581 assert_eq!(users.len(), 1);
582 }
583
584 #[test]
585 fn test_query_history_helper() {
586 let (_temp, manager) = setup_test_manager();
587 let history: Vec<HistoryEntry> = vec![];
588 let q = manager.query_history(&history);
589 let results = q.recent(5);
590 assert!(results.is_empty());
591 }
592
593 #[test]
594 fn test_read_history_no_file() {
595 let (_temp, manager) = setup_test_manager();
596 let history = manager.read_history().unwrap();
597 assert!(history.is_empty());
598 }
599
600 #[test]
601 fn test_default_impl() {
602 let _manager = ClaudeConvo::default();
604 }
605
606 fn setup_chained_conversations() -> (TempDir, ClaudeConvo) {
609 let temp = TempDir::new().unwrap();
610 let claude_dir = temp.path().join(".claude");
611 let project_dir = claude_dir.join("projects/-test-project");
612 fs::create_dir_all(&project_dir).unwrap();
613
614 fs::write(
616 project_dir.join("session-a.jsonl"),
617 r#"{"uuid":"a1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Start"}}"#,
618 ).unwrap();
619
620 let b = [
622 r#"{"uuid":"b0","type":"user","timestamp":"2024-01-01T01:00:00Z","sessionId":"session-a","message":{"role":"user","content":"Bridge"}}"#,
623 r#"{"uuid":"b1","type":"user","timestamp":"2024-01-01T01:00:01Z","sessionId":"session-b","message":{"role":"user","content":"Middle"}}"#,
624 ];
625 fs::write(project_dir.join("session-b.jsonl"), b.join("\n")).unwrap();
626
627 let c = [
629 r#"{"uuid":"c0","type":"user","timestamp":"2024-01-01T02:00:00Z","sessionId":"session-b","message":{"role":"user","content":"Bridge"}}"#,
630 r#"{"uuid":"c1","type":"user","timestamp":"2024-01-01T02:00:01Z","sessionId":"session-c","message":{"role":"user","content":"End"}}"#,
631 ];
632 fs::write(project_dir.join("session-c.jsonl"), c.join("\n")).unwrap();
633
634 let resolver = PathResolver::new().with_claude_dir(claude_dir);
635 (temp, ClaudeConvo::with_resolver(resolver))
636 }
637
638 #[test]
639 fn test_session_chain_full() {
640 let (_temp, manager) = setup_chained_conversations();
641 let chain = manager.session_chain("/test/project", "session-a").unwrap();
642 assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
643 }
644
645 #[test]
646 fn test_session_chain_from_middle() {
647 let (_temp, manager) = setup_chained_conversations();
648 let chain = manager.session_chain("/test/project", "session-b").unwrap();
649 assert_eq!(chain, vec!["session-a", "session-b", "session-c"]);
650 }
651
652 #[test]
653 fn test_session_chain_single() {
654 let (_temp, manager) = setup_test_with_conversation();
655 let chain = manager
656 .session_chain("/test/project", "session-abc")
657 .unwrap();
658 assert_eq!(chain, vec!["session-abc"]);
659 }
660
661 #[test]
662 fn test_chain_head_from_tail() {
663 let (_temp, manager) = setup_chained_conversations();
664 let head = manager.chain_head("/test/project", "session-c").unwrap();
665 assert_eq!(head, "session-a");
666 }
667
668 #[test]
669 fn test_chain_head_already_head() {
670 let (_temp, manager) = setup_chained_conversations();
671 let head = manager.chain_head("/test/project", "session-a").unwrap();
672 assert_eq!(head, "session-a");
673 }
674
675 #[test]
676 fn test_chain_head_single_session() {
677 let (_temp, manager) = setup_test_with_conversation();
678 let head = manager.chain_head("/test/project", "session-abc").unwrap();
679 assert_eq!(head, "session-abc");
680 }
681
682 #[test]
685 fn test_read_conversation_follows_chain() {
686 let (_temp, manager) = setup_chained_conversations();
687
688 let convo = manager
690 .read_conversation("/test/project", "session-a")
691 .unwrap();
692 assert_eq!(convo.session_id, "session-a");
693 assert_eq!(
694 convo.session_ids,
695 vec!["session-a", "session-b", "session-c"]
696 );
697 assert_eq!(convo.entries.len(), 3);
699 assert_eq!(convo.entries[0].uuid, "a1");
700 assert_eq!(convo.entries[1].uuid, "b1");
701 assert_eq!(convo.entries[2].uuid, "c1");
702
703 let convo_b = manager
705 .read_conversation("/test/project", "session-b")
706 .unwrap();
707 assert_eq!(
708 convo_b.session_ids,
709 vec!["session-a", "session-b", "session-c"]
710 );
711 assert_eq!(convo_b.entries.len(), 3);
712
713 let convo_c = manager
715 .read_conversation("/test/project", "session-c")
716 .unwrap();
717 assert_eq!(convo_c.entries.len(), 3);
718 }
719
720 #[test]
721 fn test_list_conversations_returns_chain_heads() {
722 let (_temp, manager) = setup_chained_conversations();
723
724 let sessions = manager.list_conversations("/test/project").unwrap();
725 assert_eq!(sessions.len(), 1);
727 assert!(sessions.contains(&"session-a".to_string()));
728 }
729
730 #[test]
731 fn test_read_segment_single_file() {
732 let (_temp, manager) = setup_chained_conversations();
733
734 let segment = manager.read_segment("/test/project", "session-b").unwrap();
736 assert_eq!(segment.session_id, "session-b");
737 assert_eq!(segment.entries.len(), 2); assert!(segment.session_ids.is_empty());
739 }
740
741 #[test]
742 fn test_list_segments_returns_all() {
743 let (_temp, manager) = setup_chained_conversations();
744
745 let mut segments = manager.list_segments("/test/project").unwrap();
746 segments.sort();
747 assert_eq!(segments, vec!["session-a", "session-b", "session-c"]);
748 }
749
750 #[test]
751 fn test_read_conversation_metadata_aggregates_chain() {
752 let (_temp, manager) = setup_chained_conversations();
753
754 let meta = manager
755 .read_conversation_metadata("/test/project", "session-a")
756 .unwrap();
757 assert_eq!(meta.session_id, "session-a");
758 assert_eq!(meta.message_count, 5);
760 assert!(meta.started_at.is_some());
762 assert!(meta.last_activity.is_some());
763 assert!(meta.last_activity > meta.started_at);
764 }
765}