1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ChatFile {
15 #[serde(default)]
18 pub session_id: String,
19
20 #[serde(default)]
22 pub project_hash: String,
23
24 #[serde(default)]
25 pub start_time: Option<DateTime<Utc>>,
26
27 #[serde(default)]
28 pub last_updated: Option<DateTime<Utc>>,
29
30 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub directories: Option<Vec<PathBuf>>,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub kind: Option<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub summary: Option<String>,
43
44 #[serde(default)]
45 pub messages: Vec<GeminiMessage>,
46
47 #[serde(flatten)]
49 pub extra: HashMap<String, Value>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct GeminiMessage {
56 #[serde(default)]
57 pub id: String,
58
59 #[serde(default)]
60 pub timestamp: String,
61
62 #[serde(rename = "type")]
64 pub role: GeminiRole,
65
66 #[serde(default)]
67 pub content: GeminiContent,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub thoughts: Option<Vec<Thought>>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub tokens: Option<Tokens>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub model: Option<String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none", rename = "toolCalls")]
81 pub tool_calls: Option<Vec<ToolCall>>,
82
83 #[serde(flatten)]
84 pub extra: HashMap<String, Value>,
85}
86
87impl GeminiMessage {
88 pub fn thoughts(&self) -> &[Thought] {
91 self.thoughts.as_deref().unwrap_or(&[])
92 }
93
94 pub fn tool_calls(&self) -> &[ToolCall] {
97 self.tool_calls.as_deref().unwrap_or(&[])
98 }
99}
100
101impl ChatFile {
102 pub fn directories(&self) -> &[PathBuf] {
104 self.directories.as_deref().unwrap_or(&[])
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub enum GeminiRole {
117 User,
118 Gemini,
119 Info,
120 Other(String),
121}
122
123impl GeminiRole {
124 pub fn as_str(&self) -> &str {
125 match self {
126 GeminiRole::User => "user",
127 GeminiRole::Gemini => "gemini",
128 GeminiRole::Info => "info",
129 GeminiRole::Other(s) => s,
130 }
131 }
132}
133
134impl serde::Serialize for GeminiRole {
135 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
136 s.serialize_str(self.as_str())
137 }
138}
139
140impl<'de> serde::Deserialize<'de> for GeminiRole {
141 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
142 let s = String::deserialize(d)?;
143 Ok(match s.as_str() {
144 "user" => GeminiRole::User,
145 "gemini" => GeminiRole::Gemini,
146 "info" => GeminiRole::Info,
147 _ => GeminiRole::Other(s),
148 })
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(untagged)]
157pub enum GeminiContent {
158 Text(String),
159 Parts(Vec<TextPart>),
160}
161
162impl Default for GeminiContent {
163 fn default() -> Self {
164 GeminiContent::Text(String::new())
165 }
166}
167
168impl GeminiContent {
169 pub fn text(&self) -> String {
171 match self {
172 GeminiContent::Text(s) => s.clone(),
173 GeminiContent::Parts(parts) => parts
174 .iter()
175 .filter_map(|p| p.text.as_deref())
176 .collect::<Vec<_>>()
177 .join("\n"),
178 }
179 }
180
181 pub fn is_empty(&self) -> bool {
183 match self {
184 GeminiContent::Text(s) => s.is_empty(),
185 GeminiContent::Parts(parts) => parts
186 .iter()
187 .all(|p| p.text.as_deref().unwrap_or("").is_empty()),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct TextPart {
195 #[serde(default)]
196 pub text: Option<String>,
197 #[serde(flatten)]
198 pub extra: HashMap<String, Value>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Thought {
204 #[serde(default)]
205 pub subject: Option<String>,
206 #[serde(default)]
207 pub description: Option<String>,
208 #[serde(default)]
209 pub timestamp: Option<String>,
210}
211
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
215pub struct Tokens {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub input: Option<u32>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub output: Option<u32>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub cached: Option<u32>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub thoughts: Option<u32>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub tool: Option<u32>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub total: Option<u32>,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct ToolCall {
234 #[serde(default)]
235 pub id: String,
236
237 #[serde(default)]
238 pub name: String,
239
240 #[serde(default)]
241 pub args: Value,
242
243 #[serde(default)]
245 pub status: String,
246
247 #[serde(default)]
248 pub timestamp: String,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub result: Vec<FunctionResponse>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub result_display: Option<Value>,
258
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub description: Option<String>,
261
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub display_name: Option<String>,
264
265 #[serde(flatten)]
266 pub extra: HashMap<String, Value>,
267}
268
269impl ToolCall {
270 pub fn file_diff(&self) -> Option<String> {
273 self.result_display
274 .as_ref()
275 .and_then(|v| v.get("fileDiff"))
276 .and_then(|v| v.as_str())
277 .map(str::to_string)
278 }
279
280 pub fn result_display_text(&self) -> Option<String> {
283 self.result_display
284 .as_ref()
285 .and_then(|v| v.as_str())
286 .map(str::to_string)
287 }
288}
289
290impl ToolCall {
291 pub fn result_text(&self) -> String {
293 let mut parts: Vec<String> = Vec::new();
294 for fr in &self.result {
295 parts.push(response_to_text(&fr.function_response.response));
296 }
297 parts.join("\n")
298 }
299
300 pub fn is_error(&self) -> bool {
302 self.status == "error"
303 }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct FunctionResponse {
309 #[serde(default, rename = "functionResponse")]
310 pub function_response: FunctionResponseBody,
311}
312
313#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct FunctionResponseBody {
315 #[serde(default)]
316 pub id: String,
317 #[serde(default)]
318 pub name: String,
319 #[serde(default)]
320 pub response: Value,
321}
322
323fn response_to_text(v: &Value) -> String {
324 if let Some(output) = v.get("output").and_then(|o| o.as_str()) {
325 return output.to_string();
326 }
327 if let Some(s) = v.as_str() {
328 return s.to_string();
329 }
330 if v.is_null() {
331 return String::new();
332 }
333 v.to_string()
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct LogEntry {
340 #[serde(default)]
341 pub session_id: String,
342 #[serde(default)]
343 pub message_id: u64,
344 #[serde(default, rename = "type")]
345 pub entry_type: String,
346 #[serde(default)]
347 pub message: String,
348 #[serde(default)]
349 pub timestamp: String,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct ConversationMetadata {
355 pub session_uuid: String,
357 pub project_path: String,
358 pub file_path: PathBuf,
359 pub message_count: usize,
360 pub started_at: Option<DateTime<Utc>>,
361 pub last_activity: Option<DateTime<Utc>>,
362 pub sub_agent_count: usize,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub first_user_message: Option<String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
373pub struct Conversation {
374 pub session_uuid: String,
376 pub project_path: Option<String>,
377 pub main: ChatFile,
380 pub sub_agents: Vec<ChatFile>,
382 pub started_at: Option<DateTime<Utc>>,
383 pub last_activity: Option<DateTime<Utc>>,
384}
385
386impl Conversation {
387 pub fn new(session_uuid: String, main: ChatFile) -> Self {
388 let started_at = main.start_time;
389 let last_activity = main.last_updated;
390 Self {
391 session_uuid,
392 project_path: None,
393 main,
394 sub_agents: Vec::new(),
395 started_at,
396 last_activity,
397 }
398 }
399
400 pub fn messages(&self) -> &[GeminiMessage] {
402 &self.main.messages
403 }
404
405 pub fn total_message_count(&self) -> usize {
407 self.main.messages.len()
408 + self
409 .sub_agents
410 .iter()
411 .map(|s| s.messages.len())
412 .sum::<usize>()
413 }
414
415 pub fn first_user_text(&self) -> Option<String> {
417 self.main.messages.iter().find_map(|m| {
418 if m.role == GeminiRole::User {
419 let t = m.content.text();
420 if t.is_empty() { None } else { Some(t) }
421 } else {
422 None
423 }
424 })
425 }
426
427 pub fn title(&self, max_len: usize) -> Option<String> {
429 self.first_user_text().map(|t| {
430 if t.chars().count() > max_len {
431 let trunc: String = t.chars().take(max_len).collect();
432 format!("{}...", trunc)
433 } else {
434 t
435 }
436 })
437 }
438
439 pub fn all_messages(&self) -> impl Iterator<Item = &GeminiMessage> {
441 self.main
442 .messages
443 .iter()
444 .chain(self.sub_agents.iter().flat_map(|s| s.messages.iter()))
445 }
446
447 pub fn sub_agent_by_session_id(&self, session_id: &str) -> Option<&ChatFile> {
449 self.sub_agents.iter().find(|s| s.session_id == session_id)
450 }
451}
452
453#[cfg(test)]
456mod tests {
457 use super::*;
458
459 const MIN_CHAT: &str = r#"{
460 "sessionId": "abc",
461 "projectHash": "deadbeef",
462 "startTime": "2026-04-17T15:23:55.515Z",
463 "lastUpdated": "2026-04-17T15:27:05.630Z",
464 "messages": [
465 {"id":"m1","timestamp":"2026-04-17T15:23:55.515Z","type":"user","content":[{"text":"Hello"}]},
466 {"id":"m2","timestamp":"2026-04-17T15:23:57.196Z","type":"gemini","content":"Hi","model":"gemini-3-flash-preview","tokens":{"input":10,"output":2,"cached":0,"thoughts":0,"tool":0,"total":12}}
467 ]
468}"#;
469
470 #[test]
471 fn test_parse_minimal_chat() {
472 let chat: ChatFile = serde_json::from_str(MIN_CHAT).unwrap();
473 assert_eq!(chat.session_id, "abc");
474 assert_eq!(chat.project_hash, "deadbeef");
475 assert_eq!(chat.messages.len(), 2);
476 assert_eq!(chat.messages[0].role, GeminiRole::User);
477 assert_eq!(chat.messages[1].role, GeminiRole::Gemini);
478 assert_eq!(chat.messages[1].content.text(), "Hi");
479 assert_eq!(
480 chat.messages[1].model.as_deref(),
481 Some("gemini-3-flash-preview")
482 );
483 assert_eq!(chat.messages[1].tokens.as_ref().unwrap().input, Some(10));
484 }
485
486 #[test]
487 fn test_content_text_from_parts() {
488 let c = GeminiContent::Parts(vec![
489 TextPart {
490 text: Some("a".into()),
491 extra: Default::default(),
492 },
493 TextPart {
494 text: None,
495 extra: Default::default(),
496 },
497 TextPart {
498 text: Some("b".into()),
499 extra: Default::default(),
500 },
501 ]);
502 assert_eq!(c.text(), "a\nb");
503 }
504
505 #[test]
506 fn test_content_text_empty_string() {
507 let c = GeminiContent::Text(String::new());
508 assert!(c.is_empty());
509 assert_eq!(c.text(), "");
510 }
511
512 #[test]
513 fn test_parse_tool_call_with_result() {
514 let json = r#"{
515 "id":"t1","name":"read_file","args":{"path":"x"},
516 "status":"success","timestamp":"2026-04-17T15:23:57Z",
517 "result":[{"functionResponse":{"id":"t1","name":"read_file","response":{"output":"hello"}}}]
518}"#;
519 let tc: ToolCall = serde_json::from_str(json).unwrap();
520 assert_eq!(tc.name, "read_file");
521 assert_eq!(tc.status, "success");
522 assert!(!tc.is_error());
523 assert_eq!(tc.result_text(), "hello");
524 }
525
526 #[test]
527 fn test_parse_tool_call_error() {
528 let json = r#"{"id":"t1","name":"run_shell_command","args":{},"status":"error","timestamp":"2026-04-17T15:23:57Z"}"#;
529 let tc: ToolCall = serde_json::from_str(json).unwrap();
530 assert!(tc.is_error());
531 }
532
533 #[test]
534 fn test_parse_subagent_file() {
535 let json = r#"{
536 "sessionId":"qclszz",
537 "projectHash":"d",
538 "kind":"subagent",
539 "summary":"found the bug",
540 "messages":[]
541}"#;
542 let chat: ChatFile = serde_json::from_str(json).unwrap();
543 assert_eq!(chat.kind.as_deref(), Some("subagent"));
544 assert_eq!(chat.summary.as_deref(), Some("found the bug"));
545 }
546
547 #[test]
548 fn test_conversation_helpers() {
549 let main: ChatFile = serde_json::from_str(MIN_CHAT).unwrap();
550 let convo = Conversation::new("session-uuid".to_string(), main);
551 assert_eq!(convo.total_message_count(), 2);
552 assert_eq!(convo.first_user_text().as_deref(), Some("Hello"));
553 assert_eq!(convo.title(3).as_deref(), Some("Hel..."));
554 assert_eq!(convo.title(100).as_deref(), Some("Hello"));
555 }
556
557 #[test]
558 fn test_thoughts_optional() {
559 let json = r#"{"id":"m","timestamp":"t","type":"gemini","content":"","thoughts":[{"subject":"s","description":"d","timestamp":"t"}]}"#;
560 let msg: GeminiMessage = serde_json::from_str(json).unwrap();
561 assert_eq!(msg.thoughts().len(), 1);
562 assert_eq!(msg.thoughts()[0].subject.as_deref(), Some("s"));
563 }
564
565 #[test]
566 fn test_log_entry() {
567 let json =
568 r#"{"sessionId":"s","messageId":0,"type":"user","message":"hi","timestamp":"t"}"#;
569 let e: LogEntry = serde_json::from_str(json).unwrap();
570 assert_eq!(e.session_id, "s");
571 assert_eq!(e.message, "hi");
572 }
573
574 #[test]
575 fn test_function_response_nested() {
576 let json = r#"[{"functionResponse":{"id":"t","name":"x","response":"plain"}}]"#;
577 let responses: Vec<FunctionResponse> = serde_json::from_str(json).unwrap();
578 assert_eq!(responses.len(), 1);
579 assert_eq!(responses[0].function_response.name, "x");
580 }
581
582 #[test]
583 fn test_response_to_text_fallback() {
584 assert_eq!(response_to_text(&serde_json::json!("hello")), "hello");
585 assert_eq!(response_to_text(&serde_json::json!(null)), "");
586 assert_eq!(response_to_text(&serde_json::json!({"output":"ok"})), "ok");
587 assert_eq!(response_to_text(&serde_json::json!(42)), "42");
589 }
590
591 #[test]
594 fn test_accessors_return_empty_slice_when_absent() {
595 let msg: GeminiMessage =
596 serde_json::from_str(r#"{"id":"m","timestamp":"ts","type":"user","content":""}"#)
597 .unwrap();
598 assert!(msg.thoughts.is_none());
599 assert!(msg.tool_calls.is_none());
600 assert!(msg.thoughts().is_empty());
601 assert!(msg.tool_calls().is_empty());
602
603 let chat: ChatFile =
604 serde_json::from_str(r#"{"sessionId":"s","projectHash":"","messages":[]}"#).unwrap();
605 assert!(chat.directories.is_none());
606 assert!(chat.directories().is_empty());
607 }
608
609 #[test]
610 fn test_accessors_return_slice_when_empty_array() {
611 let msg: GeminiMessage = serde_json::from_str(
612 r#"{"id":"m","timestamp":"ts","type":"user","content":"","thoughts":[],"toolCalls":[]}"#,
613 )
614 .unwrap();
615 assert!(msg.thoughts.is_some());
616 assert!(msg.tool_calls.is_some());
617 assert!(msg.thoughts().is_empty());
618 assert!(msg.tool_calls().is_empty());
619 }
620
621 #[test]
624 fn test_gemini_role_all_variants_roundtrip() {
625 for (json, role) in [
626 (r#""user""#, GeminiRole::User),
627 (r#""gemini""#, GeminiRole::Gemini),
628 (r#""info""#, GeminiRole::Info),
629 ] {
630 let parsed: GeminiRole = serde_json::from_str(json).unwrap();
631 assert_eq!(parsed, role);
632 let back = serde_json::to_string(&role).unwrap();
633 assert_eq!(back, json);
634 }
635 let parsed: GeminiRole = serde_json::from_str(r#""plan""#).unwrap();
637 assert_eq!(parsed, GeminiRole::Other("plan".to_string()));
638 assert_eq!(parsed.as_str(), "plan");
639 let back = serde_json::to_string(&parsed).unwrap();
640 assert_eq!(back, r#""plan""#);
641 }
642
643 #[test]
644 fn test_gemini_role_as_str() {
645 assert_eq!(GeminiRole::User.as_str(), "user");
646 assert_eq!(GeminiRole::Gemini.as_str(), "gemini");
647 assert_eq!(GeminiRole::Info.as_str(), "info");
648 assert_eq!(GeminiRole::Other("x".into()).as_str(), "x");
649 }
650
651 #[test]
654 fn test_tool_call_file_diff_from_dict() {
655 let tc: ToolCall = serde_json::from_str(
656 r#"{"id":"t","name":"write_file","args":{},"status":"success","timestamp":"ts","resultDisplay":{"fileDiff":"@@ -0,0 +1 @@\n+x"}}"#,
657 )
658 .unwrap();
659 assert_eq!(tc.file_diff().as_deref(), Some("@@ -0,0 +1 @@\n+x"));
660 }
661
662 #[test]
663 fn test_tool_call_file_diff_absent_when_no_result_display() {
664 let tc: ToolCall = serde_json::from_str(
665 r#"{"id":"t","name":"write_file","args":{},"status":"success","timestamp":"ts"}"#,
666 )
667 .unwrap();
668 assert!(tc.file_diff().is_none());
669 }
670
671 #[test]
672 fn test_tool_call_file_diff_absent_when_plain_string() {
673 let tc: ToolCall = serde_json::from_str(
674 r#"{"id":"t","name":"run_shell_command","args":{},"status":"success","timestamp":"ts","resultDisplay":"output text"}"#,
675 )
676 .unwrap();
677 assert!(tc.file_diff().is_none());
678 assert_eq!(tc.result_display_text().as_deref(), Some("output text"));
679 }
680
681 #[test]
682 fn test_tool_call_result_display_text_absent_when_dict() {
683 let tc: ToolCall = serde_json::from_str(
684 r#"{"id":"t","name":"write_file","args":{},"status":"success","timestamp":"ts","resultDisplay":{"fileDiff":"x"}}"#,
685 )
686 .unwrap();
687 assert!(tc.result_display_text().is_none());
688 }
689
690 #[test]
693 fn test_sub_agent_by_session_id() {
694 let main: ChatFile =
695 serde_json::from_str(r#"{"sessionId":"main","projectHash":"","messages":[]}"#).unwrap();
696 let mut convo = Conversation::new("uuid".into(), main);
697 let sub: ChatFile = serde_json::from_str(
698 r#"{"sessionId":"helper","projectHash":"","kind":"subagent","messages":[]}"#,
699 )
700 .unwrap();
701 convo.sub_agents.push(sub);
702 assert!(convo.sub_agent_by_session_id("helper").is_some());
703 assert!(convo.sub_agent_by_session_id("nope").is_none());
704 }
705
706 #[test]
707 fn test_conversation_all_messages_covers_main_and_subs() {
708 let main: ChatFile = serde_json::from_str(
709 r#"{"sessionId":"m","projectHash":"","messages":[
710 {"id":"u","timestamp":"ts","type":"user","content":"hi"}
711]}"#,
712 )
713 .unwrap();
714 let mut convo = Conversation::new("u".into(), main);
715 let sub: ChatFile = serde_json::from_str(
716 r#"{"sessionId":"s","projectHash":"","kind":"subagent","messages":[
717 {"id":"s1","timestamp":"ts","type":"user","content":"sub"}
718]}"#,
719 )
720 .unwrap();
721 convo.sub_agents.push(sub);
722 let all: Vec<&GeminiMessage> = convo.all_messages().collect();
723 assert_eq!(all.len(), 2);
724 }
725
726 #[test]
727 fn test_conversation_messages_accessor() {
728 let main: ChatFile = serde_json::from_str(
729 r#"{"sessionId":"m","projectHash":"","messages":[
730 {"id":"u","timestamp":"ts","type":"user","content":"hi"}
731]}"#,
732 )
733 .unwrap();
734 let convo = Conversation::new("u".into(), main);
735 assert_eq!(convo.messages().len(), 1);
736 }
737
738 #[test]
741 fn test_content_default_is_empty_text() {
742 let c = GeminiContent::default();
743 assert!(matches!(c, GeminiContent::Text(ref s) if s.is_empty()));
744 }
745
746 #[test]
747 fn test_content_parts_is_empty_all_none_texts() {
748 let c = GeminiContent::Parts(vec![TextPart {
749 text: None,
750 extra: Default::default(),
751 }]);
752 assert!(c.is_empty());
753 }
754}