Skip to main content

toolpath_claude/
lib.rs

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