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