1use anyhow::{Context, Result};
14use chrono::{DateTime, Utc};
15use serde::Deserialize;
16use std::fs;
17use std::path::{Path, PathBuf};
18use uuid::Uuid;
19
20use crate::storage::models::{Message, MessageContent, MessageRole, Session};
21
22use super::{Watcher, WatcherInfo};
23
24pub struct GeminiWatcher;
29
30impl Watcher for GeminiWatcher {
31 fn info(&self) -> WatcherInfo {
32 WatcherInfo {
33 name: "gemini",
34 description: "Google Gemini CLI",
35 default_paths: vec![gemini_base_dir()],
36 }
37 }
38
39 fn is_available(&self) -> bool {
40 gemini_base_dir().exists()
41 }
42
43 fn find_sources(&self) -> Result<Vec<PathBuf>> {
44 find_gemini_session_files()
45 }
46
47 fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
48 let parsed = parse_gemini_session_file(path)?;
49 if parsed.messages.is_empty() {
50 return Ok(vec![]);
51 }
52 let (session, messages) = parsed.to_storage_models();
53 Ok(vec![(session, messages)])
54 }
55
56 fn watch_paths(&self) -> Vec<PathBuf> {
57 vec![gemini_base_dir()]
58 }
59}
60
61fn gemini_base_dir() -> PathBuf {
65 dirs::home_dir()
66 .unwrap_or_else(|| PathBuf::from("."))
67 .join(".gemini")
68 .join("tmp")
69}
70
71#[derive(Debug, Deserialize)]
73#[serde(rename_all = "camelCase")]
74struct RawGeminiSession {
75 session_id: String,
76 #[serde(default)]
77 project_hash: Option<String>,
78 #[serde(default)]
79 start_time: Option<String>,
80 #[serde(default)]
81 last_updated: Option<String>,
82 #[serde(default)]
83 messages: Vec<RawGeminiMessage>,
84}
85
86#[derive(Debug, Deserialize)]
88#[serde(rename_all = "camelCase")]
89struct RawGeminiMessage {
90 #[serde(default)]
91 id: Option<String>,
92 #[serde(default)]
93 timestamp: Option<String>,
94 #[serde(rename = "type")]
95 msg_type: String,
96 #[serde(default)]
97 content: Option<String>,
98 #[serde(default)]
100 #[allow(dead_code)]
101 tool_calls: Option<serde_json::Value>,
102 #[serde(default)]
103 #[allow(dead_code)]
104 thoughts: Option<serde_json::Value>,
105}
106
107pub fn parse_gemini_session_file(path: &Path) -> Result<ParsedGeminiSession> {
115 let content = fs::read_to_string(path).context("Failed to read Gemini session file")?;
116 let raw: RawGeminiSession =
117 serde_json::from_str(&content).context("Failed to parse Gemini session JSON")?;
118
119 let start_time = raw
120 .start_time
121 .as_ref()
122 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
123 .map(|dt| dt.with_timezone(&Utc));
124
125 let last_updated = raw
126 .last_updated
127 .as_ref()
128 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
129 .map(|dt| dt.with_timezone(&Utc));
130
131 let messages: Vec<ParsedGeminiMessage> = raw
132 .messages
133 .iter()
134 .filter_map(|m| {
135 let role = match m.msg_type.as_str() {
136 "user" => MessageRole::User,
137 "gemini" => MessageRole::Assistant,
138 "system" => MessageRole::System,
139 _ => return None,
140 };
141
142 let content = m.content.as_ref()?.clone();
143 if content.trim().is_empty() {
144 return None;
145 }
146
147 let timestamp = m
148 .timestamp
149 .as_ref()
150 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
151 .map(|dt| dt.with_timezone(&Utc))
152 .or(start_time)
153 .unwrap_or_else(Utc::now);
154
155 let id = m.id.clone();
156
157 Some(ParsedGeminiMessage {
158 id,
159 timestamp,
160 role,
161 content,
162 })
163 })
164 .collect();
165
166 Ok(ParsedGeminiSession {
167 session_id: raw.session_id,
168 project_hash: raw.project_hash,
169 start_time,
170 last_updated,
171 messages,
172 source_path: path.to_string_lossy().to_string(),
173 })
174}
175
176#[derive(Debug)]
178pub struct ParsedGeminiSession {
179 pub session_id: String,
180 pub project_hash: Option<String>,
181 pub start_time: Option<DateTime<Utc>>,
182 pub last_updated: Option<DateTime<Utc>>,
183 pub messages: Vec<ParsedGeminiMessage>,
184 pub source_path: String,
185}
186
187impl ParsedGeminiSession {
188 pub fn to_storage_models(&self) -> (Session, Vec<Message>) {
190 let session_uuid = Uuid::parse_str(&self.session_id).unwrap_or_else(|_| Uuid::new_v4());
191
192 let started_at = self
193 .start_time
194 .or_else(|| self.messages.first().map(|m| m.timestamp))
195 .unwrap_or_else(Utc::now);
196
197 let ended_at = self
198 .last_updated
199 .or_else(|| self.messages.last().map(|m| m.timestamp));
200
201 let working_directory = self
203 .project_hash
204 .as_ref()
205 .map(|h| format!("<project:{h}>"))
206 .unwrap_or_else(|| ".".to_string());
207
208 let session = Session {
209 id: session_uuid,
210 tool: "gemini".to_string(),
211 tool_version: None,
212 started_at,
213 ended_at,
214 model: None,
215 working_directory,
216 git_branch: None,
217 source_path: Some(self.source_path.clone()),
218 message_count: self.messages.len() as i32,
219 machine_id: crate::storage::get_machine_id(),
220 };
221
222 let messages: Vec<Message> = self
223 .messages
224 .iter()
225 .enumerate()
226 .map(|(idx, m)| {
227 let id =
228 m.id.as_ref()
229 .and_then(|s| Uuid::parse_str(s).ok())
230 .unwrap_or_else(Uuid::new_v4);
231
232 Message {
233 id,
234 session_id: session_uuid,
235 parent_id: None,
236 index: idx as i32,
237 timestamp: m.timestamp,
238 role: m.role.clone(),
239 content: MessageContent::Text(m.content.clone()),
240 model: None,
241 git_branch: None,
242 cwd: None,
243 }
244 })
245 .collect();
246
247 (session, messages)
248 }
249}
250
251#[derive(Debug)]
253pub struct ParsedGeminiMessage {
254 pub id: Option<String>,
255 pub timestamp: DateTime<Utc>,
256 pub role: MessageRole,
257 pub content: String,
258}
259
260pub fn find_gemini_session_files() -> Result<Vec<PathBuf>> {
264 let base_dir = gemini_base_dir();
265
266 if !base_dir.exists() {
267 return Ok(Vec::new());
268 }
269
270 let mut files = Vec::new();
271
272 for project_entry in std::fs::read_dir(&base_dir)? {
274 let project_entry = project_entry?;
275 let project_path = project_entry.path();
276 if !project_path.is_dir() {
277 continue;
278 }
279
280 let chats_dir = project_path.join("chats");
281 if !chats_dir.exists() || !chats_dir.is_dir() {
282 continue;
283 }
284
285 for file_entry in std::fs::read_dir(&chats_dir)? {
286 let file_entry = file_entry?;
287 let file_path = file_entry.path();
288
289 if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
290 if name.starts_with("session-") && name.ends_with(".json") {
291 files.push(file_path);
292 }
293 }
294 }
295 }
296
297 Ok(files)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::io::Write;
304 use tempfile::NamedTempFile;
305
306 fn create_temp_session_file(content: &str) -> NamedTempFile {
308 let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
309 file.write_all(content.as_bytes())
310 .expect("Failed to write content");
311 file.flush().expect("Failed to flush");
312 file
313 }
314
315 fn make_session_json(session_id: &str, project_hash: &str, messages_json: &str) -> String {
317 format!(
318 r#"{{
319 "sessionId": "{session_id}",
320 "projectHash": "{project_hash}",
321 "startTime": "2025-11-30T20:06:04.951Z",
322 "lastUpdated": "2025-11-30T20:15:26.585Z",
323 "messages": {messages_json}
324 }}"#
325 )
326 }
327
328 #[test]
333 fn test_parse_simple_session() {
334 let json = make_session_json(
335 "ed60a4d9-1234-5678-abcd-ef0123456789",
336 "cc89a35",
337 r#"[
338 {"id": "msg1", "timestamp": "2025-11-30T20:06:05.000Z", "type": "user", "content": "Hello"},
339 {"id": "msg2", "timestamp": "2025-11-30T20:06:10.000Z", "type": "gemini", "content": "Hi there!"}
340 ]"#,
341 );
342
343 let file = create_temp_session_file(&json);
344 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
345
346 assert_eq!(parsed.session_id, "ed60a4d9-1234-5678-abcd-ef0123456789");
347 assert_eq!(parsed.project_hash, Some("cc89a35".to_string()));
348 assert_eq!(parsed.messages.len(), 2);
349 assert_eq!(parsed.messages[0].role, MessageRole::User);
350 assert_eq!(parsed.messages[0].content, "Hello");
351 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
352 assert_eq!(parsed.messages[1].content, "Hi there!");
353 }
354
355 #[test]
356 fn test_parse_user_message() {
357 let json = make_session_json(
358 "test-session",
359 "hash123",
360 r#"[{"type": "user", "content": "What is Rust?"}]"#,
361 );
362
363 let file = create_temp_session_file(&json);
364 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
365
366 assert_eq!(parsed.messages.len(), 1);
367 assert_eq!(parsed.messages[0].role, MessageRole::User);
368 assert_eq!(parsed.messages[0].content, "What is Rust?");
369 }
370
371 #[test]
372 fn test_parse_gemini_message_as_assistant() {
373 let json = make_session_json(
374 "test-session",
375 "hash123",
376 r#"[{"type": "gemini", "content": "Rust is a systems programming language."}]"#,
377 );
378
379 let file = create_temp_session_file(&json);
380 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
381
382 assert_eq!(parsed.messages.len(), 1);
383 assert_eq!(parsed.messages[0].role, MessageRole::Assistant);
384 }
385
386 #[test]
387 fn test_parse_system_message() {
388 let json = make_session_json(
389 "test-session",
390 "hash123",
391 r#"[{"type": "system", "content": "You are a helpful assistant."}]"#,
392 );
393
394 let file = create_temp_session_file(&json);
395 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
396
397 assert_eq!(parsed.messages.len(), 1);
398 assert_eq!(parsed.messages[0].role, MessageRole::System);
399 }
400
401 #[test]
402 fn test_unknown_message_type_skipped() {
403 let json = make_session_json(
404 "test-session",
405 "hash123",
406 r#"[
407 {"type": "user", "content": "Hello"},
408 {"type": "unknown", "content": "Should be skipped"},
409 {"type": "gemini", "content": "Hi!"}
410 ]"#,
411 );
412
413 let file = create_temp_session_file(&json);
414 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
415
416 assert_eq!(parsed.messages.len(), 2);
417 assert_eq!(parsed.messages[0].role, MessageRole::User);
418 assert_eq!(parsed.messages[1].role, MessageRole::Assistant);
419 }
420
421 #[test]
422 fn test_empty_content_skipped() {
423 let json = make_session_json(
424 "test-session",
425 "hash123",
426 r#"[
427 {"type": "user", "content": "Hello"},
428 {"type": "gemini", "content": ""},
429 {"type": "gemini", "content": " "},
430 {"type": "user", "content": "Goodbye"}
431 ]"#,
432 );
433
434 let file = create_temp_session_file(&json);
435 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
436
437 assert_eq!(parsed.messages.len(), 2);
438 }
439
440 #[test]
441 fn test_null_content_skipped() {
442 let json = make_session_json(
443 "test-session",
444 "hash123",
445 r#"[
446 {"type": "user", "content": "Hello"},
447 {"type": "gemini"}
448 ]"#,
449 );
450
451 let file = create_temp_session_file(&json);
452 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
453
454 assert_eq!(parsed.messages.len(), 1);
455 }
456
457 #[test]
458 fn test_to_storage_models() {
459 let json = make_session_json(
460 "ed60a4d9-1234-5678-abcd-ef0123456789",
461 "cc89a35",
462 r#"[
463 {"id": "550e8400-e29b-41d4-a716-446655440001", "type": "user", "content": "Hello"},
464 {"type": "gemini", "content": "Hi!"}
465 ]"#,
466 );
467
468 let file = create_temp_session_file(&json);
469 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
470 let (session, messages) = parsed.to_storage_models();
471
472 assert_eq!(session.tool, "gemini");
473 assert_eq!(
474 session.id.to_string(),
475 "ed60a4d9-1234-5678-abcd-ef0123456789"
476 );
477 assert!(session.working_directory.contains("cc89a35"));
478 assert_eq!(session.message_count, 2);
479
480 assert_eq!(messages.len(), 2);
481 assert_eq!(
482 messages[0].id.to_string(),
483 "550e8400-e29b-41d4-a716-446655440001"
484 );
485 assert_eq!(messages[0].role, MessageRole::User);
486 assert_eq!(messages[0].index, 0);
487 assert_eq!(messages[1].role, MessageRole::Assistant);
488 assert_eq!(messages[1].index, 1);
489 }
490
491 #[test]
492 fn test_timestamps_parsed() {
493 let json = make_session_json(
494 "test-session",
495 "hash123",
496 r#"[{"type": "user", "content": "Hello", "timestamp": "2025-11-30T20:06:05.000Z"}]"#,
497 );
498
499 let file = create_temp_session_file(&json);
500 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
501
502 assert!(parsed.start_time.is_some());
503 assert!(parsed.last_updated.is_some());
504 assert!(parsed.messages[0]
505 .timestamp
506 .to_rfc3339()
507 .contains("2025-11-30"));
508 }
509
510 #[test]
511 fn test_empty_messages_array() {
512 let json = make_session_json("test-session", "hash123", "[]");
513
514 let file = create_temp_session_file(&json);
515 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
516
517 assert!(parsed.messages.is_empty());
518 }
519
520 #[test]
521 fn test_watcher_parse_source() {
522 let watcher = GeminiWatcher;
523 let json = make_session_json(
524 "test-session",
525 "hash123",
526 r#"[{"type": "user", "content": "Hello"}]"#,
527 );
528
529 let file = create_temp_session_file(&json);
530 let result = watcher
531 .parse_source(file.path())
532 .expect("Should parse successfully");
533
534 assert_eq!(result.len(), 1);
535 let (session, messages) = &result[0];
536 assert_eq!(session.tool, "gemini");
537 assert_eq!(messages.len(), 1);
538 }
539
540 #[test]
541 fn test_watcher_parse_source_empty_session() {
542 let watcher = GeminiWatcher;
543 let json = make_session_json("test-session", "hash123", "[]");
544
545 let file = create_temp_session_file(&json);
546 let result = watcher
547 .parse_source(file.path())
548 .expect("Should parse successfully");
549
550 assert!(result.is_empty());
551 }
552
553 #[test]
554 fn test_invalid_uuid_generates_new() {
555 let json = make_session_json(
556 "not-a-valid-uuid",
557 "hash123",
558 r#"[{"type": "user", "content": "Hello"}]"#,
559 );
560
561 let file = create_temp_session_file(&json);
562 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
563 let (session, _) = parsed.to_storage_models();
564
565 assert!(!session.id.is_nil());
567 }
568
569 #[test]
570 fn test_messages_with_tool_calls_and_thoughts() {
571 let json = make_session_json(
572 "test-session",
573 "hash123",
574 r#"[
575 {
576 "type": "user",
577 "content": "Run a command",
578 "toolCalls": [{"name": "bash", "args": {"cmd": "ls"}}]
579 },
580 {
581 "type": "gemini",
582 "content": "Here are the files",
583 "thoughts": ["Analyzing directory structure"]
584 }
585 ]"#,
586 );
587
588 let file = create_temp_session_file(&json);
589 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
590
591 assert_eq!(parsed.messages.len(), 2);
593 }
594
595 #[test]
596 fn test_minimal_session() {
597 let json = r#"{"sessionId": "minimal", "messages": []}"#;
598
599 let file = create_temp_session_file(json);
600 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
601
602 assert_eq!(parsed.session_id, "minimal");
603 assert!(parsed.project_hash.is_none());
604 assert!(parsed.messages.is_empty());
605 }
606
607 #[test]
608 fn test_session_with_no_project_hash() {
609 let json = r#"{
610 "sessionId": "test",
611 "startTime": "2025-11-30T20:06:04.951Z",
612 "messages": [{"type": "user", "content": "Hello"}]
613 }"#;
614
615 let file = create_temp_session_file(json);
616 let parsed = parse_gemini_session_file(file.path()).expect("Failed to parse");
617 let (session, _) = parsed.to_storage_models();
618
619 assert_eq!(session.working_directory, ".");
621 }
622}