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 segment_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
406/// Sets every string-valued `sessionId` key in `value`, at any depth,
407/// to `id`.
408fn set_session_id_keys(value: &mut serde_json::Value, id: &str) {
409    match value {
410        serde_json::Value::Object(map) => {
411            for (key, child) in map.iter_mut() {
412                if key == crate::constants::SESSION_ID && child.is_string() {
413                    *child = serde_json::Value::String(id.to_string());
414                } else {
415                    set_session_id_keys(child, id);
416                }
417            }
418        }
419        serde_json::Value::Array(items) => {
420            for child in items {
421                set_session_id_keys(child, id);
422            }
423        }
424        _ => {}
425    }
426}
427
428impl Conversation {
429    pub fn new(session_id: String) -> Self {
430        Self {
431            session_id,
432            project_path: None,
433            entries: Vec::new(),
434            started_at: None,
435            last_activity: None,
436            segment_ids: Vec::new(),
437            preamble: Vec::new(),
438        }
439    }
440
441    pub fn add_entry(&mut self, entry: ConversationEntry) {
442        if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
443            if self.started_at.is_none() || Some(timestamp) < self.started_at {
444                self.started_at = Some(timestamp);
445            }
446            if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
447                self.last_activity = Some(timestamp);
448            }
449        }
450
451        if self.project_path.is_none() {
452            self.project_path = entry.cwd.clone();
453        }
454
455        self.entries.push(entry);
456    }
457
458    /// Sets the session ID everywhere the format carries it:
459    /// `session_id`, every entry's top-level `sessionId` that is
460    /// present, and every string-valued `sessionId` key in preamble
461    /// lines at any depth. Claude Code copies the ID into
462    /// `worktreeSession.sessionId` on `worktree-state` lines. A
463    /// `sessionId` nested inside an entry (a tool result, a snapshot,
464    /// an extra key) is content that names another session, such as a
465    /// sub-agent's, and stays as it is.
466    pub fn rename_session(&mut self, id: &str) {
467        self.session_id = id.to_string();
468        for slot in self
469            .entries
470            .iter_mut()
471            .filter_map(|e| e.session_id.as_mut())
472        {
473            *slot = id.to_string();
474        }
475        for raw in &mut self.preamble {
476            set_session_id_keys(raw, id);
477        }
478    }
479
480    /// Sets the directory everywhere the format carries it:
481    /// `project_path`, every entry's `cwd` that is present, and a
482    /// top-level `cwd` on a preamble line.
483    pub fn reroot(&mut self, dir: &str) {
484        self.project_path = Some(dir.to_string());
485        for slot in self.entries.iter_mut().filter_map(|e| e.cwd.as_mut()) {
486            *slot = dir.to_string();
487        }
488        for raw in &mut self.preamble {
489            if let Some(slot) = raw.get_mut(crate::constants::CWD).filter(|v| v.is_string()) {
490                *slot = serde_json::Value::String(dir.to_string());
491            }
492        }
493    }
494
495    pub fn user_messages(&self) -> Vec<&ConversationEntry> {
496        self.entries
497            .iter()
498            .filter(|e| {
499                e.entry_type == "user"
500                    && e.message
501                        .as_ref()
502                        .map(|m| m.role == MessageRole::User)
503                        .unwrap_or(false)
504            })
505            .collect()
506    }
507
508    pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
509        self.entries
510            .iter()
511            .filter(|e| {
512                e.entry_type == "assistant"
513                    && e.message
514                        .as_ref()
515                        .map(|m| m.role == MessageRole::Assistant)
516                        .unwrap_or(false)
517            })
518            .collect()
519    }
520
521    pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
522        let mut results = Vec::new();
523
524        for entry in &self.entries {
525            if let Some(message) = &entry.message
526                && let Some(MessageContent::Parts(parts)) = &message.content
527            {
528                for part in parts {
529                    if matches!(part, ContentPart::ToolUse { .. }) {
530                        results.push((entry, part));
531                    }
532                }
533            }
534        }
535
536        results
537    }
538
539    pub fn message_count(&self) -> usize {
540        self.entries.iter().filter(|e| e.message.is_some()).count()
541    }
542
543    pub fn duration(&self) -> Option<chrono::Duration> {
544        match (self.started_at, self.last_activity) {
545            (Some(start), Some(end)) => Some(end - start),
546            _ => None,
547        }
548    }
549
550    /// Returns entries after the given UUID.
551    /// If the UUID is not found, returns all entries (for full sync).
552    /// If the UUID is the last entry, returns an empty vec.
553    pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
554        match self.entries.iter().position(|e| e.uuid == since_uuid) {
555            Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
556            None => self.entries.clone(),
557        }
558    }
559
560    /// Returns the UUID of the last entry, if any.
561    pub fn last_uuid(&self) -> Option<&str> {
562        self.entries.last().map(|e| e.uuid.as_str())
563    }
564
565    /// Text of the first user message, truncated to `max_len` characters.
566    pub fn title(&self, max_len: usize) -> Option<String> {
567        self.first_user_text().map(|text| {
568            if text.chars().count() > max_len {
569                let truncated: String = text.chars().take(max_len).collect();
570                format!("{}...", truncated)
571            } else {
572                text
573            }
574        })
575    }
576
577    /// Full text of the first user message, untruncated.
578    pub fn first_user_text(&self) -> Option<String> {
579        self.entries.iter().find_map(|e| {
580            e.message.as_ref().and_then(|msg| {
581                if msg.is_user() {
582                    let text = msg.text();
583                    if text.is_empty() { None } else { Some(text) }
584                } else {
585                    None
586                }
587            })
588        })
589    }
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593pub struct ConversationMetadata {
594    pub session_id: String,
595    /// Parent directory of `file_path`, unsanitized.
596    pub project_path: String,
597    pub file_path: std::path::PathBuf,
598    pub message_count: usize,
599    pub started_at: Option<DateTime<Utc>>,
600    pub last_activity: Option<DateTime<Utc>>,
601    /// First non-empty user-prompt text. Used as a human-readable title.
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub first_user_message: Option<String>,
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn entry(json: &str) -> ConversationEntry {
611        serde_json::from_str(json).unwrap()
612    }
613
614    #[test]
615    fn rename_session_sets_every_session_id_key() {
616        let mut convo = Conversation::new("old".to_string());
617        convo.preamble.push(serde_json::json!({
618            "type": "permission-mode", "permissionMode": "default", "sessionId": "old"
619        }));
620        convo.preamble.push(serde_json::json!({
621            "type": "worktree-state", "sessionId": "old",
622            "worktreeSession": {"sessionId": "old", "worktreePath": "/wt"}
623        }));
624        convo
625            .preamble
626            .push(serde_json::json!({"type": "odd", "sessionId": 7}));
627        convo.add_entry(entry(
628            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","sessionId":"old","message":{"role":"user","content":"hi"}}"#,
629        ));
630        convo.add_entry(entry(
631            r#"{"uuid":"u2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","sessionId":"old","message":{"role":"assistant","content":"yo"}}"#,
632        ));
633
634        convo.rename_session("new");
635
636        assert_eq!(convo.session_id, "new");
637        assert!(
638            convo
639                .entries
640                .iter()
641                .all(|e| e.session_id.as_deref() == Some("new"))
642        );
643        assert_eq!(convo.preamble[0]["sessionId"], "new");
644        assert_eq!(convo.preamble[1]["sessionId"], "new");
645        assert_eq!(convo.preamble[1]["worktreeSession"]["sessionId"], "new");
646        assert_eq!(convo.preamble[1]["worktreeSession"]["worktreePath"], "/wt");
647        assert_eq!(convo.preamble[2]["sessionId"], 7);
648    }
649
650    #[test]
651    fn reroot_sets_project_path_and_every_present_cwd() {
652        let mut convo = Conversation::new("s".to_string());
653        convo.preamble.push(serde_json::json!({
654            "type": "custom-title", "cwd": "/old", "customTitle": "x"
655        }));
656        convo
657            .preamble
658            .push(serde_json::json!({"type": "last-prompt", "lastPrompt": "hi"}));
659        convo.add_entry(entry(
660            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/old","message":{"role":"user","content":"hi"}}"#,
661        ));
662        convo.add_entry(entry(
663            r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:01Z","message":{"role":"user","content":"hi"}}"#,
664        ));
665        assert_eq!(convo.project_path.as_deref(), Some("/old"));
666
667        convo.reroot("/new");
668
669        assert_eq!(convo.project_path.as_deref(), Some("/new"));
670        assert_eq!(convo.entries[0].cwd.as_deref(), Some("/new"));
671        assert_eq!(convo.entries[1].cwd, None);
672        assert_eq!(convo.preamble[0]["cwd"], "/new");
673        assert!(convo.preamble[1].get("cwd").is_none());
674    }
675
676    fn create_test_conversation() -> Conversation {
677        let mut convo = Conversation::new("test-session".to_string());
678
679        let entries = vec![
680            r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
681            r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
682            r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
683            r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
684        ];
685
686        for entry_json in entries {
687            let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
688            convo.add_entry(entry);
689        }
690
691        convo
692    }
693
694    #[test]
695    fn test_entries_since_middle() {
696        let convo = create_test_conversation();
697
698        // Get entries since uuid-2 (should return uuid-3, uuid-4)
699        let since = convo.entries_since("uuid-2");
700
701        assert_eq!(since.len(), 2);
702        assert_eq!(since[0].uuid, "uuid-3");
703        assert_eq!(since[1].uuid, "uuid-4");
704    }
705
706    #[test]
707    fn test_entries_since_first() {
708        let convo = create_test_conversation();
709
710        // Get entries since uuid-1 (should return uuid-2, uuid-3, uuid-4)
711        let since = convo.entries_since("uuid-1");
712
713        assert_eq!(since.len(), 3);
714        assert_eq!(since[0].uuid, "uuid-2");
715    }
716
717    #[test]
718    fn test_entries_since_last() {
719        let convo = create_test_conversation();
720
721        // Get entries since last UUID (should return empty)
722        let since = convo.entries_since("uuid-4");
723
724        assert!(since.is_empty());
725    }
726
727    #[test]
728    fn test_entries_since_unknown() {
729        let convo = create_test_conversation();
730
731        // Get entries since unknown UUID (should return all entries)
732        let since = convo.entries_since("unknown-uuid");
733
734        assert_eq!(since.len(), 4);
735    }
736
737    #[test]
738    fn test_last_uuid() {
739        let convo = create_test_conversation();
740
741        assert_eq!(convo.last_uuid(), Some("uuid-4"));
742    }
743
744    #[test]
745    fn test_last_uuid_empty() {
746        let convo = Conversation::new("empty-session".to_string());
747
748        assert_eq!(convo.last_uuid(), None);
749    }
750
751    // ── Conversation methods ───────────────────────────────────────────
752
753    #[test]
754    fn test_user_messages() {
755        let convo = create_test_conversation();
756        let users = convo.user_messages();
757        assert_eq!(users.len(), 2);
758        assert!(users.iter().all(|e| e.entry_type == "user"));
759    }
760
761    #[test]
762    fn test_assistant_messages() {
763        let convo = create_test_conversation();
764        let assistants = convo.assistant_messages();
765        assert_eq!(assistants.len(), 2);
766        assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
767    }
768
769    #[test]
770    fn test_message_count() {
771        let convo = create_test_conversation();
772        assert_eq!(convo.message_count(), 4);
773    }
774
775    #[test]
776    fn test_duration() {
777        let convo = create_test_conversation();
778        let dur = convo.duration().unwrap();
779        assert_eq!(dur.num_seconds(), 3); // 00:00:00 to 00:00:03
780    }
781
782    #[test]
783    fn test_duration_empty_conversation() {
784        let convo = Conversation::new("empty".to_string());
785        assert!(convo.duration().is_none());
786    }
787
788    #[test]
789    fn test_add_entry_tracks_timestamps() {
790        let mut convo = Conversation::new("test".to_string());
791        let entry: ConversationEntry = serde_json::from_str(
792            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
793        ).unwrap();
794        convo.add_entry(entry);
795
796        assert!(convo.started_at.is_some());
797        assert!(convo.last_activity.is_some());
798        assert_eq!(convo.started_at, convo.last_activity);
799    }
800
801    #[test]
802    fn test_add_entry_sets_project_path() {
803        let mut convo = Conversation::new("test".to_string());
804        let entry: ConversationEntry = serde_json::from_str(
805            r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
806        ).unwrap();
807        convo.add_entry(entry);
808        assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
809    }
810
811    #[test]
812    fn test_tool_uses() {
813        let mut convo = Conversation::new("test".to_string());
814        let entry: ConversationEntry = serde_json::from_str(
815            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"}}]}}"#
816        ).unwrap();
817        convo.add_entry(entry);
818
819        let uses = convo.tool_uses();
820        assert_eq!(uses.len(), 1);
821        match uses[0].1 {
822            ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
823            _ => panic!("Expected ToolUse"),
824        }
825    }
826
827    #[test]
828    fn test_tool_uses_empty() {
829        let convo = create_test_conversation();
830        // The test conversation uses MessageContent::Text, no tool uses
831        let uses = convo.tool_uses();
832        assert!(uses.is_empty());
833    }
834
835    // ── ContentPart::summary ───────────────────────────────────────────
836
837    #[test]
838    fn test_content_part_summary_text_short() {
839        let part = ContentPart::Text {
840            text: "Hello world".to_string(),
841        };
842        assert_eq!(part.summary(), "Hello world");
843    }
844
845    #[test]
846    fn test_content_part_summary_text_long() {
847        let long = "A".repeat(200);
848        let part = ContentPart::Text { text: long };
849        let summary = part.summary();
850        assert!(summary.ends_with("..."));
851        assert!(summary.chars().count() <= 100);
852    }
853
854    #[test]
855    fn test_content_part_summary_thinking() {
856        let part = ContentPart::Thinking {
857            thinking: "deep thought".to_string(),
858            signature: None,
859        };
860        assert_eq!(part.summary(), "[thinking]");
861    }
862
863    #[test]
864    fn test_thinking_none_signature_serializes_absent() {
865        let part = ContentPart::Thinking {
866            thinking: "deep thought".to_string(),
867            signature: None,
868        };
869        let json = serde_json::to_value(&part).unwrap();
870        assert!(
871            json.get("signature").is_none(),
872            "None signature must serialize as an absent key, not null: the \
873             API 400s on `\"signature\": null` but tolerates absence"
874        );
875
876        let signed = ContentPart::Thinking {
877            thinking: "deep thought".to_string(),
878            signature: Some("sig123".to_string()),
879        };
880        let json = serde_json::to_value(&signed).unwrap();
881        assert_eq!(json.get("signature").unwrap(), "sig123");
882    }
883
884    #[test]
885    fn test_content_part_summary_tool_use() {
886        let part = ContentPart::ToolUse {
887            id: "t1".to_string(),
888            name: "Write".to_string(),
889            input: serde_json::json!({}),
890        };
891        assert_eq!(part.summary(), "[tool_use: Write]");
892    }
893
894    #[test]
895    fn test_content_part_summary_tool_result_short() {
896        let part = ContentPart::ToolResult {
897            tool_use_id: "t1".to_string(),
898            content: ToolResultContent::Text("OK".to_string()),
899            is_error: false,
900        };
901        assert_eq!(part.summary(), "[result: OK]");
902    }
903
904    #[test]
905    fn test_content_part_summary_tool_result_error() {
906        let part = ContentPart::ToolResult {
907            tool_use_id: "t1".to_string(),
908            content: ToolResultContent::Text("fail".to_string()),
909            is_error: true,
910        };
911        assert_eq!(part.summary(), "[error: fail]");
912    }
913
914    #[test]
915    fn test_content_part_summary_tool_result_long() {
916        let long = "X".repeat(200);
917        let part = ContentPart::ToolResult {
918            tool_use_id: "t1".to_string(),
919            content: ToolResultContent::Text(long),
920            is_error: false,
921        };
922        let summary = part.summary();
923        assert!(summary.starts_with("[result:"));
924        assert!(summary.ends_with("...]"));
925    }
926
927    #[test]
928    fn test_content_part_summary_unknown() {
929        let part = ContentPart::Unknown;
930        assert_eq!(part.summary(), "[unknown]");
931    }
932
933    // ── ToolResultContent::text ────────────────────────────────────────
934
935    #[test]
936    fn test_tool_result_content_text_string() {
937        let c = ToolResultContent::Text("hello".to_string());
938        assert_eq!(c.text(), "hello");
939    }
940
941    #[test]
942    fn test_tool_result_content_text_parts() {
943        let c = ToolResultContent::Parts(vec![
944            ToolResultPart {
945                text: Some("line1".to_string()),
946            },
947            ToolResultPart { text: None },
948            ToolResultPart {
949                text: Some("line2".to_string()),
950            },
951        ]);
952        assert_eq!(c.text(), "line1\nline2");
953    }
954
955    // ── MessageRole::from_str ──────────────────────────────────────────
956
957    #[test]
958    fn test_message_role_from_str() {
959        assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
960        assert_eq!(
961            "assistant".parse::<MessageRole>().unwrap(),
962            MessageRole::Assistant
963        );
964        assert_eq!(
965            "system".parse::<MessageRole>().unwrap(),
966            MessageRole::System
967        );
968    }
969
970    #[test]
971    fn test_message_role_from_str_case_insensitive() {
972        assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
973        assert_eq!(
974            "Assistant".parse::<MessageRole>().unwrap(),
975            MessageRole::Assistant
976        );
977    }
978
979    #[test]
980    fn test_message_role_from_str_invalid() {
981        assert!("invalid".parse::<MessageRole>().is_err());
982    }
983
984    // ── Message convenience methods ──────────────────────────────────
985
986    #[test]
987    fn test_message_text_from_string() {
988        let msg = Message {
989            role: MessageRole::User,
990            content: Some(MessageContent::Text("Hello world".to_string())),
991            model: None,
992            id: None,
993            message_type: None,
994            stop_reason: None,
995            stop_sequence: None,
996            usage: None,
997        };
998        assert_eq!(msg.text(), "Hello world");
999    }
1000
1001    #[test]
1002    fn test_message_text_from_parts() {
1003        let msg = Message {
1004            role: MessageRole::Assistant,
1005            content: Some(MessageContent::Parts(vec![
1006                ContentPart::Text {
1007                    text: "First".to_string(),
1008                },
1009                ContentPart::Thinking {
1010                    thinking: "hmm".to_string(),
1011                    signature: None,
1012                },
1013                ContentPart::Text {
1014                    text: "Second".to_string(),
1015                },
1016            ])),
1017            model: None,
1018            id: None,
1019            message_type: None,
1020            stop_reason: None,
1021            stop_sequence: None,
1022            usage: None,
1023        };
1024        assert_eq!(msg.text(), "First\nSecond");
1025    }
1026
1027    #[test]
1028    fn test_message_text_none() {
1029        let msg = Message {
1030            role: MessageRole::User,
1031            content: None,
1032            model: None,
1033            id: None,
1034            message_type: None,
1035            stop_reason: None,
1036            stop_sequence: None,
1037            usage: None,
1038        };
1039        assert_eq!(msg.text(), "");
1040    }
1041
1042    #[test]
1043    fn test_message_thinking() {
1044        let msg = Message {
1045            role: MessageRole::Assistant,
1046            content: Some(MessageContent::Parts(vec![
1047                ContentPart::Thinking {
1048                    thinking: "deep thought".to_string(),
1049                    signature: None,
1050                },
1051                ContentPart::Text {
1052                    text: "answer".to_string(),
1053                },
1054                ContentPart::Thinking {
1055                    thinking: "more thought".to_string(),
1056                    signature: None,
1057                },
1058            ])),
1059            model: None,
1060            id: None,
1061            message_type: None,
1062            stop_reason: None,
1063            stop_sequence: None,
1064            usage: None,
1065        };
1066        let thinking = msg.thinking().unwrap();
1067        assert_eq!(thinking, vec!["deep thought", "more thought"]);
1068    }
1069
1070    #[test]
1071    fn test_message_thinking_none() {
1072        let msg = Message {
1073            role: MessageRole::User,
1074            content: Some(MessageContent::Text("hi".to_string())),
1075            model: None,
1076            id: None,
1077            message_type: None,
1078            stop_reason: None,
1079            stop_sequence: None,
1080            usage: None,
1081        };
1082        assert!(msg.thinking().is_none());
1083    }
1084
1085    #[test]
1086    fn test_message_tool_uses() {
1087        let msg = Message {
1088            role: MessageRole::Assistant,
1089            content: Some(MessageContent::Parts(vec![
1090                ContentPart::ToolUse {
1091                    id: "t1".to_string(),
1092                    name: "Read".to_string(),
1093                    input: serde_json::json!({"file": "test.rs"}),
1094                },
1095                ContentPart::Text {
1096                    text: "checking".to_string(),
1097                },
1098                ContentPart::ToolUse {
1099                    id: "t2".to_string(),
1100                    name: "Write".to_string(),
1101                    input: serde_json::json!({}),
1102                },
1103            ])),
1104            model: None,
1105            id: None,
1106            message_type: None,
1107            stop_reason: None,
1108            stop_sequence: None,
1109            usage: None,
1110        };
1111        let uses = msg.tool_uses();
1112        assert_eq!(uses.len(), 2);
1113        assert_eq!(uses[0].name, "Read");
1114        assert_eq!(uses[1].name, "Write");
1115    }
1116
1117    #[test]
1118    fn test_message_tool_results() {
1119        let msg = Message {
1120            role: MessageRole::User,
1121            content: Some(MessageContent::Parts(vec![
1122                ContentPart::ToolResult {
1123                    tool_use_id: "t1".to_string(),
1124                    content: ToolResultContent::Text("file contents".to_string()),
1125                    is_error: false,
1126                },
1127                ContentPart::ToolResult {
1128                    tool_use_id: "t2".to_string(),
1129                    content: ToolResultContent::Text("error msg".to_string()),
1130                    is_error: true,
1131                },
1132            ])),
1133            model: None,
1134            id: None,
1135            message_type: None,
1136            stop_reason: None,
1137            stop_sequence: None,
1138            usage: None,
1139        };
1140        let results = msg.tool_results();
1141        assert_eq!(results.len(), 2);
1142        assert_eq!(results[0].tool_use_id, "t1");
1143        assert_eq!(results[0].content.text(), "file contents");
1144        assert!(!results[0].is_error);
1145        assert_eq!(results[1].tool_use_id, "t2");
1146        assert!(results[1].is_error);
1147    }
1148
1149    #[test]
1150    fn test_message_tool_results_empty() {
1151        let msg = Message {
1152            role: MessageRole::User,
1153            content: Some(MessageContent::Text("hello".to_string())),
1154            model: None,
1155            id: None,
1156            message_type: None,
1157            stop_reason: None,
1158            stop_sequence: None,
1159            usage: None,
1160        };
1161        assert!(msg.tool_results().is_empty());
1162    }
1163
1164    #[test]
1165    fn test_message_role_checks() {
1166        let user_msg = Message {
1167            role: MessageRole::User,
1168            content: None,
1169            model: None,
1170            id: None,
1171            message_type: None,
1172            stop_reason: None,
1173            stop_sequence: None,
1174            usage: None,
1175        };
1176        assert!(user_msg.is_user());
1177        assert!(!user_msg.is_assistant());
1178        assert!(user_msg.is_role(MessageRole::User));
1179    }
1180
1181    // ── ConversationEntry convenience methods ────────────────────────
1182
1183    #[test]
1184    fn test_entry_text() {
1185        let entry: ConversationEntry = serde_json::from_str(
1186            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello there"}}"#,
1187        )
1188        .unwrap();
1189        assert_eq!(entry.text(), "Hello there");
1190    }
1191
1192    #[test]
1193    fn test_entry_text_no_message() {
1194        let entry: ConversationEntry = serde_json::from_str(
1195            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z"}"#,
1196        )
1197        .unwrap();
1198        assert_eq!(entry.text(), "");
1199    }
1200
1201    #[test]
1202    fn test_entry_role() {
1203        let entry: ConversationEntry = serde_json::from_str(
1204            r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hi"}}"#,
1205        )
1206        .unwrap();
1207        assert_eq!(entry.role(), Some(&MessageRole::User));
1208    }
1209
1210    #[test]
1211    fn test_entry_stop_reason() {
1212        let entry: ConversationEntry = serde_json::from_str(
1213            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stopReason":"end_turn"}}"#,
1214        )
1215        .unwrap();
1216        assert_eq!(entry.stop_reason(), Some("end_turn"));
1217    }
1218
1219    // ── Snake_case deserialization (real JSONL format) ─────────────
1220
1221    #[test]
1222    fn test_stop_reason_snake_case() {
1223        let entry: ConversationEntry = serde_json::from_str(
1224            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"done","stop_reason":"end_turn","stop_sequence":null}}"#,
1225        )
1226        .unwrap();
1227        assert_eq!(entry.stop_reason(), Some("end_turn"));
1228        assert!(entry.message.as_ref().unwrap().stop_sequence.is_none());
1229    }
1230
1231    #[test]
1232    fn test_usage_snake_case() {
1233        let entry: ConversationEntry = serde_json::from_str(
1234            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"}}}"#,
1235        )
1236        .unwrap();
1237        let usage = entry.message.unwrap().usage.unwrap();
1238        assert_eq!(usage.input_tokens, Some(1200));
1239        assert_eq!(usage.output_tokens, Some(350));
1240        assert_eq!(usage.cache_creation_input_tokens, Some(100));
1241        assert_eq!(usage.cache_read_input_tokens, Some(500));
1242        assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1243    }
1244
1245    #[test]
1246    fn test_cache_creation_snake_case() {
1247        let json = r#"{"ephemeral_5m_input_tokens":10,"ephemeral_1h_input_tokens":20}"#;
1248        let cc: CacheCreation = serde_json::from_str(json).unwrap();
1249        assert_eq!(cc.ephemeral_5m_input_tokens, Some(10));
1250        assert_eq!(cc.ephemeral_1h_input_tokens, Some(20));
1251    }
1252
1253    #[test]
1254    fn test_full_assistant_entry_snake_case() {
1255        // Matches the actual JSONL format written by Claude Code
1256        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"}"#;
1257        let entry: ConversationEntry = serde_json::from_str(json).unwrap();
1258        let msg = entry.message.unwrap();
1259        assert_eq!(msg.stop_reason.as_deref(), Some("end_turn"));
1260        assert!(msg.stop_sequence.is_none());
1261        let usage = msg.usage.unwrap();
1262        assert_eq!(usage.input_tokens, Some(3));
1263        assert_eq!(usage.output_tokens, Some(4));
1264        assert_eq!(usage.cache_creation_input_tokens, Some(4561));
1265        assert_eq!(usage.cache_read_input_tokens, Some(17868));
1266        assert_eq!(usage.service_tier.as_deref(), Some("standard"));
1267        let cc = usage.cache_creation.unwrap();
1268        assert_eq!(cc.ephemeral_5m_input_tokens, Some(0));
1269        assert_eq!(cc.ephemeral_1h_input_tokens, Some(4561));
1270    }
1271
1272    #[test]
1273    fn test_entry_model() {
1274        let entry: ConversationEntry = serde_json::from_str(
1275            r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","content":"hi","model":"claude-opus-4-6"}}"#,
1276        )
1277        .unwrap();
1278        assert_eq!(entry.model(), Some("claude-opus-4-6"));
1279    }
1280
1281    // ── Conversation title/first_user_text ───────────────────────────
1282
1283    #[test]
1284    fn test_conversation_title() {
1285        let convo = create_test_conversation();
1286        let title = convo.title(4).unwrap();
1287        assert_eq!(title, "Hell...");
1288    }
1289
1290    #[test]
1291    fn test_conversation_title_short() {
1292        let convo = create_test_conversation();
1293        let title = convo.title(100).unwrap();
1294        assert_eq!(title, "Hello");
1295    }
1296
1297    #[test]
1298    fn test_conversation_first_user_text() {
1299        let convo = create_test_conversation();
1300        assert_eq!(convo.first_user_text(), Some("Hello".to_string()));
1301    }
1302
1303    #[test]
1304    fn test_conversation_title_empty() {
1305        let convo = Conversation::new("empty".to_string());
1306        assert!(convo.title(50).is_none());
1307    }
1308}