Skip to main content

toolpath_claude/
types.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct ConversationEntry {
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub parent_uuid: Option<String>,
11
12    #[serde(default)]
13    pub is_sidechain: bool,
14
15    #[serde(rename = "type")]
16    pub entry_type: String,
17
18    #[serde(default)]
19    pub uuid: String,
20
21    #[serde(default)]
22    pub timestamp: String,
23
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub session_id: Option<String>,
26
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub cwd: Option<String>,
29
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub git_branch: Option<String>,
32
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub version: Option<String>,
35
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub message: Option<Message>,
38
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub user_type: Option<String>,
41
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub request_id: Option<String>,
44
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub tool_use_result: Option<Value>,
47
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub snapshot: Option<Value>,
50
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub message_id: Option<String>,
53
54    #[serde(flatten)]
55    pub extra: HashMap<String, Value>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Message {
61    pub role: MessageRole,
62
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub content: Option<MessageContent>,
65
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub model: Option<String>,
68
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub id: Option<String>,
71
72    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
73    pub message_type: Option<String>,
74
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub stop_reason: Option<String>,
77
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub stop_sequence: Option<String>,
80
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub usage: Option<Usage>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(untagged)]
87pub enum MessageContent {
88    Text(String),
89    Parts(Vec<ContentPart>),
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum ContentPart {
95    Text {
96        text: String,
97    },
98    Thinking {
99        thinking: String,
100        #[serde(default)]
101        signature: Option<String>,
102    },
103    ToolUse {
104        id: String,
105        name: String,
106        input: Value,
107    },
108    ToolResult {
109        tool_use_id: String,
110        content: ToolResultContent,
111        #[serde(default)]
112        is_error: bool,
113    },
114    /// Catch-all for unknown content types
115    #[serde(other)]
116    Unknown,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[serde(untagged)]
121pub enum ToolResultContent {
122    Text(String),
123    Parts(Vec<ToolResultPart>),
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ToolResultPart {
128    #[serde(default)]
129    pub text: Option<String>,
130}
131
132impl ToolResultContent {
133    pub fn text(&self) -> String {
134        match self {
135            ToolResultContent::Text(s) => s.clone(),
136            ToolResultContent::Parts(parts) => parts
137                .iter()
138                .filter_map(|p| p.text.as_deref())
139                .collect::<Vec<_>>()
140                .join("\n"),
141        }
142    }
143}
144
145impl ContentPart {
146    /// Returns a short summary of this content part.
147    pub fn summary(&self) -> String {
148        match self {
149            ContentPart::Text { text } => {
150                if text.chars().count() > 100 {
151                    let truncated: String = text.chars().take(97).collect();
152                    format!("{}...", truncated)
153                } else {
154                    text.clone()
155                }
156            }
157            ContentPart::Thinking { .. } => "[thinking]".to_string(),
158            ContentPart::ToolUse { name, .. } => format!("[tool_use: {}]", name),
159            ContentPart::ToolResult {
160                is_error, content, ..
161            } => {
162                let text = content.text();
163                let prefix = if *is_error { "error" } else { "result" };
164                if text.chars().count() > 80 {
165                    let truncated: String = text.chars().take(77).collect();
166                    format!("[{}: {}...]", prefix, truncated)
167                } else {
168                    format!("[{}: {}]", prefix, text)
169                }
170            }
171            ContentPart::Unknown => "[unknown]".to_string(),
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)]
177#[serde(rename_all = "lowercase")]
178pub enum MessageRole {
179    User,
180    Assistant,
181    System,
182}
183
184impl std::str::FromStr for MessageRole {
185    type Err = String;
186
187    fn from_str(s: &str) -> Result<Self, Self::Err> {
188        match s.to_lowercase().as_str() {
189            "user" => Ok(MessageRole::User),
190            "assistant" => Ok(MessageRole::Assistant),
191            "system" => Ok(MessageRole::System),
192            _ => Err(format!("Invalid message role: {}", s)),
193        }
194    }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct Usage {
200    pub input_tokens: Option<u32>,
201    pub output_tokens: Option<u32>,
202    pub cache_creation_input_tokens: Option<u32>,
203    pub cache_read_input_tokens: Option<u32>,
204    pub cache_creation: Option<CacheCreation>,
205    pub service_tier: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct CacheCreation {
211    pub ephemeral_5m_input_tokens: Option<u32>,
212    pub ephemeral_1h_input_tokens: Option<u32>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct HistoryEntry {
217    pub display: String,
218
219    #[serde(rename = "pastedContents", default)]
220    pub pasted_contents: HashMap<String, Value>,
221
222    pub timestamp: i64,
223
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub project: Option<String>,
226
227    #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
228    pub session_id: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct Conversation {
233    pub session_id: String,
234    pub project_path: Option<String>,
235    pub entries: Vec<ConversationEntry>,
236    pub started_at: Option<DateTime<Utc>>,
237    pub last_activity: Option<DateTime<Utc>>,
238}
239
240impl Conversation {
241    pub fn new(session_id: String) -> Self {
242        Self {
243            session_id,
244            project_path: None,
245            entries: Vec::new(),
246            started_at: None,
247            last_activity: None,
248        }
249    }
250
251    pub fn add_entry(&mut self, entry: ConversationEntry) {
252        if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
253            if self.started_at.is_none() || Some(timestamp) < self.started_at {
254                self.started_at = Some(timestamp);
255            }
256            if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
257                self.last_activity = Some(timestamp);
258            }
259        }
260
261        if self.project_path.is_none() {
262            self.project_path = entry.cwd.clone();
263        }
264
265        self.entries.push(entry);
266    }
267
268    pub fn user_messages(&self) -> Vec<&ConversationEntry> {
269        self.entries
270            .iter()
271            .filter(|e| {
272                e.entry_type == "user"
273                    && e.message
274                        .as_ref()
275                        .map(|m| m.role == MessageRole::User)
276                        .unwrap_or(false)
277            })
278            .collect()
279    }
280
281    pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
282        self.entries
283            .iter()
284            .filter(|e| {
285                e.entry_type == "assistant"
286                    && e.message
287                        .as_ref()
288                        .map(|m| m.role == MessageRole::Assistant)
289                        .unwrap_or(false)
290            })
291            .collect()
292    }
293
294    pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
295        let mut results = Vec::new();
296
297        for entry in &self.entries {
298            if let Some(message) = &entry.message
299                && let Some(MessageContent::Parts(parts)) = &message.content
300            {
301                for part in parts {
302                    if matches!(part, ContentPart::ToolUse { .. }) {
303                        results.push((entry, part));
304                    }
305                }
306            }
307        }
308
309        results
310    }
311
312    pub fn message_count(&self) -> usize {
313        self.entries.iter().filter(|e| e.message.is_some()).count()
314    }
315
316    pub fn duration(&self) -> Option<chrono::Duration> {
317        match (self.started_at, self.last_activity) {
318            (Some(start), Some(end)) => Some(end - start),
319            _ => None,
320        }
321    }
322
323    /// Returns entries after the given UUID.
324    /// If the UUID is not found, returns all entries (for full sync).
325    /// If the UUID is the last entry, returns an empty vec.
326    pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
327        match self.entries.iter().position(|e| e.uuid == since_uuid) {
328            Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
329            None => self.entries.clone(),
330        }
331    }
332
333    /// Returns the UUID of the last entry, if any.
334    pub fn last_uuid(&self) -> Option<&str> {
335        self.entries.last().map(|e| e.uuid.as_str())
336    }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct ConversationMetadata {
341    pub session_id: String,
342    pub project_path: String,
343    pub file_path: std::path::PathBuf,
344    pub message_count: usize,
345    pub started_at: Option<DateTime<Utc>>,
346    pub last_activity: Option<DateTime<Utc>>,
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    fn create_test_conversation() -> Conversation {
354        let mut convo = Conversation::new("test-session".to_string());
355
356        let entries = vec![
357            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
358            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
359            r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
360            r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
361        ];
362
363        for entry_json in entries {
364            let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
365            convo.add_entry(entry);
366        }
367
368        convo
369    }
370
371    #[test]
372    fn test_entries_since_middle() {
373        let convo = create_test_conversation();
374
375        // Get entries since uuid-2 (should return uuid-3, uuid-4)
376        let since = convo.entries_since("uuid-2");
377
378        assert_eq!(since.len(), 2);
379        assert_eq!(since[0].uuid, "uuid-3");
380        assert_eq!(since[1].uuid, "uuid-4");
381    }
382
383    #[test]
384    fn test_entries_since_first() {
385        let convo = create_test_conversation();
386
387        // Get entries since uuid-1 (should return uuid-2, uuid-3, uuid-4)
388        let since = convo.entries_since("uuid-1");
389
390        assert_eq!(since.len(), 3);
391        assert_eq!(since[0].uuid, "uuid-2");
392    }
393
394    #[test]
395    fn test_entries_since_last() {
396        let convo = create_test_conversation();
397
398        // Get entries since last UUID (should return empty)
399        let since = convo.entries_since("uuid-4");
400
401        assert!(since.is_empty());
402    }
403
404    #[test]
405    fn test_entries_since_unknown() {
406        let convo = create_test_conversation();
407
408        // Get entries since unknown UUID (should return all entries)
409        let since = convo.entries_since("unknown-uuid");
410
411        assert_eq!(since.len(), 4);
412    }
413
414    #[test]
415    fn test_last_uuid() {
416        let convo = create_test_conversation();
417
418        assert_eq!(convo.last_uuid(), Some("uuid-4"));
419    }
420
421    #[test]
422    fn test_last_uuid_empty() {
423        let convo = Conversation::new("empty-session".to_string());
424
425        assert_eq!(convo.last_uuid(), None);
426    }
427
428    // ── Conversation methods ───────────────────────────────────────────
429
430    #[test]
431    fn test_user_messages() {
432        let convo = create_test_conversation();
433        let users = convo.user_messages();
434        assert_eq!(users.len(), 2);
435        assert!(users.iter().all(|e| e.entry_type == "user"));
436    }
437
438    #[test]
439    fn test_assistant_messages() {
440        let convo = create_test_conversation();
441        let assistants = convo.assistant_messages();
442        assert_eq!(assistants.len(), 2);
443        assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
444    }
445
446    #[test]
447    fn test_message_count() {
448        let convo = create_test_conversation();
449        assert_eq!(convo.message_count(), 4);
450    }
451
452    #[test]
453    fn test_duration() {
454        let convo = create_test_conversation();
455        let dur = convo.duration().unwrap();
456        assert_eq!(dur.num_seconds(), 3); // 00:00:00 to 00:00:03
457    }
458
459    #[test]
460    fn test_duration_empty_conversation() {
461        let convo = Conversation::new("empty".to_string());
462        assert!(convo.duration().is_none());
463    }
464
465    #[test]
466    fn test_add_entry_tracks_timestamps() {
467        let mut convo = Conversation::new("test".to_string());
468        let entry: ConversationEntry = serde_json::from_str(
469            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
470        ).unwrap();
471        convo.add_entry(entry);
472
473        assert!(convo.started_at.is_some());
474        assert!(convo.last_activity.is_some());
475        assert_eq!(convo.started_at, convo.last_activity);
476    }
477
478    #[test]
479    fn test_add_entry_sets_project_path() {
480        let mut convo = Conversation::new("test".to_string());
481        let entry: ConversationEntry = serde_json::from_str(
482            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
483        ).unwrap();
484        convo.add_entry(entry);
485        assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
486    }
487
488    #[test]
489    fn test_tool_uses() {
490        let mut convo = Conversation::new("test".to_string());
491        let entry: ConversationEntry = serde_json::from_str(
492            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/test"}}]}}"#
493        ).unwrap();
494        convo.add_entry(entry);
495
496        let uses = convo.tool_uses();
497        assert_eq!(uses.len(), 1);
498        match uses[0].1 {
499            ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
500            _ => panic!("Expected ToolUse"),
501        }
502    }
503
504    #[test]
505    fn test_tool_uses_empty() {
506        let convo = create_test_conversation();
507        // The test conversation uses MessageContent::Text, no tool uses
508        let uses = convo.tool_uses();
509        assert!(uses.is_empty());
510    }
511
512    // ── ContentPart::summary ───────────────────────────────────────────
513
514    #[test]
515    fn test_content_part_summary_text_short() {
516        let part = ContentPart::Text {
517            text: "Hello world".to_string(),
518        };
519        assert_eq!(part.summary(), "Hello world");
520    }
521
522    #[test]
523    fn test_content_part_summary_text_long() {
524        let long = "A".repeat(200);
525        let part = ContentPart::Text { text: long };
526        let summary = part.summary();
527        assert!(summary.ends_with("..."));
528        assert!(summary.chars().count() <= 100);
529    }
530
531    #[test]
532    fn test_content_part_summary_thinking() {
533        let part = ContentPart::Thinking {
534            thinking: "deep thought".to_string(),
535            signature: None,
536        };
537        assert_eq!(part.summary(), "[thinking]");
538    }
539
540    #[test]
541    fn test_content_part_summary_tool_use() {
542        let part = ContentPart::ToolUse {
543            id: "t1".to_string(),
544            name: "Write".to_string(),
545            input: serde_json::json!({}),
546        };
547        assert_eq!(part.summary(), "[tool_use: Write]");
548    }
549
550    #[test]
551    fn test_content_part_summary_tool_result_short() {
552        let part = ContentPart::ToolResult {
553            tool_use_id: "t1".to_string(),
554            content: ToolResultContent::Text("OK".to_string()),
555            is_error: false,
556        };
557        assert_eq!(part.summary(), "[result: OK]");
558    }
559
560    #[test]
561    fn test_content_part_summary_tool_result_error() {
562        let part = ContentPart::ToolResult {
563            tool_use_id: "t1".to_string(),
564            content: ToolResultContent::Text("fail".to_string()),
565            is_error: true,
566        };
567        assert_eq!(part.summary(), "[error: fail]");
568    }
569
570    #[test]
571    fn test_content_part_summary_tool_result_long() {
572        let long = "X".repeat(200);
573        let part = ContentPart::ToolResult {
574            tool_use_id: "t1".to_string(),
575            content: ToolResultContent::Text(long),
576            is_error: false,
577        };
578        let summary = part.summary();
579        assert!(summary.starts_with("[result:"));
580        assert!(summary.ends_with("...]"));
581    }
582
583    #[test]
584    fn test_content_part_summary_unknown() {
585        let part = ContentPart::Unknown;
586        assert_eq!(part.summary(), "[unknown]");
587    }
588
589    // ── ToolResultContent::text ────────────────────────────────────────
590
591    #[test]
592    fn test_tool_result_content_text_string() {
593        let c = ToolResultContent::Text("hello".to_string());
594        assert_eq!(c.text(), "hello");
595    }
596
597    #[test]
598    fn test_tool_result_content_text_parts() {
599        let c = ToolResultContent::Parts(vec![
600            ToolResultPart {
601                text: Some("line1".to_string()),
602            },
603            ToolResultPart { text: None },
604            ToolResultPart {
605                text: Some("line2".to_string()),
606            },
607        ]);
608        assert_eq!(c.text(), "line1\nline2");
609    }
610
611    // ── MessageRole::from_str ──────────────────────────────────────────
612
613    #[test]
614    fn test_message_role_from_str() {
615        assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
616        assert_eq!(
617            "assistant".parse::<MessageRole>().unwrap(),
618            MessageRole::Assistant
619        );
620        assert_eq!(
621            "system".parse::<MessageRole>().unwrap(),
622            MessageRole::System
623        );
624    }
625
626    #[test]
627    fn test_message_role_from_str_case_insensitive() {
628        assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
629        assert_eq!(
630            "Assistant".parse::<MessageRole>().unwrap(),
631            MessageRole::Assistant
632        );
633    }
634
635    #[test]
636    fn test_message_role_from_str_invalid() {
637        assert!("invalid".parse::<MessageRole>().is_err());
638    }
639}