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#[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 pub fn list_conversations(&self, project_path: &str) -> Result<Vec<String>> {
98 self.io.list_sessions(project_path)
99 }
100
101 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 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 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 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 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 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 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}