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
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    pub input_tokens: Option<u32>,
318    pub output_tokens: Option<u32>,
319    pub cache_creation_input_tokens: Option<u32>,
320    pub cache_read_input_tokens: Option<u32>,
321    pub cache_creation: Option<CacheCreation>,
322    pub service_tier: Option<String>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326#[serde(rename_all = "camelCase")]
327pub struct CacheCreation {
328    pub ephemeral_5m_input_tokens: Option<u32>,
329    pub ephemeral_1h_input_tokens: Option<u32>,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct HistoryEntry {
334    pub display: String,
335
336    #[serde(rename = "pastedContents", default)]
337    pub pasted_contents: HashMap<String, Value>,
338
339    pub timestamp: i64,
340
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub project: Option<String>,
343
344    #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
345    pub session_id: Option<String>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct Conversation {
350    pub session_id: String,
351    pub project_path: Option<String>,
352    pub entries: Vec<ConversationEntry>,
353    pub started_at: Option<DateTime<Utc>>,
354    pub last_activity: Option<DateTime<Utc>>,
355}
356
357impl Conversation {
358    pub fn new(session_id: String) -> Self {
359        Self {
360            session_id,
361            project_path: None,
362            entries: Vec::new(),
363            started_at: None,
364            last_activity: None,
365        }
366    }
367
368    pub fn add_entry(&mut self, entry: ConversationEntry) {
369        if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
370            if self.started_at.is_none() || Some(timestamp) < self.started_at {
371                self.started_at = Some(timestamp);
372            }
373            if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
374                self.last_activity = Some(timestamp);
375            }
376        }
377
378        if self.project_path.is_none() {
379            self.project_path = entry.cwd.clone();
380        }
381
382        self.entries.push(entry);
383    }
384
385    pub fn user_messages(&self) -> Vec<&ConversationEntry> {
386        self.entries
387            .iter()
388            .filter(|e| {
389                e.entry_type == "user"
390                    && e.message
391                        .as_ref()
392                        .map(|m| m.role == MessageRole::User)
393                        .unwrap_or(false)
394            })
395            .collect()
396    }
397
398    pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
399        self.entries
400            .iter()
401            .filter(|e| {
402                e.entry_type == "assistant"
403                    && e.message
404                        .as_ref()
405                        .map(|m| m.role == MessageRole::Assistant)
406                        .unwrap_or(false)
407            })
408            .collect()
409    }
410
411    pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
412        let mut results = Vec::new();
413
414        for entry in &self.entries {
415            if let Some(message) = &entry.message
416                && let Some(MessageContent::Parts(parts)) = &message.content
417            {
418                for part in parts {
419                    if matches!(part, ContentPart::ToolUse { .. }) {
420                        results.push((entry, part));
421                    }
422                }
423            }
424        }
425
426        results
427    }
428
429    pub fn message_count(&self) -> usize {
430        self.entries.iter().filter(|e| e.message.is_some()).count()
431    }
432
433    pub fn duration(&self) -> Option<chrono::Duration> {
434        match (self.started_at, self.last_activity) {
435            (Some(start), Some(end)) => Some(end - start),
436            _ => None,
437        }
438    }
439
440    /// Returns entries after the given UUID.
441    /// If the UUID is not found, returns all entries (for full sync).
442    /// If the UUID is the last entry, returns an empty vec.
443    pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
444        match self.entries.iter().position(|e| e.uuid == since_uuid) {
445            Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
446            None => self.entries.clone(),
447        }
448    }
449
450    /// Returns the UUID of the last entry, if any.
451    pub fn last_uuid(&self) -> Option<&str> {
452        self.entries.last().map(|e| e.uuid.as_str())
453    }
454
455    /// Text of the first user message, truncated to `max_len` characters.
456    pub fn title(&self, max_len: usize) -> Option<String> {
457        self.first_user_text().map(|text| {
458            if text.chars().count() > max_len {
459                let truncated: String = text.chars().take(max_len).collect();
460                format!("{}...", truncated)
461            } else {
462                text
463            }
464        })
465    }
466
467    /// Full text of the first user message, untruncated.
468    pub fn first_user_text(&self) -> Option<String> {
469        self.entries.iter().find_map(|e| {
470            e.message.as_ref().and_then(|msg| {
471                if msg.is_user() {
472                    let text = msg.text();
473                    if text.is_empty() { None } else { Some(text) }
474                } else {
475                    None
476                }
477            })
478        })
479    }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct ConversationMetadata {
484    pub session_id: String,
485    pub project_path: String,
486    pub file_path: std::path::PathBuf,
487    pub message_count: usize,
488    pub started_at: Option<DateTime<Utc>>,
489    pub last_activity: Option<DateTime<Utc>>,
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn create_test_conversation() -> Conversation {
497        let mut convo = Conversation::new("test-session".to_string());
498
499        let entries = vec![
500            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
501            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
502            r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
503            r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
504        ];
505
506        for entry_json in entries {
507            let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
508            convo.add_entry(entry);
509        }
510
511        convo
512    }
513
514    #[test]
515    fn test_entries_since_middle() {
516        let convo = create_test_conversation();
517
518        // Get entries since uuid-2 (should return uuid-3, uuid-4)
519        let since = convo.entries_since("uuid-2");
520
521        assert_eq!(since.len(), 2);
522        assert_eq!(since[0].uuid, "uuid-3");
523        assert_eq!(since[1].uuid, "uuid-4");
524    }
525
526    #[test]
527    fn test_entries_since_first() {
528        let convo = create_test_conversation();
529
530        // Get entries since uuid-1 (should return uuid-2, uuid-3, uuid-4)
531        let since = convo.entries_since("uuid-1");
532
533        assert_eq!(since.len(), 3);
534        assert_eq!(since[0].uuid, "uuid-2");
535    }
536
537    #[test]
538    fn test_entries_since_last() {
539        let convo = create_test_conversation();
540
541        // Get entries since last UUID (should return empty)
542        let since = convo.entries_since("uuid-4");
543
544        assert!(since.is_empty());
545    }
546
547    #[test]
548    fn test_entries_since_unknown() {
549        let convo = create_test_conversation();
550
551        // Get entries since unknown UUID (should return all entries)
552        let since = convo.entries_since("unknown-uuid");
553
554        assert_eq!(since.len(), 4);
555    }
556
557    #[test]
558    fn test_last_uuid() {
559        let convo = create_test_conversation();
560
561        assert_eq!(convo.last_uuid(), Some("uuid-4"));
562    }
563
564    #[test]
565    fn test_last_uuid_empty() {
566        let convo = Conversation::new("empty-session".to_string());
567
568        assert_eq!(convo.last_uuid(), None);
569    }
570
571    // ── Conversation methods ───────────────────────────────────────────
572
573    #[test]
574    fn test_user_messages() {
575        let convo = create_test_conversation();
576        let users = convo.user_messages();
577        assert_eq!(users.len(), 2);
578        assert!(users.iter().all(|e| e.entry_type == "user"));
579    }
580
581    #[test]
582    fn test_assistant_messages() {
583        let convo = create_test_conversation();
584        let assistants = convo.assistant_messages();
585        assert_eq!(assistants.len(), 2);
586        assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
587    }
588
589    #[test]
590    fn test_message_count() {
591        let convo = create_test_conversation();
592        assert_eq!(convo.message_count(), 4);
593    }
594
595    #[test]
596    fn test_duration() {
597        let convo = create_test_conversation();
598        let dur = convo.duration().unwrap();
599        assert_eq!(dur.num_seconds(), 3); // 00:00:00 to 00:00:03
600    }
601
602    #[test]
603    fn test_duration_empty_conversation() {
604        let convo = Conversation::new("empty".to_string());
605        assert!(convo.duration().is_none());
606    }
607
608    #[test]
609    fn test_add_entry_tracks_timestamps() {
610        let mut convo = Conversation::new("test".to_string());
611        let entry: ConversationEntry = serde_json::from_str(
612            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
613        ).unwrap();
614        convo.add_entry(entry);
615
616        assert!(convo.started_at.is_some());
617        assert!(convo.last_activity.is_some());
618        assert_eq!(convo.started_at, convo.last_activity);
619    }
620
621    #[test]
622    fn test_add_entry_sets_project_path() {
623        let mut convo = Conversation::new("test".to_string());
624        let entry: ConversationEntry = serde_json::from_str(
625            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
626        ).unwrap();
627        convo.add_entry(entry);
628        assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
629    }
630
631    #[test]
632    fn test_tool_uses() {
633        let mut convo = Conversation::new("test".to_string());
634        let entry: ConversationEntry = serde_json::from_str(
635            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"}}]}}"#
636        ).unwrap();
637        convo.add_entry(entry);
638
639        let uses = convo.tool_uses();
640        assert_eq!(uses.len(), 1);
641        match uses[0].1 {
642            ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
643            _ => panic!("Expected ToolUse"),
644        }
645    }
646
647    #[test]
648    fn test_tool_uses_empty() {
649        let convo = create_test_conversation();
650        // The test conversation uses MessageContent::Text, no tool uses
651        let uses = convo.tool_uses();
652        assert!(uses.is_empty());
653    }
654
655    // ── ContentPart::summary ───────────────────────────────────────────
656
657    #[test]
658    fn test_content_part_summary_text_short() {
659        let part = ContentPart::Text {
660            text: "Hello world".to_string(),
661        };
662        assert_eq!(part.summary(), "Hello world");
663    }
664
665    #[test]
666    fn test_content_part_summary_text_long() {
667        let long = "A".repeat(200);
668        let part = ContentPart::Text { text: long };
669        let summary = part.summary();
670        assert!(summary.ends_with("..."));
671        assert!(summary.chars().count() <= 100);
672    }
673
674    #[test]
675    fn test_content_part_summary_thinking() {
676        let part = ContentPart::Thinking {
677            thinking: "deep thought".to_string(),
678            signature: None,
679        };
680        assert_eq!(part.summary(), "[thinking]");
681    }
682
683    #[test]
684    fn test_content_part_summary_tool_use() {
685        let part = ContentPart::ToolUse {
686            id: "t1".to_string(),
687            name: "Write".to_string(),
688            input: serde_json::json!({}),
689        };
690        assert_eq!(part.summary(), "[tool_use: Write]");
691    }
692
693    #[test]
694    fn test_content_part_summary_tool_result_short() {
695        let part = ContentPart::ToolResult {
696            tool_use_id: "t1".to_string(),
697            content: ToolResultContent::Text("OK".to_string()),
698            is_error: false,
699        };
700        assert_eq!(part.summary(), "[result: OK]");
701    }
702
703    #[test]
704    fn test_content_part_summary_tool_result_error() {
705        let part = ContentPart::ToolResult {
706            tool_use_id: "t1".to_string(),
707            content: ToolResultContent::Text("fail".to_string()),
708            is_error: true,
709        };
710        assert_eq!(part.summary(), "[error: fail]");
711    }
712
713    #[test]
714    fn test_content_part_summary_tool_result_long() {
715        let long = "X".repeat(200);
716        let part = ContentPart::ToolResult {
717            tool_use_id: "t1".to_string(),
718            content: ToolResultContent::Text(long),
719            is_error: false,
720        };
721        let summary = part.summary();
722        assert!(summary.starts_with("[result:"));
723        assert!(summary.ends_with("...]"));
724    }
725
726    #[test]
727    fn test_content_part_summary_unknown() {
728        let part = ContentPart::Unknown;
729        assert_eq!(part.summary(), "[unknown]");
730    }
731
732    // ── ToolResultContent::text ────────────────────────────────────────
733
734    #[test]
735    fn test_tool_result_content_text_string() {
736        let c = ToolResultContent::Text("hello".to_string());
737        assert_eq!(c.text(), "hello");
738    }
739
740    #[test]
741    fn test_tool_result_content_text_parts() {
742        let c = ToolResultContent::Parts(vec![
743            ToolResultPart {
744                text: Some("line1".to_string()),
745            },
746            ToolResultPart { text: None },
747            ToolResultPart {
748                text: Some("line2".to_string()),
749            },
750        ]);
751        assert_eq!(c.text(), "line1\nline2");
752    }
753
754    // ── MessageRole::from_str ──────────────────────────────────────────
755
756    #[test]
757    fn test_message_role_from_str() {
758        assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
759        assert_eq!(
760            "assistant".parse::<MessageRole>().unwrap(),
761            MessageRole::Assistant
762        );
763        assert_eq!(
764            "system".parse::<MessageRole>().unwrap(),
765            MessageRole::System
766        );
767    }
768
769    #[test]
770    fn test_message_role_from_str_case_insensitive() {
771        assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
772        assert_eq!(
773            "Assistant".parse::<MessageRole>().unwrap(),
774            MessageRole::Assistant
775        );
776    }
777
778    #[test]
779    fn test_message_role_from_str_invalid() {
780        assert!("invalid".parse::<MessageRole>().is_err());
781    }
782
783    // ── Message convenience methods ──────────────────────────────────
784
785    #[test]
786    fn test_message_text_from_string() {
787        let msg = Message {
788            role: MessageRole::User,
789            content: Some(MessageContent::Text("Hello world".to_string())),
790            model: None,
791            id: None,
792            message_type: None,
793            stop_reason: None,
794            stop_sequence: None,
795            usage: None,
796        };
797        assert_eq!(msg.text(), "Hello world");
798    }
799
800    #[test]
801    fn test_message_text_from_parts() {
802        let msg = Message {
803            role: MessageRole::Assistant,
804            content: Some(MessageContent::Parts(vec![
805                ContentPart::Text {
806                    text: "First".to_string(),
807                },
808                ContentPart::Thinking {
809                    thinking: "hmm".to_string(),
810                    signature: None,
811                },
812                ContentPart::Text {
813                    text: "Second".to_string(),
814                },
815            ])),
816            model: None,
817            id: None,
818            message_type: None,
819            stop_reason: None,
820            stop_sequence: None,
821            usage: None,
822        };
823        assert_eq!(msg.text(), "First\nSecond");
824    }
825
826    #[test]
827    fn test_message_text_none() {
828        let msg = Message {
829            role: MessageRole::User,
830            content: None,
831            model: None,
832            id: None,
833            message_type: None,
834            stop_reason: None,
835            stop_sequence: None,
836            usage: None,
837        };
838        assert_eq!(msg.text(), "");
839    }
840
841    #[test]
842    fn test_message_thinking() {
843        let msg = Message {
844            role: MessageRole::Assistant,
845            content: Some(MessageContent::Parts(vec![
846                ContentPart::Thinking {
847                    thinking: "deep thought".to_string(),
848                    signature: None,
849                },
850                ContentPart::Text {
851                    text: "answer".to_string(),
852                },
853                ContentPart::Thinking {
854                    thinking: "more thought".to_string(),
855                    signature: None,
856                },
857            ])),
858            model: None,
859            id: None,
860            message_type: None,
861            stop_reason: None,
862            stop_sequence: None,
863            usage: None,
864        };
865        let thinking = msg.thinking().unwrap();
866        assert_eq!(thinking, vec!["deep thought", "more thought"]);
867    }
868
869    #[test]
870    fn test_message_thinking_none() {
871        let msg = Message {
872            role: MessageRole::User,
873            content: Some(MessageContent::Text("hi".to_string())),
874            model: None,
875            id: None,
876            message_type: None,
877            stop_reason: None,
878            stop_sequence: None,
879            usage: None,
880        };
881        assert!(msg.thinking().is_none());
882    }
883
884    #[test]
885    fn test_message_tool_uses() {
886        let msg = Message {
887            role: MessageRole::Assistant,
888            content: Some(MessageContent::Parts(vec![
889                ContentPart::ToolUse {
890                    id: "t1".to_string(),
891                    name: "Read".to_string(),
892                    input: serde_json::json!({"file": "test.rs"}),
893                },
894                ContentPart::Text {
895                    text: "checking".to_string(),
896                },
897                ContentPart::ToolUse {
898                    id: "t2".to_string(),
899                    name: "Write".to_string(),
900                    input: serde_json::json!({}),
901                },
902            ])),
903            model: None,
904            id: None,
905            message_type: None,
906            stop_reason: None,
907            stop_sequence: None,
908            usage: None,
909        };
910        let uses = msg.tool_uses();
911        assert_eq!(uses.len(), 2);
912        assert_eq!(uses[0].name, "Read");
913        assert_eq!(uses[1].name, "Write");
914    }
915
916    #[test]
917    fn test_message_role_checks() {
918        let user_msg = Message {
919            role: MessageRole::User,
920            content: None,
921            model: None,
922            id: None,
923            message_type: None,
924            stop_reason: None,
925            stop_sequence: None,
926            usage: None,
927        };
928        assert!(user_msg.is_user());
929        assert!(!user_msg.is_assistant());
930        assert!(user_msg.is_role(MessageRole::User));
931    }
932
933    // ── ConversationEntry convenience methods ────────────────────────
934
935    #[test]
936    fn test_entry_text() {
937        let entry: ConversationEntry = serde_json::from_str(
938            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
939        )
940        .unwrap();
941        assert_eq!(entry.text(), "Hello there");
942    }
943
944    #[test]
945    fn test_entry_text_no_message() {
946        let entry: ConversationEntry = serde_json::from_str(
947            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
948        )
949        .unwrap();
950        assert_eq!(entry.text(), "");
951    }
952
953    #[test]
954    fn test_entry_role() {
955        let entry: ConversationEntry = serde_json::from_str(
956            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
957        )
958        .unwrap();
959        assert_eq!(entry.role(), Some(&MessageRole::User));
960    }
961
962    #[test]
963    fn test_entry_stop_reason() {
964        let entry: ConversationEntry = serde_json::from_str(
965            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
966        )
967        .unwrap();
968        assert_eq!(entry.stop_reason(), Some("end_turn"));
969    }
970
971    #[test]
972    fn test_entry_model() {
973        let entry: ConversationEntry = serde_json::from_str(
974            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
975        )
976        .unwrap();
977        assert_eq!(entry.model(), Some("claude-opus-4-6"));
978    }
979
980    // ── Conversation title/first_user_text ───────────────────────────
981
982    #[test]
983    fn test_conversation_title() {
984        let convo = create_test_conversation();
985        let title = convo.title(4).unwrap();
986        assert_eq!(title, "Hell...");
987    }
988
989    #[test]
990    fn test_conversation_title_short() {
991        let convo = create_test_conversation();
992        let title = convo.title(100).unwrap();
993        assert_eq!(title, "Hello");
994    }
995
996    #[test]
997    fn test_conversation_first_user_text() {
998        let convo = create_test_conversation();
999        assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1000    }
1001
1002    #[test]
1003    fn test_conversation_title_empty() {
1004        let convo = Conversation::new("empty".to_string());
1005        assert!(convo.title(50).is_none());
1006    }
1007}