Skip to main content

normalize_chat_sessions/formats/
gemini_cli.rs

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