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