Skip to main content

toolpath_claude/
provider.rs

1//! Implementation of `toolpath-convo` traits for Claude conversations.
2
3use crate::ClaudeConvo;
4use crate::types::{
5    ContentPart, Conversation, ConversationEntry, Message, MessageContent, MessageRole,
6};
7use toolpath_convo::{
8    ConversationMeta, ConversationProvider, ConversationView, ConvoError, Role, TokenUsage,
9    ToolInvocation, ToolResult, Turn, WatcherEvent,
10};
11
12// ── Conversion helpers ───────────────────────────────────────────────
13
14fn claude_role_to_role(role: &MessageRole) -> Role {
15    match role {
16        MessageRole::User => Role::User,
17        MessageRole::Assistant => Role::Assistant,
18        MessageRole::System => Role::System,
19    }
20}
21
22fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
23    let text = msg.text();
24
25    let thinking = msg.thinking().map(|parts| parts.join("\n"));
26
27    let tool_uses = msg
28        .tool_uses()
29        .into_iter()
30        .map(|tu| {
31            let result = find_tool_result_in_parts(msg, tu.id);
32            ToolInvocation {
33                id: tu.id.to_string(),
34                name: tu.name.to_string(),
35                input: tu.input.clone(),
36                result,
37            }
38        })
39        .collect();
40
41    let token_usage = msg.usage.as_ref().map(|u| TokenUsage {
42        input_tokens: u.input_tokens,
43        output_tokens: u.output_tokens,
44    });
45
46    Turn {
47        id: entry.uuid.clone(),
48        parent_id: entry.parent_uuid.clone(),
49        role: claude_role_to_role(&msg.role),
50        timestamp: entry.timestamp.clone(),
51        text,
52        thinking,
53        tool_uses,
54        model: msg.model.clone(),
55        stop_reason: msg.stop_reason.clone(),
56        token_usage,
57        extra: Default::default(),
58    }
59}
60
61fn find_tool_result_in_parts(msg: &Message, tool_use_id: &str) -> Option<ToolResult> {
62    let parts = match &msg.content {
63        Some(MessageContent::Parts(parts)) => parts,
64        _ => return None,
65    };
66    parts.iter().find_map(|p| match p {
67        ContentPart::ToolResult {
68            tool_use_id: id,
69            content,
70            is_error,
71        } if id == tool_use_id => Some(ToolResult {
72            content: content.text(),
73            is_error: *is_error,
74        }),
75        _ => None,
76    })
77}
78
79fn entry_to_turn(entry: &ConversationEntry) -> Option<Turn> {
80    entry
81        .message
82        .as_ref()
83        .map(|msg| message_to_turn(entry, msg))
84}
85
86fn conversation_to_view(convo: &Conversation) -> ConversationView {
87    let turns = convo.entries.iter().filter_map(entry_to_turn).collect();
88
89    ConversationView {
90        id: convo.session_id.clone(),
91        started_at: convo.started_at,
92        last_activity: convo.last_activity,
93        turns,
94    }
95}
96
97fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
98    match entry_to_turn(entry) {
99        Some(turn) => WatcherEvent::Turn(Box::new(turn)),
100        None => WatcherEvent::Progress {
101            kind: entry.entry_type.clone(),
102            data: serde_json::json!({
103                "uuid": entry.uuid,
104                "timestamp": entry.timestamp,
105            }),
106        },
107    }
108}
109
110// ── ConversationProvider for ClaudeConvo ──────────────────────────────
111
112impl ConversationProvider for ClaudeConvo {
113    fn list_conversations(&self, project: &str) -> toolpath_convo::Result<Vec<String>> {
114        crate::ClaudeConvo::list_conversations(self, project)
115            .map_err(|e| ConvoError::Provider(e.to_string()))
116    }
117
118    fn load_conversation(
119        &self,
120        project: &str,
121        conversation_id: &str,
122    ) -> toolpath_convo::Result<ConversationView> {
123        let convo = self
124            .read_conversation(project, conversation_id)
125            .map_err(|e| ConvoError::Provider(e.to_string()))?;
126        Ok(conversation_to_view(&convo))
127    }
128
129    fn load_metadata(
130        &self,
131        project: &str,
132        conversation_id: &str,
133    ) -> toolpath_convo::Result<ConversationMeta> {
134        let meta = self
135            .read_conversation_metadata(project, conversation_id)
136            .map_err(|e| ConvoError::Provider(e.to_string()))?;
137        Ok(ConversationMeta {
138            id: meta.session_id,
139            started_at: meta.started_at,
140            last_activity: meta.last_activity,
141            message_count: meta.message_count,
142            file_path: Some(meta.file_path),
143        })
144    }
145
146    fn list_metadata(&self, project: &str) -> toolpath_convo::Result<Vec<ConversationMeta>> {
147        let metas = self
148            .list_conversation_metadata(project)
149            .map_err(|e| ConvoError::Provider(e.to_string()))?;
150        Ok(metas
151            .into_iter()
152            .map(|m| ConversationMeta {
153                id: m.session_id,
154                started_at: m.started_at,
155                last_activity: m.last_activity,
156                message_count: m.message_count,
157                file_path: Some(m.file_path),
158            })
159            .collect())
160    }
161}
162
163// ── ConversationWatcher for ConversationWatcher ──────────────────────
164
165#[cfg(feature = "watcher")]
166impl toolpath_convo::ConversationWatcher for crate::watcher::ConversationWatcher {
167    fn poll(&mut self) -> toolpath_convo::Result<Vec<WatcherEvent>> {
168        let entries = crate::watcher::ConversationWatcher::poll(self)
169            .map_err(|e| ConvoError::Provider(e.to_string()))?;
170        Ok(entries.iter().map(entry_to_watcher_event).collect())
171    }
172
173    fn seen_count(&self) -> usize {
174        crate::watcher::ConversationWatcher::seen_count(self)
175    }
176}
177
178// ── Public re-exports for convenience ────────────────────────────────
179
180/// Convert a Claude [`Conversation`] directly into a [`ConversationView`].
181///
182/// This is useful when you already have a loaded `Conversation` and want
183/// to convert it without going through the trait.
184pub fn to_view(convo: &Conversation) -> ConversationView {
185    conversation_to_view(convo)
186}
187
188/// Convert a single Claude [`ConversationEntry`] into a [`Turn`], if it
189/// contains a message.
190pub fn to_turn(entry: &ConversationEntry) -> Option<Turn> {
191    entry_to_turn(entry)
192}
193
194// ── Tests ────────────────────────────────────────────────────────────
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::PathResolver;
200    use std::fs;
201    use tempfile::TempDir;
202
203    fn setup_provider() -> (TempDir, ClaudeConvo) {
204        let temp = TempDir::new().unwrap();
205        let claude_dir = temp.path().join(".claude");
206        let project_dir = claude_dir.join("projects/-test-project");
207        fs::create_dir_all(&project_dir).unwrap();
208
209        let entries = vec![
210            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Fix the bug"}}"#,
211            r#"{"uuid":"uuid-2","type":"assistant","parentUuid":"uuid-1","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"I'll fix that."},{"type":"thinking","thinking":"The bug is in auth"},{"type":"tool_use","id":"t1","name":"Read","input":{"file":"src/main.rs"}}],"model":"claude-opus-4-6","stopReason":"end_turn","usage":{"inputTokens":100,"outputTokens":50}}}"#,
212            r#"{"uuid":"uuid-3","type":"user","parentUuid":"uuid-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"Thanks!"}}"#,
213        ];
214        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
215
216        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
217        (temp, ClaudeConvo::with_resolver(resolver))
218    }
219
220    #[test]
221    fn test_load_conversation() {
222        let (_temp, provider) = setup_provider();
223        let view = ConversationProvider::load_conversation(&provider, "/test/project", "session-1")
224            .unwrap();
225
226        assert_eq!(view.id, "session-1");
227        assert_eq!(view.turns.len(), 3);
228
229        // First turn: user
230        assert_eq!(view.turns[0].role, Role::User);
231        assert_eq!(view.turns[0].text, "Fix the bug");
232        assert!(view.turns[0].parent_id.is_none());
233
234        // Second turn: assistant with thinking + tool use
235        assert_eq!(view.turns[1].role, Role::Assistant);
236        assert_eq!(view.turns[1].text, "I'll fix that.");
237        assert_eq!(
238            view.turns[1].thinking.as_deref(),
239            Some("The bug is in auth")
240        );
241        assert_eq!(view.turns[1].tool_uses.len(), 1);
242        assert_eq!(view.turns[1].tool_uses[0].name, "Read");
243        assert_eq!(view.turns[1].model.as_deref(), Some("claude-opus-4-6"));
244        assert_eq!(view.turns[1].stop_reason.as_deref(), Some("end_turn"));
245        assert_eq!(view.turns[1].parent_id.as_deref(), Some("uuid-1"));
246
247        // Token usage
248        let usage = view.turns[1].token_usage.as_ref().unwrap();
249        assert_eq!(usage.input_tokens, Some(100));
250        assert_eq!(usage.output_tokens, Some(50));
251
252        // Third turn: user
253        assert_eq!(view.turns[2].role, Role::User);
254        assert_eq!(view.turns[2].text, "Thanks!");
255    }
256
257    #[test]
258    fn test_list_conversations() {
259        let (_temp, provider) = setup_provider();
260        let ids = ConversationProvider::list_conversations(&provider, "/test/project").unwrap();
261        assert_eq!(ids, vec!["session-1"]);
262    }
263
264    #[test]
265    fn test_load_metadata() {
266        let (_temp, provider) = setup_provider();
267        let meta =
268            ConversationProvider::load_metadata(&provider, "/test/project", "session-1").unwrap();
269        assert_eq!(meta.id, "session-1");
270        assert_eq!(meta.message_count, 3);
271        assert!(meta.file_path.is_some());
272    }
273
274    #[test]
275    fn test_list_metadata() {
276        let (_temp, provider) = setup_provider();
277        let metas = ConversationProvider::list_metadata(&provider, "/test/project").unwrap();
278        assert_eq!(metas.len(), 1);
279        assert_eq!(metas[0].id, "session-1");
280    }
281
282    #[test]
283    fn test_to_view() {
284        let (_temp, manager) = setup_provider();
285        let convo = manager
286            .read_conversation("/test/project", "session-1")
287            .unwrap();
288        let view = to_view(&convo);
289        assert_eq!(view.turns.len(), 3);
290        assert_eq!(view.title(20).unwrap(), "Fix the bug");
291    }
292
293    #[test]
294    fn test_to_turn_with_message() {
295        let entry: ConversationEntry = serde_json::from_str(
296            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
297        )
298        .unwrap();
299        let turn = to_turn(&entry).unwrap();
300        assert_eq!(turn.id, "u1");
301        assert_eq!(turn.text, "hello");
302        assert_eq!(turn.role, Role::User);
303    }
304
305    #[test]
306    fn test_to_turn_without_message() {
307        let entry: ConversationEntry = serde_json::from_str(
308            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
309        )
310        .unwrap();
311        assert!(to_turn(&entry).is_none());
312    }
313
314    #[test]
315    fn test_entry_to_watcher_event_turn() {
316        let entry: ConversationEntry = serde_json::from_str(
317            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
318        )
319        .unwrap();
320        let event = entry_to_watcher_event(&entry);
321        assert!(matches!(event, WatcherEvent::Turn(_)));
322    }
323
324    #[test]
325    fn test_entry_to_watcher_event_progress() {
326        let entry: ConversationEntry = serde_json::from_str(
327            r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
328        )
329        .unwrap();
330        let event = entry_to_watcher_event(&entry);
331        assert!(matches!(event, WatcherEvent::Progress { .. }));
332    }
333
334    #[cfg(feature = "watcher")]
335    #[test]
336    fn test_watcher_trait() {
337        let temp = TempDir::new().unwrap();
338        let claude_dir = temp.path().join(".claude");
339        let project_dir = claude_dir.join("projects/-test-project");
340        fs::create_dir_all(&project_dir).unwrap();
341
342        let entries = vec![
343            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
344            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
345        ];
346        fs::write(project_dir.join("session-1.jsonl"), entries.join("\n")).unwrap();
347
348        let resolver = PathResolver::new().with_claude_dir(&claude_dir);
349        let manager = ClaudeConvo::with_resolver(resolver);
350
351        let mut watcher = crate::watcher::ConversationWatcher::new(
352            manager,
353            "/test/project".to_string(),
354            "session-1".to_string(),
355        );
356
357        // Use the trait explicitly (inherent poll returns ConversationEntry)
358        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
359        assert_eq!(events.len(), 2);
360        assert!(matches!(&events[0], WatcherEvent::Turn(t) if t.role == Role::User));
361        assert!(matches!(&events[1], WatcherEvent::Turn(t) if t.role == Role::Assistant));
362        assert_eq!(toolpath_convo::ConversationWatcher::seen_count(&watcher), 2);
363
364        // Second poll returns nothing
365        let events = toolpath_convo::ConversationWatcher::poll(&mut watcher).unwrap();
366        assert!(events.is_empty());
367    }
368}