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", alias = "stop_reason")]
76    pub stop_reason: Option<String>,
77
78    #[serde(skip_serializing_if = "Option::is_none", alias = "stop_sequence")]
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
145/// A reference to a tool use entry within a content part.
146#[derive(Debug)]
147pub struct ToolUseRef<'a> {
148    pub id: &'a str,
149    pub name: &'a str,
150    pub input: &'a Value,
151}
152
153impl Message {
154    /// Collapsed text content, joining all text parts with newlines.
155    ///
156    /// Returns an empty string if content is `None` or contains no text parts.
157    pub fn text(&self) -> String {
158        match &self.content {
159            Some(MessageContent::Text(t)) => t.clone(),
160            Some(MessageContent::Parts(parts)) => parts
161                .iter()
162                .filter_map(|p| match p {
163                    ContentPart::Text { text } => Some(text.as_str()),
164                    _ => None,
165                })
166                .collect::<Vec<_>>()
167                .join("\n"),
168            None => String::new(),
169        }
170    }
171
172    /// Thinking blocks, if any.
173    ///
174    /// Returns `None` when the message has no thinking content (not an empty vec).
175    pub fn thinking(&self) -> Option<Vec<&str>> {
176        let parts = match &self.content {
177            Some(MessageContent::Parts(parts)) => parts,
178            _ => return None,
179        };
180        let thinking: Vec<&str> = parts
181            .iter()
182            .filter_map(|p| match p {
183                ContentPart::Thinking { thinking, .. } => Some(thinking.as_str()),
184                _ => None,
185            })
186            .collect();
187        if thinking.is_empty() {
188            None
189        } else {
190            Some(thinking)
191        }
192    }
193
194    /// Tool use entries, if any.
195    pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
196        let parts = match &self.content {
197            Some(MessageContent::Parts(parts)) => parts,
198            _ => return Vec::new(),
199        };
200        parts
201            .iter()
202            .filter_map(|p| match p {
203                ContentPart::ToolUse { id, name, input } => Some(ToolUseRef { id, name, input }),
204                _ => None,
205            })
206            .collect()
207    }
208
209    /// Whether this message has the given role.
210    pub fn is_role(&self, role: MessageRole) -> bool {
211        self.role == role
212    }
213
214    /// Whether this is a user message.
215    pub fn is_user(&self) -> bool {
216        self.role == MessageRole::User
217    }
218
219    /// Whether this is an assistant message.
220    pub fn is_assistant(&self) -> bool {
221        self.role == MessageRole::Assistant
222    }
223}
224
225impl ConversationEntry {
226    /// Role of the message, if present.
227    pub fn role(&self) -> Option<&MessageRole> {
228        self.message.as_ref().map(|m| &m.role)
229    }
230
231    /// Collapsed text content of the message.
232    ///
233    /// Delegates to [`Message::text`]. Returns an empty string if no message is present.
234    pub fn text(&self) -> String {
235        self.message.as_ref().map(|m| m.text()).unwrap_or_default()
236    }
237
238    /// Thinking blocks from the message, if any.
239    pub fn thinking(&self) -> Option<Vec<&str>> {
240        self.message.as_ref().and_then(|m| m.thinking())
241    }
242
243    /// Tool use entries from the message, if any.
244    pub fn tool_uses(&self) -> Vec<ToolUseRef<'_>> {
245        self.message
246            .as_ref()
247            .map(|m| m.tool_uses())
248            .unwrap_or_default()
249    }
250
251    /// Stop reason, if present.
252    pub fn stop_reason(&self) -> Option<&str> {
253        self.message.as_ref().and_then(|m| m.stop_reason.as_deref())
254    }
255
256    /// Model name, if present.
257    pub fn model(&self) -> Option<&str> {
258        self.message.as_ref().and_then(|m| m.model.as_deref())
259    }
260}
261
262impl ContentPart {
263    /// Returns a short summary of this content part.
264    pub fn summary(&self) -> String {
265        match self {
266            ContentPart::Text { text } => {
267                if text.chars().count() > 100 {
268                    let truncated: String = text.chars().take(97).collect();
269                    format!("{}...", truncated)
270                } else {
271                    text.clone()
272                }
273            }
274            ContentPart::Thinking { .. } => "[thinking]".to_string(),
275            ContentPart::ToolUse { name, .. } => format!("[tool_use: {}]", name),
276            ContentPart::ToolResult {
277                is_error, content, ..
278            } => {
279                let text = content.text();
280                let prefix = if *is_error { "error" } else { "result" };
281                if text.chars().count() > 80 {
282                    let truncated: String = text.chars().take(77).collect();
283                    format!("[{}: {}...]", prefix, truncated)
284                } else {
285                    format!("[{}: {}]", prefix, text)
286                }
287            }
288            ContentPart::Unknown => "[unknown]".to_string(),
289        }
290    }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)]
294#[serde(rename_all = "lowercase")]
295pub enum MessageRole {
296    User,
297    Assistant,
298    System,
299}
300
301impl std::str::FromStr for MessageRole {
302    type Err = String;
303
304    fn from_str(s: &str) -> Result<Self, Self::Err> {
305        match s.to_lowercase().as_str() {
306            "user" => Ok(MessageRole::User),
307            "assistant" => Ok(MessageRole::Assistant),
308            "system" => Ok(MessageRole::System),
309            _ => Err(format!("Invalid message role: {}", s)),
310        }
311    }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316pub struct Usage {
317    #[serde(alias = "input_tokens")]
318    pub input_tokens: Option<u32>,
319    #[serde(alias = "output_tokens")]
320    pub output_tokens: Option<u32>,
321    #[serde(alias = "cache_creation_input_tokens")]
322    pub cache_creation_input_tokens: Option<u32>,
323    #[serde(alias = "cache_read_input_tokens")]
324    pub cache_read_input_tokens: Option<u32>,
325    #[serde(alias = "cache_creation")]
326    pub cache_creation: Option<CacheCreation>,
327    #[serde(alias = "service_tier")]
328    pub service_tier: Option<String>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct CacheCreation {
334    #[serde(alias = "ephemeral_5m_input_tokens")]
335    pub ephemeral_5m_input_tokens: Option<u32>,
336    #[serde(alias = "ephemeral_1h_input_tokens")]
337    pub ephemeral_1h_input_tokens: Option<u32>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct HistoryEntry {
342    pub display: String,
343
344    #[serde(rename = "pastedContents", default)]
345    pub pasted_contents: HashMap<String, Value>,
346
347    pub timestamp: i64,
348
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub project: Option<String>,
351
352    #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
353    pub session_id: Option<String>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct Conversation {
358    pub session_id: String,
359    pub project_path: Option<String>,
360    pub entries: Vec<ConversationEntry>,
361    pub started_at: Option<DateTime<Utc>>,
362    pub last_activity: Option<DateTime<Utc>>,
363}
364
365impl Conversation {
366    pub fn new(session_id: String) -> Self {
367        Self {
368            session_id,
369            project_path: None,
370            entries: Vec::new(),
371            started_at: None,
372            last_activity: None,
373        }
374    }
375
376    pub fn add_entry(&mut self, entry: ConversationEntry) {
377        if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
378            if self.started_at.is_none() || Some(timestamp) < self.started_at {
379                self.started_at = Some(timestamp);
380            }
381            if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
382                self.last_activity = Some(timestamp);
383            }
384        }
385
386        if self.project_path.is_none() {
387            self.project_path = entry.cwd.clone();
388        }
389
390        self.entries.push(entry);
391    }
392
393    pub fn user_messages(&self) -> Vec<&ConversationEntry> {
394        self.entries
395            .iter()
396            .filter(|e| {
397                e.entry_type == "user"
398                    && e.message
399                        .as_ref()
400                        .map(|m| m.role == MessageRole::User)
401                        .unwrap_or(false)
402            })
403            .collect()
404    }
405
406    pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
407        self.entries
408            .iter()
409            .filter(|e| {
410                e.entry_type == "assistant"
411                    && e.message
412                        .as_ref()
413                        .map(|m| m.role == MessageRole::Assistant)
414                        .unwrap_or(false)
415            })
416            .collect()
417    }
418
419    pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
420        let mut results = Vec::new();
421
422        for entry in &self.entries {
423            if let Some(message) = &entry.message
424                && let Some(MessageContent::Parts(parts)) = &message.content
425            {
426                for part in parts {
427                    if matches!(part, ContentPart::ToolUse { .. }) {
428                        results.push((entry, part));
429                    }
430                }
431            }
432        }
433
434        results
435    }
436
437    pub fn message_count(&self) -> usize {
438        self.entries.iter().filter(|e| e.message.is_some()).count()
439    }
440
441    pub fn duration(&self) -> Option<chrono::Duration> {
442        match (self.started_at, self.last_activity) {
443            (Some(start), Some(end)) => Some(end - start),
444            _ => None,
445        }
446    }
447
448    /// Returns entries after the given UUID.
449    /// If the UUID is not found, returns all entries (for full sync).
450    /// If the UUID is the last entry, returns an empty vec.
451    pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
452        match self.entries.iter().position(|e| e.uuid == since_uuid) {
453            Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
454            None => self.entries.clone(),
455        }
456    }
457
458    /// Returns the UUID of the last entry, if any.
459    pub fn last_uuid(&self) -> Option<&str> {
460        self.entries.last().map(|e| e.uuid.as_str())
461    }
462
463    /// Text of the first user message, truncated to `max_len` characters.
464    pub fn title(&self, max_len: usize) -> Option<String> {
465        self.first_user_text().map(|text| {
466            if text.chars().count() > max_len {
467                let truncated: String = text.chars().take(max_len).collect();
468                format!("{}...", truncated)
469            } else {
470                text
471            }
472        })
473    }
474
475    /// Full text of the first user message, untruncated.
476    pub fn first_user_text(&self) -> Option<String> {
477        self.entries.iter().find_map(|e| {
478            e.message.as_ref().and_then(|msg| {
479                if msg.is_user() {
480                    let text = msg.text();
481                    if text.is_empty() { None } else { Some(text) }
482                } else {
483                    None
484                }
485            })
486        })
487    }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct ConversationMetadata {
492    pub session_id: String,
493    pub project_path: String,
494    pub file_path: std::path::PathBuf,
495    pub message_count: usize,
496    pub started_at: Option<DateTime<Utc>>,
497    pub last_activity: Option<DateTime<Utc>>,
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn create_test_conversation() -> Conversation {
505        let mut convo = Conversation::new("test-session".to_string());
506
507        let entries = vec![
508            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
509            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
510            r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
511            r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
512        ];
513
514        for entry_json in entries {
515            let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
516            convo.add_entry(entry);
517        }
518
519        convo
520    }
521
522    #[test]
523    fn test_entries_since_middle() {
524        let convo = create_test_conversation();
525
526        // Get entries since uuid-2 (should return uuid-3, uuid-4)
527        let since = convo.entries_since("uuid-2");
528
529        assert_eq!(since.len(), 2);
530        assert_eq!(since[0].uuid, "uuid-3");
531        assert_eq!(since[1].uuid, "uuid-4");
532    }
533
534    #[test]
535    fn test_entries_since_first() {
536        let convo = create_test_conversation();
537
538        // Get entries since uuid-1 (should return uuid-2, uuid-3, uuid-4)
539        let since = convo.entries_since("uuid-1");
540
541        assert_eq!(since.len(), 3);
542        assert_eq!(since[0].uuid, "uuid-2");
543    }
544
545    #[test]
546    fn test_entries_since_last() {
547        let convo = create_test_conversation();
548
549        // Get entries since last UUID (should return empty)
550        let since = convo.entries_since("uuid-4");
551
552        assert!(since.is_empty());
553    }
554
555    #[test]
556    fn test_entries_since_unknown() {
557        let convo = create_test_conversation();
558
559        // Get entries since unknown UUID (should return all entries)
560        let since = convo.entries_since("unknown-uuid");
561
562        assert_eq!(since.len(), 4);
563    }
564
565    #[test]
566    fn test_last_uuid() {
567        let convo = create_test_conversation();
568
569        assert_eq!(convo.last_uuid(), Some("uuid-4"));
570    }
571
572    #[test]
573    fn test_last_uuid_empty() {
574        let convo = Conversation::new("empty-session".to_string());
575
576        assert_eq!(convo.last_uuid(), None);
577    }
578
579    // ── Conversation methods ───────────────────────────────────────────
580
581    #[test]
582    fn test_user_messages() {
583        let convo = create_test_conversation();
584        let users = convo.user_messages();
585        assert_eq!(users.len(), 2);
586        assert!(users.iter().all(|e| e.entry_type == "user"));
587    }
588
589    #[test]
590    fn test_assistant_messages() {
591        let convo = create_test_conversation();
592        let assistants = convo.assistant_messages();
593        assert_eq!(assistants.len(), 2);
594        assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
595    }
596
597    #[test]
598    fn test_message_count() {
599        let convo = create_test_conversation();
600        assert_eq!(convo.message_count(), 4);
601    }
602
603    #[test]
604    fn test_duration() {
605        let convo = create_test_conversation();
606        let dur = convo.duration().unwrap();
607        assert_eq!(dur.num_seconds(), 3); // 00:00:00 to 00:00:03
608    }
609
610    #[test]
611    fn test_duration_empty_conversation() {
612        let convo = Conversation::new("empty".to_string());
613        assert!(convo.duration().is_none());
614    }
615
616    #[test]
617    fn test_add_entry_tracks_timestamps() {
618        let mut convo = Conversation::new("test".to_string());
619        let entry: ConversationEntry = serde_json::from_str(
620            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
621        ).unwrap();
622        convo.add_entry(entry);
623
624        assert!(convo.started_at.is_some());
625        assert!(convo.last_activity.is_some());
626        assert_eq!(convo.started_at, convo.last_activity);
627    }
628
629    #[test]
630    fn test_add_entry_sets_project_path() {
631        let mut convo = Conversation::new("test".to_string());
632        let entry: ConversationEntry = serde_json::from_str(
633            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
634        ).unwrap();
635        convo.add_entry(entry);
636        assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
637    }
638
639    #[test]
640    fn test_tool_uses() {
641        let mut convo = Conversation::new("test".to_string());
642        let entry: ConversationEntry = serde_json::from_str(
643            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"}}]}}"#
644        ).unwrap();
645        convo.add_entry(entry);
646
647        let uses = convo.tool_uses();
648        assert_eq!(uses.len(), 1);
649        match uses[0].1 {
650            ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
651            _ => panic!("Expected ToolUse"),
652        }
653    }
654
655    #[test]
656    fn test_tool_uses_empty() {
657        let convo = create_test_conversation();
658        // The test conversation uses MessageContent::Text, no tool uses
659        let uses = convo.tool_uses();
660        assert!(uses.is_empty());
661    }
662
663    // ── ContentPart::summary ───────────────────────────────────────────
664
665    #[test]
666    fn test_content_part_summary_text_short() {
667        let part = ContentPart::Text {
668            text: "Hello world".to_string(),
669        };
670        assert_eq!(part.summary(), "Hello world");
671    }
672
673    #[test]
674    fn test_content_part_summary_text_long() {
675        let long = "A".repeat(200);
676        let part = ContentPart::Text { text: long };
677        let summary = part.summary();
678        assert!(summary.ends_with("..."));
679        assert!(summary.chars().count() <= 100);
680    }
681
682    #[test]
683    fn test_content_part_summary_thinking() {
684        let part = ContentPart::Thinking {
685            thinking: "deep thought".to_string(),
686            signature: None,
687        };
688        assert_eq!(part.summary(), "[thinking]");
689    }
690
691    #[test]
692    fn test_content_part_summary_tool_use() {
693        let part = ContentPart::ToolUse {
694            id: "t1".to_string(),
695            name: "Write".to_string(),
696            input: serde_json::json!({}),
697        };
698        assert_eq!(part.summary(), "[tool_use: Write]");
699    }
700
701    #[test]
702    fn test_content_part_summary_tool_result_short() {
703        let part = ContentPart::ToolResult {
704            tool_use_id: "t1".to_string(),
705            content: ToolResultContent::Text("OK".to_string()),
706            is_error: false,
707        };
708        assert_eq!(part.summary(), "[result: OK]");
709    }
710
711    #[test]
712    fn test_content_part_summary_tool_result_error() {
713        let part = ContentPart::ToolResult {
714            tool_use_id: "t1".to_string(),
715            content: ToolResultContent::Text("fail".to_string()),
716            is_error: true,
717        };
718        assert_eq!(part.summary(), "[error: fail]");
719    }
720
721    #[test]
722    fn test_content_part_summary_tool_result_long() {
723        let long = "X".repeat(200);
724        let part = ContentPart::ToolResult {
725            tool_use_id: "t1".to_string(),
726            content: ToolResultContent::Text(long),
727            is_error: false,
728        };
729        let summary = part.summary();
730        assert!(summary.starts_with("[result:"));
731        assert!(summary.ends_with("...]"));
732    }
733
734    #[test]
735    fn test_content_part_summary_unknown() {
736        let part = ContentPart::Unknown;
737        assert_eq!(part.summary(), "[unknown]");
738    }
739
740    // ── ToolResultContent::text ────────────────────────────────────────
741
742    #[test]
743    fn test_tool_result_content_text_string() {
744        let c = ToolResultContent::Text("hello".to_string());
745        assert_eq!(c.text(), "hello");
746    }
747
748    #[test]
749    fn test_tool_result_content_text_parts() {
750        let c = ToolResultContent::Parts(vec![
751            ToolResultPart {
752                text: Some("line1".to_string()),
753            },
754            ToolResultPart { text: None },
755            ToolResultPart {
756                text: Some("line2".to_string()),
757            },
758        ]);
759        assert_eq!(c.text(), "line1\nline2");
760    }
761
762    // ── MessageRole::from_str ──────────────────────────────────────────
763
764    #[test]
765    fn test_message_role_from_str() {
766        assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
767        assert_eq!(
768            "assistant".parse::<MessageRole>().unwrap(),
769            MessageRole::Assistant
770        );
771        assert_eq!(
772            "system".parse::<MessageRole>().unwrap(),
773            MessageRole::System
774        );
775    }
776
777    #[test]
778    fn test_message_role_from_str_case_insensitive() {
779        assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
780        assert_eq!(
781            "Assistant".parse::<MessageRole>().unwrap(),
782            MessageRole::Assistant
783        );
784    }
785
786    #[test]
787    fn test_message_role_from_str_invalid() {
788        assert!("invalid".parse::<MessageRole>().is_err());
789    }
790
791    // ── Message convenience methods ──────────────────────────────────
792
793    #[test]
794    fn test_message_text_from_string() {
795        let msg = Message {
796            role: MessageRole::User,
797            content: Some(MessageContent::Text("Hello world".to_string())),
798            model: None,
799            id: None,
800            message_type: None,
801            stop_reason: None,
802            stop_sequence: None,
803            usage: None,
804        };
805        assert_eq!(msg.text(), "Hello world");
806    }
807
808    #[test]
809    fn test_message_text_from_parts() {
810        let msg = Message {
811            role: MessageRole::Assistant,
812            content: Some(MessageContent::Parts(vec![
813                ContentPart::Text {
814                    text: "First".to_string(),
815                },
816                ContentPart::Thinking {
817                    thinking: "hmm".to_string(),
818                    signature: None,
819                },
820                ContentPart::Text {
821                    text: "Second".to_string(),
822                },
823            ])),
824            model: None,
825            id: None,
826            message_type: None,
827            stop_reason: None,
828            stop_sequence: None,
829            usage: None,
830        };
831        assert_eq!(msg.text(), "First\nSecond");
832    }
833
834    #[test]
835    fn test_message_text_none() {
836        let msg = Message {
837            role: MessageRole::User,
838            content: None,
839            model: None,
840            id: None,
841            message_type: None,
842            stop_reason: None,
843            stop_sequence: None,
844            usage: None,
845        };
846        assert_eq!(msg.text(), "");
847    }
848
849    #[test]
850    fn test_message_thinking() {
851        let msg = Message {
852            role: MessageRole::Assistant,
853            content: Some(MessageContent::Parts(vec![
854                ContentPart::Thinking {
855                    thinking: "deep thought".to_string(),
856                    signature: None,
857                },
858                ContentPart::Text {
859                    text: "answer".to_string(),
860                },
861                ContentPart::Thinking {
862                    thinking: "more thought".to_string(),
863                    signature: None,
864                },
865            ])),
866            model: None,
867            id: None,
868            message_type: None,
869            stop_reason: None,
870            stop_sequence: None,
871            usage: None,
872        };
873        let thinking = msg.thinking().unwrap();
874        assert_eq!(thinking, vec!["deep thought", "more thought"]);
875    }
876
877    #[test]
878    fn test_message_thinking_none() {
879        let msg = Message {
880            role: MessageRole::User,
881            content: Some(MessageContent::Text("hi".to_string())),
882            model: None,
883            id: None,
884            message_type: None,
885            stop_reason: None,
886            stop_sequence: None,
887            usage: None,
888        };
889        assert!(msg.thinking().is_none());
890    }
891
892    #[test]
893    fn test_message_tool_uses() {
894        let msg = Message {
895            role: MessageRole::Assistant,
896            content: Some(MessageContent::Parts(vec![
897                ContentPart::ToolUse {
898                    id: "t1".to_string(),
899                    name: "Read".to_string(),
900                    input: serde_json::json!({"file": "test.rs"}),
901                },
902                ContentPart::Text {
903                    text: "checking".to_string(),
904                },
905                ContentPart::ToolUse {
906                    id: "t2".to_string(),
907                    name: "Write".to_string(),
908                    input: serde_json::json!({}),
909                },
910            ])),
911            model: None,
912            id: None,
913            message_type: None,
914            stop_reason: None,
915            stop_sequence: None,
916            usage: None,
917        };
918        let uses = msg.tool_uses();
919        assert_eq!(uses.len(), 2);
920        assert_eq!(uses[0].name, "Read");
921        assert_eq!(uses[1].name, "Write");
922    }
923
924    #[test]
925    fn test_message_role_checks() {
926        let user_msg = Message {
927            role: MessageRole::User,
928            content: None,
929            model: None,
930            id: None,
931            message_type: None,
932            stop_reason: None,
933            stop_sequence: None,
934            usage: None,
935        };
936        assert!(user_msg.is_user());
937        assert!(!user_msg.is_assistant());
938        assert!(user_msg.is_role(MessageRole::User));
939    }
940
941    // ── ConversationEntry convenience methods ────────────────────────
942
943    #[test]
944    fn test_entry_text() {
945        let entry: ConversationEntry = serde_json::from_str(
946            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
947        )
948        .unwrap();
949        assert_eq!(entry.text(), "Hello there");
950    }
951
952    #[test]
953    fn test_entry_text_no_message() {
954        let entry: ConversationEntry = serde_json::from_str(
955            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
956        )
957        .unwrap();
958        assert_eq!(entry.text(), "");
959    }
960
961    #[test]
962    fn test_entry_role() {
963        let entry: ConversationEntry = serde_json::from_str(
964            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
965        )
966        .unwrap();
967        assert_eq!(entry.role(), Some(&MessageRole::User));
968    }
969
970    #[test]
971    fn test_entry_stop_reason() {
972        let entry: ConversationEntry = serde_json::from_str(
973            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
974        )
975        .unwrap();
976        assert_eq!(entry.stop_reason(), Some("end_turn"));
977    }
978
979    // ── Snake_case deserialization (real JSONL format) ─────────────
980
981    #[test]
982    fn test_stop_reason_snake_case() {
983        let entry: ConversationEntry = serde_json::from_str(
984            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stop_reason":"end_turn","stop_sequence":null}}"#,
985        )
986        .unwrap();
987        assert_eq!(entry.stop_reason(), Some("end_turn"));
988        assert!(entry.message.as_ref().unwrap().stop_sequence.is_none());
989    }
990
991    #[test]
992    fn test_usage_snake_case() {
993        let entry: ConversationEntry = serde_json::from_str(
994            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","usage":{"input_tokens":1200,"output_tokens":350,"cache_creation_input_tokens":100,"cache_read_input_tokens":500,"service_tier":"standard"}}}"#,
995        )
996        .unwrap();
997        let usage = entry.message.unwrap().usage.unwrap();
998        assert_eq!(usage.input_tokens, Some(1200));
999        assert_eq!(usage.output_tokens, Some(350));
1000        assert_eq!(usage.cache_creation_input_tokens, Some(100));
1001        assert_eq!(usage.cache_read_input_tokens, Some(500));
1002        assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1003    }
1004
1005    #[test]
1006    fn test_cache_creation_snake_case() {
1007        let json = r#"{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}"#;
1008        let cc: CacheCreation = serde_json::from_str(json).unwrap();
1009        assert_eq!(cc.ephemeral_5m_input_tokens, Some(10));
1010        assert_eq!(cc.ephemeral_1h_input_tokens, Some(20));
1011    }
1012
1013    #[test]
1014    fn test_full_assistant_entry_snake_case() {
1015        // Matches the actual JSONL format written by Claude Code
1016        let json = r#"{"parentUuid":"abc","isSidechain":false,"userType":"external","cwd":"/project","sessionId":"sess-1","version":"2.1.37","message":{"model":"claude-opus-4-6","id":"msg_123","type":"message","role":"assistant","content":[{"type":"text","text":"Done."}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":4561,"cache_read_input_tokens":17868,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":4561},"output_tokens":4,"service_tier":"standard"}},"requestId":"req_123","type":"assistant","uuid":"u1","timestamp":"2024-01-01T00:00:00Z"}"#;
1017        let entry: ConversationEntry = serde_json::from_str(json).unwrap();
1018        let msg = entry.message.unwrap();
1019        assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1020        assert!(msg.stop_sequence.is_none());
1021        let usage = msg.usage.unwrap();
1022        assert_eq!(usage.input_tokens, Some(3));
1023        assert_eq!(usage.output_tokens, Some(4));
1024        assert_eq!(usage.cache_creation_input_tokens, Some(4561));
1025        assert_eq!(usage.cache_read_input_tokens, Some(17868));
1026        assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1027        let cc = usage.cache_creation.unwrap();
1028        assert_eq!(cc.ephemeral_5m_input_tokens, Some(0));
1029        assert_eq!(cc.ephemeral_1h_input_tokens, Some(4561));
1030    }
1031
1032    #[test]
1033    fn test_entry_model() {
1034        let entry: ConversationEntry = serde_json::from_str(
1035            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
1036        )
1037        .unwrap();
1038        assert_eq!(entry.model(), Some("claude-opus-4-6"));
1039    }
1040
1041    // ── Conversation title/first_user_text ───────────────────────────
1042
1043    #[test]
1044    fn test_conversation_title() {
1045        let convo = create_test_conversation();
1046        let title = convo.title(4).unwrap();
1047        assert_eq!(title, "Hell...");
1048    }
1049
1050    #[test]
1051    fn test_conversation_title_short() {
1052        let convo = create_test_conversation();
1053        let title = convo.title(100).unwrap();
1054        assert_eq!(title, "Hello");
1055    }
1056
1057    #[test]
1058    fn test_conversation_first_user_text() {
1059        let convo = create_test_conversation();
1060        assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1061    }
1062
1063    #[test]
1064    fn test_conversation_title_empty() {
1065        let convo = Conversation::new("empty".to_string());
1066        assert!(convo.title(50).is_none());
1067    }
1068}