Skip to main content

normalize_chat_sessions/formats/
gemini_cli.rs

1//! Gemini CLI JSON format parser.
2
3use super::{LogFormat, ParseError, SessionFile, read_file};
4use crate::{ContentBlock, Message, Role, Session, TokenUsage, Turn};
5use serde_json::Value;
6use std::path::{Path, PathBuf};
7
8/// Gemini CLI session log format (JSON with messages array).
9pub struct GeminiCliFormat;
10
11impl LogFormat for GeminiCliFormat {
12    fn name(&self) -> &'static str {
13        "gemini"
14    }
15
16    fn sessions_dir(&self, _project: Option<&Path>) -> PathBuf {
17        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
18        PathBuf::from(home).join(".gemini/tmp")
19    }
20
21    fn list_sessions(&self, project: Option<&Path>) -> Vec<SessionFile> {
22        let dir = self.sessions_dir(project);
23        // Gemini stores sessions in ~/.gemini/tmp/<hash>/logs.json
24        let mut sessions = Vec::new();
25        if let Ok(entries) = std::fs::read_dir(&dir) {
26            for entry in entries.filter_map(|e| e.ok()) {
27                let subdir = entry.path();
28                if !subdir.is_dir() {
29                    continue;
30                }
31                let logs_path = subdir.join("logs.json");
32                if logs_path.exists()
33                    && let Ok(meta) = logs_path.metadata()
34                    && let Ok(mtime) = meta.modified()
35                {
36                    sessions.push(SessionFile {
37                        path: logs_path,
38                        mtime,
39                        parent_id: None,
40                        agent_id: None,
41                        subagent_type: Some("interactive".into()),
42                    });
43                }
44            }
45        }
46        sessions
47    }
48
49    fn detect(&self, path: &Path) -> f64 {
50        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
51        if ext != "json" {
52            return 0.0;
53        }
54
55        // Try to parse as JSON (not JSONL)
56        let Ok(content) = read_file(path) else {
57            return 0.0;
58        };
59
60        let Ok(data) = serde_json::from_str::<Value>(&content) else {
61            return 0.0;
62        };
63
64        // Gemini CLI has sessionId and messages array with type="gemini"
65        if data.get("sessionId").is_some() && data.get("messages").is_some() {
66            if let Some(messages) = data.get("messages").and_then(|m| m.as_array()) {
67                for msg in messages {
68                    if msg.get("type").and_then(|t| t.as_str()) == Some("gemini") {
69                        return 1.0;
70                    }
71                }
72            }
73            return 0.5; // Has structure but no gemini messages yet
74        }
75
76        0.0
77    }
78
79    fn parse(&self, path: &Path) -> Result<Session, ParseError> {
80        let content = read_file(path)?;
81        let data: Value = serde_json::from_str(&content).map_err(|e| ParseError::Format {
82            path: path.to_path_buf(),
83            message: e.to_string(),
84        })?;
85
86        let mut session = Session::new(path.to_path_buf(), self.name());
87        session.subagent_type = Some("interactive".into());
88
89        // Extract metadata
90        session.metadata.session_id = data
91            .get("sessionId")
92            .and_then(|v| v.as_str())
93            .map(String::from);
94        session.metadata.provider = Some("google".to_string());
95
96        let messages = data
97            .get("messages")
98            .and_then(|m| m.as_array())
99            .cloned()
100            .unwrap_or_default();
101
102        let mut current_turn = Turn::default();
103
104        for msg in &messages {
105            let msg_type = msg.get("type").and_then(|t| t.as_str()).unwrap_or("");
106
107            match msg_type {
108                "user" => {
109                    // Flush previous turn
110                    if !current_turn.messages.is_empty() {
111                        session.turns.push(std::mem::take(&mut current_turn));
112                    }
113
114                    let message = parse_user_message(msg);
115                    current_turn.messages.push(message);
116                }
117                "gemini" => {
118                    // Extract model from first gemini message
119                    if session.metadata.model.is_none() {
120                        session.metadata.model =
121                            msg.get("model").and_then(|v| v.as_str()).map(String::from);
122                    }
123
124                    let message = parse_gemini_message(msg);
125                    current_turn.messages.push(message);
126
127                    // Extract token usage
128                    if let Some(tokens) = msg.get("tokens") {
129                        current_turn.token_usage = Some(TokenUsage {
130                            input: tokens.get("input").and_then(|v| v.as_u64()).unwrap_or(0),
131                            output: tokens.get("output").and_then(|v| v.as_u64()).unwrap_or(0),
132                            cache_read: tokens.get("cached").and_then(|v| v.as_u64()),
133                            cache_create: None,
134                            model: msg.get("model").and_then(|v| v.as_str()).map(String::from),
135                        });
136                    }
137                }
138                _ => {}
139            }
140        }
141
142        // Flush final turn
143        if !current_turn.messages.is_empty() {
144            session.turns.push(current_turn);
145        }
146
147        Ok(session)
148    }
149}
150
151/// Parse a user message from Gemini CLI format.
152fn parse_user_message(msg: &Value) -> Message {
153    let mut content = Vec::new();
154
155    if let Some(text) = msg.get("content").and_then(|v| v.as_str()) {
156        content.push(ContentBlock::Text {
157            text: text.to_string(),
158        });
159    }
160
161    Message {
162        role: Role::User,
163        content,
164        timestamp: msg
165            .get("timestamp")
166            .and_then(|v| v.as_str())
167            .map(String::from),
168    }
169}
170
171/// Parse a gemini (assistant) message from Gemini CLI format.
172fn parse_gemini_message(msg: &Value) -> Message {
173    let mut content = Vec::new();
174
175    // Text content
176    if let Some(text) = msg.get("content").and_then(|v| v.as_str()) {
177        content.push(ContentBlock::Text {
178            text: text.to_string(),
179        });
180    }
181
182    // Tool calls
183    if let Some(tool_calls) = msg.get("toolCalls").and_then(|t| t.as_array()) {
184        for tc in tool_calls {
185            let id = tc
186                .get("id")
187                .and_then(|v| v.as_str())
188                .unwrap_or("")
189                .to_string();
190            let name = tc
191                .get("name")
192                .and_then(|v| v.as_str())
193                .unwrap_or("")
194                .to_string();
195            let input = tc.get("args").cloned().unwrap_or(Value::Null);
196
197            content.push(ContentBlock::ToolUse {
198                id: id.clone(),
199                name,
200                input,
201            });
202
203            // Tool result (Gemini includes result in the same message)
204            if let Some(result) = tc.get("result") {
205                let result_content = if let Some(s) = result.as_str() {
206                    s.to_string()
207                } else {
208                    result.to_string()
209                };
210                let is_error = tc.get("status").and_then(|s| s.as_str()) == Some("error");
211                content.push(ContentBlock::ToolResult {
212                    tool_use_id: id,
213                    content: result_content,
214                    is_error,
215                });
216            }
217        }
218    }
219
220    Message {
221        role: Role::Assistant,
222        content,
223        timestamp: msg
224            .get("timestamp")
225            .and_then(|v| v.as_str())
226            .map(String::from),
227    }
228}