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