Skip to main content

toolpath_gemini/
lib.rs

1#![doc = include_str!("../README.md")]
2
3pub mod derive;
4pub mod error;
5pub mod io;
6pub mod paths;
7pub mod project;
8pub mod provider;
9pub mod query;
10pub mod reader;
11pub mod types;
12
13#[cfg(feature = "watcher")]
14pub mod watcher;
15
16pub use error::{ConvoError, Result};
17pub use io::ConvoIO;
18pub use paths::PathResolver;
19pub use query::ConversationQuery;
20pub use reader::ConversationReader;
21pub use types::{
22    ChatFile, Conversation, ConversationMetadata, FunctionResponse, FunctionResponseBody,
23    GeminiContent, GeminiMessage, GeminiRole, LogEntry, TextPart, Thought, Tokens, ToolCall,
24};
25
26#[cfg(feature = "watcher")]
27pub use watcher::ConversationWatcher;
28
29/// High-level entry point for reading Gemini CLI conversations.
30///
31/// `GeminiConvo` is chain-unaware by design — Gemini doesn't rotate
32/// files. Instead, a "conversation" is a session UUID directory: the
33/// main chat file plus every sibling sub-agent chat file.
34///
35/// # Example
36///
37/// ```rust,no_run
38/// use toolpath_gemini::GeminiConvo;
39///
40/// let manager = GeminiConvo::new();
41/// let projects = manager.list_projects()?;
42/// let convo = manager.read_conversation(
43///     "/Users/alex/project",
44///     "session-uuid",
45/// )?;
46/// println!("{} messages", convo.total_message_count());
47/// # Ok::<(), toolpath_gemini::ConvoError>(())
48/// ```
49#[derive(Debug, Clone)]
50pub struct GeminiConvo {
51    io: ConvoIO,
52}
53
54impl Default for GeminiConvo {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl GeminiConvo {
61    pub fn new() -> Self {
62        Self { io: ConvoIO::new() }
63    }
64
65    pub fn with_resolver(resolver: PathResolver) -> Self {
66        Self {
67            io: ConvoIO::with_resolver(resolver),
68        }
69    }
70
71    pub fn io(&self) -> &ConvoIO {
72        &self.io
73    }
74
75    pub fn resolver(&self) -> &PathResolver {
76        self.io.resolver()
77    }
78
79    pub fn exists(&self) -> bool {
80        self.io.exists()
81    }
82
83    pub fn gemini_dir_path(&self) -> Result<std::path::PathBuf> {
84        self.io.gemini_dir_path()
85    }
86
87    pub fn list_projects(&self) -> Result<Vec<String>> {
88        self.io.list_projects()
89    }
90
91    pub fn project_exists(&self, project_path: &str) -> bool {
92        self.io.project_exists(project_path)
93    }
94
95    /// List session UUIDs for a project (each corresponds to one
96    /// `chats/<uuid>/` directory).
97    pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
98        self.io.list_sessions(project_path)
99    }
100
101    /// Metadata for every session in a project, sorted newest first.
102    pub fn list_conversation_metadata(
103        &self,
104        project_path: &str,
105    ) -> Result<Vec<ConversationMetadata>> {
106        self.io.list_session_metadata(project_path)
107    }
108
109    /// List chat-file stems for a given session UUID.
110    pub fn list_chat_files(&self, project_path: &str, session_uuid: &str) -> Result<Vec<String>> {
111        self.io.list_chat_files(project_path, session_uuid)
112    }
113
114    /// Read a full conversation — the main chat plus every sibling
115    /// sub-agent chat file.
116    pub fn read_conversation(
117        &self,
118        project_path: &str,
119        session_uuid: &str,
120    ) -> Result<Conversation> {
121        self.io.read_session(project_path, session_uuid)
122    }
123
124    /// Read a single chat file without pulling in siblings.
125    pub fn read_chat_file(
126        &self,
127        project_path: &str,
128        session_uuid: &str,
129        chat_name: &str,
130    ) -> Result<ChatFile> {
131        self.io.read_chat(project_path, session_uuid, chat_name)
132    }
133
134    pub fn read_conversation_metadata(
135        &self,
136        project_path: &str,
137        session_uuid: &str,
138    ) -> Result<ConversationMetadata> {
139        self.io.read_session_metadata(project_path, session_uuid)
140    }
141
142    pub fn conversation_exists(&self, project_path: &str, session_uuid: &str) -> Result<bool> {
143        self.io.session_exists(project_path, session_uuid)
144    }
145
146    /// Read every conversation in a project, sorted by last activity.
147    pub fn read_all_conversations(&self, project_path: &str) -> Result<Vec<Conversation>> {
148        let sessions = self.list_conversations(project_path)?;
149        let mut out = Vec::new();
150        for uuid in sessions {
151            match self.read_conversation(project_path, &uuid) {
152                Ok(c) => out.push(c),
153                Err(e) => eprintln!("Warning: Failed to read conversation {}: {}", uuid, e),
154            }
155        }
156        out.sort_by_key(|c| std::cmp::Reverse(c.last_activity));
157        Ok(out)
158    }
159
160    pub fn most_recent_conversation(&self, project_path: &str) -> Result<Option<Conversation>> {
161        let metas = self.list_conversation_metadata(project_path)?;
162        match metas.first() {
163            Some(m) => Ok(Some(self.read_conversation(project_path, &m.session_uuid)?)),
164            None => Ok(None),
165        }
166    }
167
168    /// Case-insensitive substring search across all conversations in a
169    /// project. Returns conversations that contain a match.
170    pub fn find_conversations_with_text(
171        &self,
172        project_path: &str,
173        search_text: &str,
174    ) -> Result<Vec<Conversation>> {
175        let conversations = self.read_all_conversations(project_path)?;
176        Ok(conversations
177            .into_iter()
178            .filter(|c| {
179                let q = ConversationQuery::new(c);
180                !q.contains_text(search_text).is_empty()
181            })
182            .collect())
183    }
184
185    pub fn query<'a>(&self, conversation: &'a Conversation) -> ConversationQuery<'a> {
186        ConversationQuery::new(conversation)
187    }
188
189    pub fn read_logs(&self, project_path: &str) -> Result<Vec<LogEntry>> {
190        self.io.read_logs(project_path)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use std::fs;
198    use tempfile::TempDir;
199
200    fn setup() -> (TempDir, GeminiConvo) {
201        let temp = TempDir::new().unwrap();
202        let gemini = temp.path().join(".gemini");
203        let project_slot = gemini.join("tmp/myrepo");
204        let session_dir = project_slot.join("chats/session-uuid");
205        fs::create_dir_all(&session_dir).unwrap();
206        fs::write(
207            gemini.join("projects.json"),
208            r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
209        )
210        .unwrap();
211
212        fs::write(
213            session_dir.join("main.json"),
214            r#"{
215  "sessionId":"main-s",
216  "projectHash":"h",
217  "startTime":"2026-04-17T15:00:00Z",
218  "lastUpdated":"2026-04-17T15:10:00Z",
219  "directories":["/abs/myrepo"],
220  "messages":[
221    {"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Hello"}]},
222    {"id":"m2","timestamp":"2026-04-17T15:00:01Z","type":"gemini","content":"Hi","model":"gemini-3-flash-preview"}
223  ]
224}"#,
225        )
226        .unwrap();
227
228        let resolver = PathResolver::new().with_gemini_dir(&gemini);
229        (temp, GeminiConvo::with_resolver(resolver))
230    }
231
232    #[test]
233    fn test_list_projects() {
234        let (_t, mgr) = setup();
235        assert_eq!(
236            mgr.list_projects().unwrap(),
237            vec!["/abs/myrepo".to_string()]
238        );
239    }
240
241    #[test]
242    fn test_list_conversations() {
243        let (_t, mgr) = setup();
244        let sessions = mgr.list_conversations("/abs/myrepo").unwrap();
245        assert_eq!(sessions, vec!["session-uuid".to_string()]);
246    }
247
248    #[test]
249    fn test_read_conversation() {
250        let (_t, mgr) = setup();
251        let c = mgr
252            .read_conversation("/abs/myrepo", "session-uuid")
253            .unwrap();
254        assert_eq!(c.main.messages.len(), 2);
255        assert!(c.sub_agents.is_empty());
256    }
257
258    #[test]
259    fn test_read_conversation_metadata() {
260        let (_t, mgr) = setup();
261        let meta = mgr
262            .read_conversation_metadata("/abs/myrepo", "session-uuid")
263            .unwrap();
264        assert_eq!(meta.message_count, 2);
265        assert_eq!(meta.sub_agent_count, 0);
266    }
267
268    #[test]
269    fn test_most_recent_conversation() {
270        let (_t, mgr) = setup();
271        let c = mgr.most_recent_conversation("/abs/myrepo").unwrap();
272        assert!(c.is_some());
273        assert_eq!(c.unwrap().main.session_id, "main-s");
274    }
275
276    #[test]
277    fn test_most_recent_conversation_empty() {
278        let (_t, mgr) = setup();
279        let c = mgr.most_recent_conversation("/nonexistent").unwrap();
280        assert!(c.is_none());
281    }
282
283    #[test]
284    fn test_read_all_conversations_sorted() {
285        let (t, mgr) = setup();
286        let gemini = t.path().join(".gemini");
287        let second = gemini.join("tmp/myrepo/chats/session-b");
288        fs::create_dir_all(&second).unwrap();
289        fs::write(
290            second.join("main.json"),
291            r#"{"sessionId":"b","projectHash":"","startTime":"2026-04-20T00:00:00Z","lastUpdated":"2026-04-20T00:00:00Z","messages":[]}"#,
292        )
293        .unwrap();
294        let all = mgr.read_all_conversations("/abs/myrepo").unwrap();
295        assert_eq!(all.len(), 2);
296        // The b session is newer; should come first
297        assert_eq!(all[0].main.session_id, "b");
298    }
299
300    #[test]
301    fn test_find_conversations_with_text() {
302        let (_t, mgr) = setup();
303        let results = mgr
304            .find_conversations_with_text("/abs/myrepo", "Hello")
305            .unwrap();
306        assert_eq!(results.len(), 1);
307        let none = mgr
308            .find_conversations_with_text("/abs/myrepo", "unrelated xyzzy")
309            .unwrap();
310        assert!(none.is_empty());
311    }
312
313    #[test]
314    fn test_query_helper() {
315        let (_t, mgr) = setup();
316        let c = mgr
317            .read_conversation("/abs/myrepo", "session-uuid")
318            .unwrap();
319        let q = mgr.query(&c);
320        assert_eq!(q.by_role(GeminiRole::User).len(), 1);
321    }
322
323    #[test]
324    fn test_conversation_exists() {
325        let (_t, mgr) = setup();
326        assert!(
327            mgr.conversation_exists("/abs/myrepo", "session-uuid")
328                .unwrap()
329        );
330        assert!(!mgr.conversation_exists("/abs/myrepo", "nope").unwrap());
331    }
332
333    #[test]
334    fn test_gemini_dir_path() {
335        let (t, mgr) = setup();
336        assert_eq!(mgr.gemini_dir_path().unwrap(), t.path().join(".gemini"));
337    }
338
339    #[test]
340    fn test_list_chat_files() {
341        let (_t, mgr) = setup();
342        let files = mgr.list_chat_files("/abs/myrepo", "session-uuid").unwrap();
343        assert_eq!(files, vec!["main".to_string()]);
344    }
345
346    #[test]
347    fn test_default() {
348        let _mgr = GeminiConvo::default();
349    }
350
351    #[test]
352    fn test_project_exists() {
353        let (_t, mgr) = setup();
354        assert!(mgr.project_exists("/abs/myrepo"));
355        assert!(!mgr.project_exists("/never"));
356    }
357}