vct_core/models/gemini.rs
1//! Serde models for the Google Gemini CLI chat-log JSONL format.
2//!
3//! The first line of each chat file is a [`GeminiSession`] meta record; the
4//! remaining lines are individual events the parser handles as plain `Value`s,
5//! materializing only assistant turns into [`GeminiMessage`]. The `content`
6//! field is polymorphic and handled by the `deserialize_content` helper.
7
8use serde::{Deserialize, Deserializer, Serialize};
9use serde_json::Value;
10
11/// Top-level session metadata line of a Gemini CLI chat log.
12///
13/// Current Gemini CLI writes chats as JSONL event streams under
14/// `~/.gemini/tmp/<project>/chats/session-*.jsonl`. The very first line is a
15/// pure session-meta record:
16///
17/// ```json
18/// {"sessionId":"...","projectHash":"...","startTime":"...","lastUpdated":"...","kind":"main"}
19/// ```
20///
21/// Subsequent lines are individual assistant / user / info events that the
22/// parser handles one-by-one as plain `Value`s (see
23/// `parse_gemini_events`), so this struct only needs to capture the
24/// identifiers found on that opening meta line. Legacy single-object
25/// exports (`chats/<session>.json` with an inline `messages` array) are no
26/// longer supported — the filesystem filter ignores `.json` entirely.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct GeminiSession {
30 /// Session identifier.
31 pub session_id: String,
32 /// Hash identifying the project the session belongs to.
33 #[serde(default)]
34 pub project_hash: String,
35 /// ISO-8601 session start time.
36 #[serde(default)]
37 pub start_time: String,
38 /// ISO-8601 timestamp of the last update to the session.
39 #[serde(default)]
40 pub last_updated: String,
41 /// Present on JSONL session-meta records (e.g. `"main"`), but not
42 /// required — older CLI builds occasionally omit it.
43 #[serde(default)]
44 pub kind: Option<String>,
45}
46
47/// Single message within a Gemini session
48///
49/// Used both for the legacy `messages[]` entries and for JSONL events whose
50/// `type == "gemini"`. Non-assistant events (`"user"`, `"info"`, `$set`
51/// meta-updates, …) are filtered out by the analyzer before reaching this
52/// type, so fields such as `content` and `model` can stay absent without
53/// breaking deserialisation.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct GeminiMessage {
57 /// Message identifier.
58 #[serde(default)]
59 pub id: String,
60 /// ISO-8601 timestamp string for the message.
61 #[serde(default)]
62 pub timestamp: String,
63 /// Message type discriminator (e.g. `gemini`, `user`, `info`).
64 #[serde(rename = "type", default)]
65 pub message_type: String,
66 /// Flattened message text (string or joined `{text}` array blocks).
67 #[serde(default, deserialize_with = "deserialize_content")]
68 pub content: String,
69 /// Reasoning steps recorded for the turn.
70 #[serde(default)]
71 pub thoughts: Vec<GeminiThought>,
72 /// Token-usage breakdown for the turn, when reported.
73 pub tokens: Option<GeminiTokens>,
74 /// Model that produced the turn, when reported.
75 pub model: Option<String>,
76 /// Raw tool-call entries for the turn.
77 #[serde(default)]
78 pub tool_calls: Vec<Value>,
79}
80
81/// Flattens a polymorphic message `content` field into a single `String`.
82///
83/// Content may be a string, an array of `{text: "..."}` objects, null, or
84/// missing entirely. Arrays are joined with newlines, `null` becomes an empty
85/// string, and any other JSON value is stringified.
86///
87/// # Errors
88///
89/// Returns a deserialization error if the underlying tokens are not valid JSON
90/// for [`Value`].
91fn deserialize_content<'de, D>(deserializer: D) -> Result<String, D::Error>
92where
93 D: Deserializer<'de>,
94{
95 let value = Value::deserialize(deserializer)?;
96 match value {
97 Value::String(s) => Ok(s),
98 Value::Array(arr) => {
99 let texts: Vec<&str> = arr
100 .iter()
101 .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
102 .collect();
103 Ok(texts.join("\n"))
104 }
105 Value::Null => Ok(String::new()),
106 _ => Ok(value.to_string()),
107 }
108}
109
110/// A single reasoning step captured during Gemini's thought process.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct GeminiThought {
113 /// Short subject line for the reasoning step.
114 #[serde(default)]
115 pub subject: String,
116 /// Detailed description of the reasoning step.
117 #[serde(default)]
118 pub description: String,
119 /// ISO-8601 timestamp string for the reasoning step.
120 #[serde(default)]
121 pub timestamp: String,
122}
123
124/// Token-usage breakdown for a single Gemini message.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct GeminiTokens {
127 /// Input (prompt) tokens.
128 #[serde(default)]
129 pub input: i64,
130 /// Output (response) tokens.
131 #[serde(default)]
132 pub output: i64,
133 /// Tokens served from cache.
134 #[serde(default)]
135 pub cached: i64,
136 /// Tokens spent on reasoning / thoughts.
137 #[serde(default)]
138 pub thoughts: i64,
139 /// Tokens attributed to tool use.
140 #[serde(default)]
141 pub tool: i64,
142 /// Total token count for the message.
143 #[serde(default)]
144 pub total: i64,
145}