lore_cli/capture/watchers/
gemini.rs

1//! Gemini CLI session parser.
2//!
3//! Parses session files from Google's Gemini CLI tool. Sessions are stored as
4//! single JSON files at `~/.gemini/tmp/<project-hash>/chats/session-*.json`.
5//!
6//! Each file contains a JSON object with:
7//! - `sessionId`: Unique session identifier
8//! - `projectHash`: Hash of the project directory
9//! - `startTime`: ISO 8601 timestamp
10//! - `lastUpdated`: ISO 8601 timestamp
11//! - `messages`: Array of message objects with id, timestamp, type, and content
12
13use 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
24/// Watcher for Gemini CLI sessions.
25///
26/// Discovers and parses JSON session files from the Gemini CLI tool.
27/// Sessions are stored in `~/.gemini/tmp/<project-hash>/chats/session-*.json`.
28pub 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
61/// Returns the path to the Gemini base directory.
62///
63/// This is typically `~/.gemini/tmp/`.
64fn gemini_base_dir() -> PathBuf {
65    dirs::home_dir()
66        .unwrap_or_else(|| PathBuf::from("."))
67        .join(".gemini")
68        .join("tmp")
69}
70
71/// Raw session structure from Gemini JSON files.
72#[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/// Raw message structure from Gemini JSON files.
87#[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    // Optional fields we currently ignore but may use later
99    #[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
107/// Parses a Gemini JSON session file.
108///
109/// Reads the JSON file and extracts session metadata and messages.
110///
111/// # Errors
112///
113/// Returns an error if the file cannot be opened or parsed.
114pub 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/// Intermediate representation of a parsed Gemini session.
177#[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    /// Converts this parsed session to storage-ready models.
189    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        // Try to derive working directory from project hash in source path
202        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/// Intermediate representation of a parsed Gemini message.
252#[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
260/// Discovers all Gemini session files.
261///
262/// Scans `~/.gemini/tmp/*/chats/` for `session-*.json` files.
263pub 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    // Walk the directory tree: tmp/<project-hash>/chats/session-*.json
273    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    /// Creates a temporary JSON file with given content.
307    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    /// Generate a simple Gemini session JSON.
316    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    // Note: Common watcher trait tests (info, watch_paths, find_sources) are in
329    // src/capture/watchers/test_common.rs to avoid duplication across all watchers.
330    // Only tool-specific parsing tests remain here.
331
332    #[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        // Should still have a valid UUID (newly generated)
566        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        // Should parse messages despite having extra fields
592        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        // Working directory should default to "."
620        assert_eq!(session.working_directory, ".");
621    }
622}