Skip to main content

toolpath_claude/
lib.rs

1#![doc = include_str!("../README.md")]
2
3#[cfg(feature = "watcher")]
4pub mod async_watcher;
5pub mod derive;
6pub mod error;
7pub mod io;
8pub mod paths;
9pub mod provider;
10pub mod query;
11pub mod reader;
12pub mod types;
13#[cfg(feature = "watcher")]
14pub mod watcher;
15
16#[cfg(feature = "watcher")]
17pub use async_watcher::{AsyncConversationWatcher, WatcherConfig, WatcherHandle};
18pub use error::{ConvoError, Result};
19pub use io::ConvoIO;
20pub use paths::PathResolver;
21pub use query::{ConversationQuery, HistoryQuery};
22pub use reader::ConversationReader;
23pub use types::{
24    CacheCreation, ContentPart, Conversation, ConversationEntry, ConversationMetadata,
25    HistoryEntry, Message, MessageContent, MessageRole, ToolResultContent, ToolResultRef,
26    ToolUseRef, Usage,
27};
28#[cfg(feature = "watcher")]
29pub use watcher::ConversationWatcher;
30
31/// High-level interface for reading Claude conversations.
32///
33/// This is the primary entry point for most use cases. It provides
34/// convenient methods for reading conversations, listing projects,
35/// and accessing conversation history.
36///
37/// # Example
38///
39/// ```rust,no_run
40/// use toolpath_claude::ClaudeConvo;
41///
42/// let manager = ClaudeConvo::new();
43///
44/// // List all projects
45/// let projects = manager.list_projects()?;
46///
47/// // Read a conversation
48/// let convo = manager.read_conversation(
49///     "/Users/alex/project",
50///     "session-uuid"
51/// )?;
52///
53/// println!("Conversation has {} messages", convo.message_count());
54/// # Ok::<(), toolpath_claude::ConvoError>(())
55/// ```
56#[derive(Debug, Clone)]
57pub struct ClaudeConvo {
58    io: ConvoIO,
59}
60
61impl Default for ClaudeConvo {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl ClaudeConvo {
68    /// Creates a new ClaudeConvo manager with default path resolution.
69    pub fn new() -> Self {
70        Self { io: ConvoIO::new() }
71    }
72
73    /// Creates a ClaudeConvo manager with a custom path resolver.
74    ///
75    /// This is useful for testing or when working with non-standard paths.
76    ///
77    /// # Example
78    ///
79    /// ```rust
80    /// use toolpath_claude::{ClaudeConvo, PathResolver};
81    ///
82    /// let resolver = PathResolver::new()
83    ///     .with_home("/custom/home")
84    ///     .with_claude_dir("/custom/.claude");
85    ///
86    /// let manager = ClaudeConvo::with_resolver(resolver);
87    /// ```
88    pub fn with_resolver(resolver: PathResolver) -> Self {
89        Self {
90            io: ConvoIO::with_resolver(resolver),
91        }
92    }
93
94    /// Returns a reference to the underlying ConvoIO.
95    pub fn io(&self) -> &ConvoIO {
96        &self.io
97    }
98
99    /// Returns a reference to the path resolver.
100    pub fn resolver(&self) -> &PathResolver {
101        self.io.resolver()
102    }
103
104    /// Reads a conversation by project path and session ID.
105    ///
106    /// # Arguments
107    ///
108    /// * `project_path` - The project path (e.g., "/Users/alex/project")
109    /// * `session_id` - The session UUID
110    ///
111    /// # Returns
112    ///
113    /// Returns the parsed conversation or an error if the file doesn't exist or can't be parsed.
114    pub fn read_conversation(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
115        self.io.read_conversation(project_path, session_id)
116    }
117
118    /// Reads conversation metadata without loading the full content.
119    ///
120    /// This is more efficient when you only need basic information about a conversation.
121    pub fn read_conversation_metadata(
122        &self,
123        project_path: &str,
124        session_id: &str,
125    ) -> Result<ConversationMetadata> {
126        self.io.read_conversation_metadata(project_path, session_id)
127    }
128
129    /// Lists all conversation session IDs for a project.
130    pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
131        self.io.list_conversations(project_path)
132    }
133
134    /// Lists metadata for all conversations in a project.
135    ///
136    /// Results are sorted by last activity (most recent first).
137    pub fn list_conversation_metadata(
138        &self,
139        project_path: &str,
140    ) -> Result<Vec<ConversationMetadata>> {
141        self.io.list_conversation_metadata(project_path)
142    }
143
144    /// Lists all projects that have conversations.
145    ///
146    /// Returns the original project paths (e.g., "/Users/alex/project").
147    pub fn list_projects(&self) -> Result<Vec<String>> {
148        self.io.list_projects()
149    }
150
151    /// Reads the global history file.
152    ///
153    /// The history file contains a record of all queries across all projects.
154    pub fn read_history(&self) -> Result<Vec<HistoryEntry>> {
155        self.io.read_history()
156    }
157
158    /// Checks if the Claude directory exists.
159    pub fn exists(&self) -> bool {
160        self.io.exists()
161    }
162
163    /// Returns the path to the Claude directory.
164    pub fn claude_dir_path(&self) -> Result<std::path::PathBuf> {
165        self.io.claude_dir_path()
166    }
167
168    /// Checks if a specific conversation exists.
169    pub fn conversation_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
170        self.io.conversation_exists(project_path, session_id)
171    }
172
173    /// Checks if a project directory exists.
174    pub fn project_exists(&self, project_path: &str) -> bool {
175        self.io.project_exists(project_path)
176    }
177
178    /// Creates a query builder for a conversation.
179    pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
180        ConversationQuery::new(conversation)
181    }
182
183    /// Creates a query builder for history entries.
184    pub fn query_history<'a>(&self, history: &'a [HistoryEntry]) -> HistoryQuery<'a> {
185        HistoryQuery::new(history)
186    }
187
188    /// Reads all conversations for a project.
189    ///
190    /// Returns a vector of conversations sorted by last activity.
191    pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
192        let session_ids = self.list_conversations(project_path)?;
193        let mut conversations = Vec::new();
194
195        for session_id in session_ids {
196            match self.read_conversation(project_path, &session_id) {
197                Ok(convo) => conversations.push(convo),
198                Err(e) => {
199                    eprintln!("Warning: Failed to read conversation {}: {}", session_id, e);
200                }
201            }
202        }
203
204        conversations.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
205        Ok(conversations)
206    }
207
208    /// Gets the most recent conversation for a project.
209    pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
210        let metadata = self.list_conversation_metadata(project_path)?;
211
212        if let Some(latest) = metadata.first() {
213            Ok(Some(
214                self.read_conversation(project_path, &latest.session_id)?,
215            ))
216        } else {
217            Ok(None)
218        }
219    }
220
221    /// Finds conversations that contain specific text.
222    pub fn find_conversations_with_text(
223        &self,
224        project_path: &str,
225        search_text: &str,
226    ) -> Result<Vec<Conversation>> {
227        let conversations = self.read_all_conversations(project_path)?;
228
229        Ok(conversations
230            .into_iter()
231            .filter(|convo| {
232                let query = ConversationQuery::new(convo);
233                !query.contains_text(search_text).is_empty()
234            })
235            .collect())
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use std::fs;
243    use tempfile::TempDir;
244
245    fn setup_test_manager() -> (TempDir, ClaudeConvo) {
246        let temp = TempDir::new().unwrap();
247        let claude_dir = temp.path().join(".claude");
248        fs::create_dir_all(claude_dir.join("projects/-test-project")).unwrap();
249
250        let resolver = PathResolver::new().with_claude_dir(claude_dir);
251        let manager = ClaudeConvo::with_resolver(resolver);
252
253        (temp, manager)
254    }
255
256    #[test]
257    fn test_basic_setup() {
258        let (_temp, manager) = setup_test_manager();
259        assert!(manager.exists());
260    }
261
262    #[test]
263    fn test_list_projects() {
264        let (_temp, manager) = setup_test_manager();
265        let projects = manager.list_projects().unwrap();
266        assert_eq!(projects.len(), 1);
267        assert_eq!(projects[0], "/test/project");
268    }
269
270    #[test]
271    fn test_project_exists() {
272        let (_temp, manager) = setup_test_manager();
273        assert!(manager.project_exists("/test/project"));
274        assert!(!manager.project_exists("/nonexistent"));
275    }
276
277    fn setup_test_with_conversation() -> (TempDir, ClaudeConvo) {
278        let temp = TempDir::new().unwrap();
279        let claude_dir = temp.path().join(".claude");
280        let project_dir = claude_dir.join("projects/-test-project");
281        fs::create_dir_all(&project_dir).unwrap();
282
283        let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
284        let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi there"}}"#;
285        fs::write(
286            project_dir.join("session-abc.jsonl"),
287            format!("{}\n{}\n", entry1, entry2),
288        )
289        .unwrap();
290
291        let resolver = PathResolver::new().with_claude_dir(claude_dir);
292        let manager = ClaudeConvo::with_resolver(resolver);
293        (temp, manager)
294    }
295
296    #[test]
297    fn test_read_conversation() {
298        let (_temp, manager) = setup_test_with_conversation();
299        let convo = manager
300            .read_conversation("/test/project", "session-abc")
301            .unwrap();
302        assert_eq!(convo.entries.len(), 2);
303        assert_eq!(convo.message_count(), 2);
304    }
305
306    #[test]
307    fn test_read_conversation_metadata() {
308        let (_temp, manager) = setup_test_with_conversation();
309        let meta = manager
310            .read_conversation_metadata("/test/project", "session-abc")
311            .unwrap();
312        assert_eq!(meta.message_count, 2);
313        assert_eq!(meta.session_id, "session-abc");
314    }
315
316    #[test]
317    fn test_list_conversations() {
318        let (_temp, manager) = setup_test_with_conversation();
319        let sessions = manager.list_conversations("/test/project").unwrap();
320        assert_eq!(sessions.len(), 1);
321        assert_eq!(sessions[0], "session-abc");
322    }
323
324    #[test]
325    fn test_list_conversation_metadata() {
326        let (_temp, manager) = setup_test_with_conversation();
327        let metadata = manager.list_conversation_metadata("/test/project").unwrap();
328        assert_eq!(metadata.len(), 1);
329        assert_eq!(metadata[0].session_id, "session-abc");
330    }
331
332    #[test]
333    fn test_conversation_exists() {
334        let (_temp, manager) = setup_test_with_conversation();
335        assert!(
336            manager
337                .conversation_exists("/test/project", "session-abc")
338                .unwrap()
339        );
340        assert!(
341            !manager
342                .conversation_exists("/test/project", "nonexistent")
343                .unwrap()
344        );
345    }
346
347    #[test]
348    fn test_io_accessor() {
349        let (_temp, manager) = setup_test_with_conversation();
350        assert!(manager.io().exists());
351    }
352
353    #[test]
354    fn test_resolver_accessor() {
355        let (_temp, manager) = setup_test_with_conversation();
356        assert!(manager.resolver().exists());
357    }
358
359    #[test]
360    fn test_claude_dir_path() {
361        let (_temp, manager) = setup_test_with_conversation();
362        let path = manager.claude_dir_path().unwrap();
363        assert!(path.exists());
364    }
365
366    #[test]
367    fn test_read_all_conversations() {
368        let (_temp, manager) = setup_test_with_conversation();
369        let convos = manager.read_all_conversations("/test/project").unwrap();
370        assert_eq!(convos.len(), 1);
371    }
372
373    #[test]
374    fn test_most_recent_conversation() {
375        let (_temp, manager) = setup_test_with_conversation();
376        let convo = manager.most_recent_conversation("/test/project").unwrap();
377        assert!(convo.is_some());
378    }
379
380    #[test]
381    fn test_most_recent_conversation_empty() {
382        let (_temp, manager) = setup_test_manager();
383        // No conversations in this project
384        let convo = manager.most_recent_conversation("/test/project").unwrap();
385        assert!(convo.is_none());
386    }
387
388    #[test]
389    fn test_find_conversations_with_text() {
390        let (_temp, manager) = setup_test_with_conversation();
391        let results = manager
392            .find_conversations_with_text("/test/project", "Hello")
393            .unwrap();
394        assert_eq!(results.len(), 1);
395
396        let no_results = manager
397            .find_conversations_with_text("/test/project", "nonexistent text xyz")
398            .unwrap();
399        assert!(no_results.is_empty());
400    }
401
402    #[test]
403    fn test_query_helper() {
404        let (_temp, manager) = setup_test_with_conversation();
405        let convo = manager
406            .read_conversation("/test/project", "session-abc")
407            .unwrap();
408        let q = manager.query(&convo);
409        let users = q.by_role(MessageRole::User);
410        assert_eq!(users.len(), 1);
411    }
412
413    #[test]
414    fn test_query_history_helper() {
415        let (_temp, manager) = setup_test_manager();
416        let history: Vec<HistoryEntry> = vec![];
417        let q = manager.query_history(&history);
418        let results = q.recent(5);
419        assert!(results.is_empty());
420    }
421
422    #[test]
423    fn test_read_history_no_file() {
424        let (_temp, manager) = setup_test_manager();
425        let history = manager.read_history().unwrap();
426        assert!(history.is_empty());
427    }
428
429    #[test]
430    fn test_default_impl() {
431        // Test that Default trait works
432        let _manager = ClaudeConvo::default();
433    }
434}