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