1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct ConversationEntry {
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub parent_uuid: Option<String>,
11
12 #[serde(default)]
13 pub is_sidechain: bool,
14
15 #[serde(rename = "type")]
16 pub entry_type: String,
17
18 #[serde(default)]
19 pub uuid: String,
20
21 #[serde(default)]
22 pub timestamp: String,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub session_id: Option<String>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub cwd: Option<String>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub git_branch: Option<String>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub version: Option<String>,
35
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub message: Option<Message>,
38
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub user_type: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub request_id: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub tool_use_result: Option<Value>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub snapshot: Option<Value>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub message_id: Option<String>,
53
54 #[serde(flatten)]
55 pub extra: HashMap<String, Value>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct Message {
61 pub role: MessageRole,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub content: Option<MessageContent>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub model: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub id: Option<String>,
71
72 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
73 pub message_type: Option<String>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub stop_reason: Option<String>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub stop_sequence: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub usage: Option<Usage>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(untagged)]
87pub enum MessageContent {
88 Text(String),
89 Parts(Vec<ContentPart>),
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(tag = "type", rename_all = "snake_case")]
94pub enum ContentPart {
95 Text {
96 text: String,
97 },
98 Thinking {
99 thinking: String,
100 #[serde(default)]
101 signature: Option<String>,
102 },
103 ToolUse {
104 id: String,
105 name: String,
106 input: Value,
107 },
108 ToolResult {
109 tool_use_id: String,
110 content: ToolResultContent,
111 #[serde(default)]
112 is_error: bool,
113 },
114 #[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
145impl ContentPart {
146 pub fn summary(&self) -> String {
148 match self {
149 ContentPart::Text { text } => {
150 if text.chars().count() > 100 {
151 let truncated: String = text.chars().take(97).collect();
152 format!("{}...", truncated)
153 } else {
154 text.clone()
155 }
156 }
157 ContentPart::Thinking { .. } => "[thinking]".to_string(),
158 ContentPart::ToolUse { name, .. } => format!("[tool_use: {}]", name),
159 ContentPart::ToolResult {
160 is_error, content, ..
161 } => {
162 let text = content.text();
163 let prefix = if *is_error { "error" } else { "result" };
164 if text.chars().count() > 80 {
165 let truncated: String = text.chars().take(77).collect();
166 format!("[{}: {}...]", prefix, truncated)
167 } else {
168 format!("[{}: {}]", prefix, text)
169 }
170 }
171 ContentPart::Unknown => "[unknown]".to_string(),
172 }
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Copy)]
177#[serde(rename_all = "lowercase")]
178pub enum MessageRole {
179 User,
180 Assistant,
181 System,
182}
183
184impl std::str::FromStr for MessageRole {
185 type Err = String;
186
187 fn from_str(s: &str) -> Result<Self, Self::Err> {
188 match s.to_lowercase().as_str() {
189 "user" => Ok(MessageRole::User),
190 "assistant" => Ok(MessageRole::Assistant),
191 "system" => Ok(MessageRole::System),
192 _ => Err(format!("Invalid message role: {}", s)),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct Usage {
200 pub input_tokens: Option<u32>,
201 pub output_tokens: Option<u32>,
202 pub cache_creation_input_tokens: Option<u32>,
203 pub cache_read_input_tokens: Option<u32>,
204 pub cache_creation: Option<CacheCreation>,
205 pub service_tier: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct CacheCreation {
211 pub ephemeral_5m_input_tokens: Option<u32>,
212 pub ephemeral_1h_input_tokens: Option<u32>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct HistoryEntry {
217 pub display: String,
218
219 #[serde(rename = "pastedContents", default)]
220 pub pasted_contents: HashMap<String, Value>,
221
222 pub timestamp: i64,
223
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub project: Option<String>,
226
227 #[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
228 pub session_id: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct Conversation {
233 pub session_id: String,
234 pub project_path: Option<String>,
235 pub entries: Vec<ConversationEntry>,
236 pub started_at: Option<DateTime<Utc>>,
237 pub last_activity: Option<DateTime<Utc>>,
238}
239
240impl Conversation {
241 pub fn new(session_id: String) -> Self {
242 Self {
243 session_id,
244 project_path: None,
245 entries: Vec::new(),
246 started_at: None,
247 last_activity: None,
248 }
249 }
250
251 pub fn add_entry(&mut self, entry: ConversationEntry) {
252 if let Ok(timestamp) = entry.timestamp.parse::<DateTime<Utc>>() {
253 if self.started_at.is_none() || Some(timestamp) < self.started_at {
254 self.started_at = Some(timestamp);
255 }
256 if self.last_activity.is_none() || Some(timestamp) > self.last_activity {
257 self.last_activity = Some(timestamp);
258 }
259 }
260
261 if self.project_path.is_none() {
262 self.project_path = entry.cwd.clone();
263 }
264
265 self.entries.push(entry);
266 }
267
268 pub fn user_messages(&self) -> Vec<&ConversationEntry> {
269 self.entries
270 .iter()
271 .filter(|e| {
272 e.entry_type == "user"
273 && e.message
274 .as_ref()
275 .map(|m| m.role == MessageRole::User)
276 .unwrap_or(false)
277 })
278 .collect()
279 }
280
281 pub fn assistant_messages(&self) -> Vec<&ConversationEntry> {
282 self.entries
283 .iter()
284 .filter(|e| {
285 e.entry_type == "assistant"
286 && e.message
287 .as_ref()
288 .map(|m| m.role == MessageRole::Assistant)
289 .unwrap_or(false)
290 })
291 .collect()
292 }
293
294 pub fn tool_uses(&self) -> Vec<(&ConversationEntry, &ContentPart)> {
295 let mut results = Vec::new();
296
297 for entry in &self.entries {
298 if let Some(message) = &entry.message
299 && let Some(MessageContent::Parts(parts)) = &message.content
300 {
301 for part in parts {
302 if matches!(part, ContentPart::ToolUse { .. }) {
303 results.push((entry, part));
304 }
305 }
306 }
307 }
308
309 results
310 }
311
312 pub fn message_count(&self) -> usize {
313 self.entries.iter().filter(|e| e.message.is_some()).count()
314 }
315
316 pub fn duration(&self) -> Option<chrono::Duration> {
317 match (self.started_at, self.last_activity) {
318 (Some(start), Some(end)) => Some(end - start),
319 _ => None,
320 }
321 }
322
323 pub fn entries_since(&self, since_uuid: &str) -> Vec<ConversationEntry> {
327 match self.entries.iter().position(|e| e.uuid == since_uuid) {
328 Some(idx) => self.entries.iter().skip(idx + 1).cloned().collect(),
329 None => self.entries.clone(),
330 }
331 }
332
333 pub fn last_uuid(&self) -> Option<&str> {
335 self.entries.last().map(|e| e.uuid.as_str())
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct ConversationMetadata {
341 pub session_id: String,
342 pub project_path: String,
343 pub file_path: std::path::PathBuf,
344 pub message_count: usize,
345 pub started_at: Option<DateTime<Utc>>,
346 pub last_activity: Option<DateTime<Utc>>,
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 fn create_test_conversation() -> Conversation {
354 let mut convo = Conversation::new("test-session".to_string());
355
356 let entries = vec![
357 r#"{"uuid":"uuid-1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}"#,
358 r#"{"uuid":"uuid-2","type":"assistant","timestamp":"2024-01-01T00:00:01Z","message":{"role":"assistant","content":"Hi"}}"#,
359 r#"{"uuid":"uuid-3","type":"user","timestamp":"2024-01-01T00:00:02Z","message":{"role":"user","content":"How are you?"}}"#,
360 r#"{"uuid":"uuid-4","type":"assistant","timestamp":"2024-01-01T00:00:03Z","message":{"role":"assistant","content":"I'm good!"}}"#,
361 ];
362
363 for entry_json in entries {
364 let entry: ConversationEntry = serde_json::from_str(entry_json).unwrap();
365 convo.add_entry(entry);
366 }
367
368 convo
369 }
370
371 #[test]
372 fn test_entries_since_middle() {
373 let convo = create_test_conversation();
374
375 let since = convo.entries_since("uuid-2");
377
378 assert_eq!(since.len(), 2);
379 assert_eq!(since[0].uuid, "uuid-3");
380 assert_eq!(since[1].uuid, "uuid-4");
381 }
382
383 #[test]
384 fn test_entries_since_first() {
385 let convo = create_test_conversation();
386
387 let since = convo.entries_since("uuid-1");
389
390 assert_eq!(since.len(), 3);
391 assert_eq!(since[0].uuid, "uuid-2");
392 }
393
394 #[test]
395 fn test_entries_since_last() {
396 let convo = create_test_conversation();
397
398 let since = convo.entries_since("uuid-4");
400
401 assert!(since.is_empty());
402 }
403
404 #[test]
405 fn test_entries_since_unknown() {
406 let convo = create_test_conversation();
407
408 let since = convo.entries_since("unknown-uuid");
410
411 assert_eq!(since.len(), 4);
412 }
413
414 #[test]
415 fn test_last_uuid() {
416 let convo = create_test_conversation();
417
418 assert_eq!(convo.last_uuid(), Some("uuid-4"));
419 }
420
421 #[test]
422 fn test_last_uuid_empty() {
423 let convo = Conversation::new("empty-session".to_string());
424
425 assert_eq!(convo.last_uuid(), None);
426 }
427
428 #[test]
431 fn test_user_messages() {
432 let convo = create_test_conversation();
433 let users = convo.user_messages();
434 assert_eq!(users.len(), 2);
435 assert!(users.iter().all(|e| e.entry_type == "user"));
436 }
437
438 #[test]
439 fn test_assistant_messages() {
440 let convo = create_test_conversation();
441 let assistants = convo.assistant_messages();
442 assert_eq!(assistants.len(), 2);
443 assert!(assistants.iter().all(|e| e.entry_type == "assistant"));
444 }
445
446 #[test]
447 fn test_message_count() {
448 let convo = create_test_conversation();
449 assert_eq!(convo.message_count(), 4);
450 }
451
452 #[test]
453 fn test_duration() {
454 let convo = create_test_conversation();
455 let dur = convo.duration().unwrap();
456 assert_eq!(dur.num_seconds(), 3); }
458
459 #[test]
460 fn test_duration_empty_conversation() {
461 let convo = Conversation::new("empty".to_string());
462 assert!(convo.duration().is_none());
463 }
464
465 #[test]
466 fn test_add_entry_tracks_timestamps() {
467 let mut convo = Conversation::new("test".to_string());
468 let entry: ConversationEntry = serde_json::from_str(
469 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","message":{"role":"user","content":"hi"}}"#
470 ).unwrap();
471 convo.add_entry(entry);
472
473 assert!(convo.started_at.is_some());
474 assert!(convo.last_activity.is_some());
475 assert_eq!(convo.started_at, convo.last_activity);
476 }
477
478 #[test]
479 fn test_add_entry_sets_project_path() {
480 let mut convo = Conversation::new("test".to_string());
481 let entry: ConversationEntry = serde_json::from_str(
482 r#"{"uuid":"u1","type":"user","timestamp":"2024-06-15T10:00:00Z","cwd":"/home/user/project","message":{"role":"user","content":"hi"}}"#
483 ).unwrap();
484 convo.add_entry(entry);
485 assert_eq!(convo.project_path.as_deref(), Some("/home/user/project"));
486 }
487
488 #[test]
489 fn test_tool_uses() {
490 let mut convo = Conversation::new("test".to_string());
491 let entry: ConversationEntry = serde_json::from_str(
492 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"}}]}}"#
493 ).unwrap();
494 convo.add_entry(entry);
495
496 let uses = convo.tool_uses();
497 assert_eq!(uses.len(), 1);
498 match uses[0].1 {
499 ContentPart::ToolUse { name, .. } => assert_eq!(name, "Read"),
500 _ => panic!("Expected ToolUse"),
501 }
502 }
503
504 #[test]
505 fn test_tool_uses_empty() {
506 let convo = create_test_conversation();
507 let uses = convo.tool_uses();
509 assert!(uses.is_empty());
510 }
511
512 #[test]
515 fn test_content_part_summary_text_short() {
516 let part = ContentPart::Text {
517 text: "Hello world".to_string(),
518 };
519 assert_eq!(part.summary(), "Hello world");
520 }
521
522 #[test]
523 fn test_content_part_summary_text_long() {
524 let long = "A".repeat(200);
525 let part = ContentPart::Text { text: long };
526 let summary = part.summary();
527 assert!(summary.ends_with("..."));
528 assert!(summary.chars().count() <= 100);
529 }
530
531 #[test]
532 fn test_content_part_summary_thinking() {
533 let part = ContentPart::Thinking {
534 thinking: "deep thought".to_string(),
535 signature: None,
536 };
537 assert_eq!(part.summary(), "[thinking]");
538 }
539
540 #[test]
541 fn test_content_part_summary_tool_use() {
542 let part = ContentPart::ToolUse {
543 id: "t1".to_string(),
544 name: "Write".to_string(),
545 input: serde_json::json!({}),
546 };
547 assert_eq!(part.summary(), "[tool_use: Write]");
548 }
549
550 #[test]
551 fn test_content_part_summary_tool_result_short() {
552 let part = ContentPart::ToolResult {
553 tool_use_id: "t1".to_string(),
554 content: ToolResultContent::Text("OK".to_string()),
555 is_error: false,
556 };
557 assert_eq!(part.summary(), "[result: OK]");
558 }
559
560 #[test]
561 fn test_content_part_summary_tool_result_error() {
562 let part = ContentPart::ToolResult {
563 tool_use_id: "t1".to_string(),
564 content: ToolResultContent::Text("fail".to_string()),
565 is_error: true,
566 };
567 assert_eq!(part.summary(), "[error: fail]");
568 }
569
570 #[test]
571 fn test_content_part_summary_tool_result_long() {
572 let long = "X".repeat(200);
573 let part = ContentPart::ToolResult {
574 tool_use_id: "t1".to_string(),
575 content: ToolResultContent::Text(long),
576 is_error: false,
577 };
578 let summary = part.summary();
579 assert!(summary.starts_with("[result:"));
580 assert!(summary.ends_with("...]"));
581 }
582
583 #[test]
584 fn test_content_part_summary_unknown() {
585 let part = ContentPart::Unknown;
586 assert_eq!(part.summary(), "[unknown]");
587 }
588
589 #[test]
592 fn test_tool_result_content_text_string() {
593 let c = ToolResultContent::Text("hello".to_string());
594 assert_eq!(c.text(), "hello");
595 }
596
597 #[test]
598 fn test_tool_result_content_text_parts() {
599 let c = ToolResultContent::Parts(vec![
600 ToolResultPart {
601 text: Some("line1".to_string()),
602 },
603 ToolResultPart { text: None },
604 ToolResultPart {
605 text: Some("line2".to_string()),
606 },
607 ]);
608 assert_eq!(c.text(), "line1\nline2");
609 }
610
611 #[test]
614 fn test_message_role_from_str() {
615 assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
616 assert_eq!(
617 "assistant".parse::<MessageRole>().unwrap(),
618 MessageRole::Assistant
619 );
620 assert_eq!(
621 "system".parse::<MessageRole>().unwrap(),
622 MessageRole::System
623 );
624 }
625
626 #[test]
627 fn test_message_role_from_str_case_insensitive() {
628 assert_eq!("USER".parse::<MessageRole>().unwrap(), MessageRole::User);
629 assert_eq!(
630 "Assistant".parse::<MessageRole>().unwrap(),
631 MessageRole::Assistant
632 );
633 }
634
635 #[test]
636 fn test_message_role_from_str_invalid() {
637 assert!("invalid".parse::<MessageRole>().is_err());
638 }
639}