1use crate::error::Result;
4use crate::paths::PathResolver;
5use crate::reader::ConversationReader;
6use crate::types::{ChatFile, Conversation, ConversationMetadata, GeminiRole, LogEntry};
7use std::path::PathBuf;
8
9fn first_user_text(chat: &ChatFile) -> Option<String> {
11 chat.messages
12 .iter()
13 .filter(|m| m.role == GeminiRole::User)
14 .find_map(|m| {
15 let text = m.content.text();
16 let trimmed = text.trim();
17 if trimmed.is_empty() {
18 None
19 } else {
20 Some(trimmed.to_string())
21 }
22 })
23}
24
25#[derive(Debug, Clone)]
26pub struct ConvoIO {
27 resolver: PathResolver,
28}
29
30impl Default for ConvoIO {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl ConvoIO {
37 pub fn new() -> Self {
38 Self {
39 resolver: PathResolver::new(),
40 }
41 }
42
43 pub fn with_resolver(resolver: PathResolver) -> Self {
44 Self { resolver }
45 }
46
47 pub fn resolver(&self) -> &PathResolver {
48 &self.resolver
49 }
50
51 pub fn gemini_dir_path(&self) -> Result<PathBuf> {
52 self.resolver.gemini_dir()
53 }
54
55 pub fn exists(&self) -> bool {
56 self.resolver.exists()
57 }
58
59 pub fn list_projects(&self) -> Result<Vec<String>> {
60 self.resolver.list_project_dirs()
61 }
62
63 pub fn list_sessions(&self, project_path: &str) -> Result<Vec<String>> {
64 self.resolver.list_sessions(project_path)
65 }
66
67 pub fn list_chat_files(&self, project_path: &str, session_uuid: &str) -> Result<Vec<String>> {
68 self.resolver.list_chat_files(project_path, session_uuid)
69 }
70
71 pub fn project_exists(&self, project_path: &str) -> bool {
72 self.resolver
73 .project_dir(project_path)
74 .map(|p| p.exists())
75 .unwrap_or(false)
76 }
77
78 pub fn session_exists(&self, project_path: &str, session_id: &str) -> Result<bool> {
79 if self
83 .resolver
84 .resolve_main_file(project_path, session_id)?
85 .is_some()
86 {
87 return Ok(true);
88 }
89 let dir = self.resolver.session_dir(project_path, session_id)?;
90 Ok(dir.exists())
91 }
92
93 pub fn read_chat(
95 &self,
96 project_path: &str,
97 session_uuid: &str,
98 chat_name: &str,
99 ) -> Result<ChatFile> {
100 let path = self
101 .resolver
102 .chat_file(project_path, session_uuid, chat_name)?;
103 ConversationReader::read_chat_file(&path)
104 }
105
106 pub fn read_all_chats(
108 &self,
109 project_path: &str,
110 session_uuid: &str,
111 ) -> Result<Vec<(String, ChatFile)>> {
112 let stems = self.list_chat_files(project_path, session_uuid)?;
113 let mut out = Vec::with_capacity(stems.len());
114 for stem in stems {
115 let chat = self.read_chat(project_path, session_uuid, &stem)?;
116 out.push((stem, chat));
117 }
118 Ok(out)
119 }
120
121 pub fn read_session(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
135 if let Some(main_path) = self.resolver.resolve_main_file(project_path, session_id)? {
138 let main = ConversationReader::read_chat_file(&main_path)?;
139 let uuid = main.session_id.clone();
140 let sub_agents = if !uuid.is_empty() {
141 let uuid_dir = self.resolver.session_dir(project_path, &uuid)?;
142 if uuid_dir.exists() {
143 let stems = self.resolver.list_chat_files(project_path, &uuid)?;
144 let mut subs = Vec::with_capacity(stems.len());
145 for stem in stems {
146 match self.read_chat(project_path, &uuid, &stem) {
147 Ok(c) => subs.push(c),
148 Err(e) => eprintln!(
149 "Warning: failed to read sub-agent {}/{}: {}",
150 uuid, stem, e
151 ),
152 }
153 }
154 subs
155 } else {
156 Vec::new()
157 }
158 } else {
159 Vec::new()
160 };
161
162 let project_root: Option<String> = main
163 .directories()
164 .first()
165 .map(|p| p.to_string_lossy().to_string());
166
167 let mut convo = Conversation::new(session_id.to_string(), main);
168 convo.project_path = project_root;
169 convo.sub_agents = sub_agents;
170 return Ok(convo);
171 }
172
173 let chats = self.read_all_chats(project_path, session_id)?;
175 if chats.is_empty() {
176 return Err(crate::error::ConvoError::ConversationNotFound(format!(
177 "{}/{}",
178 project_path, session_id
179 )));
180 }
181
182 let (main_idx, _) = chats
183 .iter()
184 .enumerate()
185 .find(|(_, (_, c))| c.kind.as_deref() != Some("subagent"))
186 .unwrap_or((0, &chats[0]));
187
188 let mut chats = chats;
189 let (_, main) = chats.remove(main_idx);
190 let sub_agents: Vec<ChatFile> = chats.into_iter().map(|(_, c)| c).collect();
191
192 let project_root: Option<String> = main
193 .directories()
194 .first()
195 .map(|p| p.to_string_lossy().to_string());
196
197 let mut convo = Conversation::new(session_id.to_string(), main);
198 convo.project_path = project_root;
199 convo.sub_agents = sub_agents;
200 Ok(convo)
201 }
202
203 pub fn read_session_metadata(
208 &self,
209 project_path: &str,
210 session_id: &str,
211 ) -> Result<ConversationMetadata> {
212 if let Some(main_path) = self.resolver.resolve_main_file(project_path, session_id)? {
214 let main = ConversationReader::read_chat_file(&main_path)?;
215 let uuid = main.session_id.clone();
216 let mut sub_chats: Vec<ChatFile> = Vec::new();
217 if !uuid.is_empty() {
218 let uuid_dir = self.resolver.session_dir(project_path, &uuid)?;
219 if uuid_dir.exists() {
220 for stem in self.resolver.list_chat_files(project_path, &uuid)? {
221 if let Ok(c) = self.read_chat(project_path, &uuid, &stem) {
222 sub_chats.push(c);
223 }
224 }
225 }
226 }
227 let mut message_count = main.messages.len();
228 for s in &sub_chats {
229 message_count += s.messages.len();
230 }
231 let mut started_at = main.start_time;
232 let mut last_activity = main.last_updated;
233 for s in &sub_chats {
234 if let Some(t) = s.start_time
235 && started_at.map(|x| t < x).unwrap_or(true)
236 {
237 started_at = Some(t);
238 }
239 if let Some(t) = s.last_updated
240 && last_activity.map(|x| t > x).unwrap_or(true)
241 {
242 last_activity = Some(t);
243 }
244 }
245 let sub_agent_count = sub_chats
246 .iter()
247 .filter(|c| c.kind.as_deref() == Some("subagent"))
248 .count();
249 let project_root: String = main
250 .directories()
251 .first()
252 .map(|p| p.to_string_lossy().to_string())
253 .unwrap_or_else(|| project_path.to_string());
254 let first_user_message = first_user_text(&main);
255 return Ok(ConversationMetadata {
256 session_uuid: session_id.to_string(),
257 project_path: project_root,
258 file_path: main_path,
259 message_count,
260 started_at,
261 last_activity,
262 sub_agent_count,
263 first_user_message,
264 });
265 }
266
267 let chats = self.read_all_chats(project_path, session_id)?;
269 let session_dir = self.resolver.session_dir(project_path, session_id)?;
270
271 let main = chats
272 .iter()
273 .find(|(_, c)| c.kind.as_deref() != Some("subagent"))
274 .or_else(|| chats.first())
275 .ok_or_else(|| {
276 crate::error::ConvoError::ConversationNotFound(format!(
277 "{}/{}",
278 project_path, session_id
279 ))
280 })?;
281
282 let message_count: usize = chats.iter().map(|(_, c)| c.messages.len()).sum();
283 let started_at = chats.iter().filter_map(|(_, c)| c.start_time).min();
284 let last_activity = chats.iter().filter_map(|(_, c)| c.last_updated).max();
285 let sub_agent_count = chats
286 .iter()
287 .filter(|(_, c)| c.kind.as_deref() == Some("subagent"))
288 .count();
289
290 let project_root: String = main
291 .1
292 .directories()
293 .first()
294 .map(|p| p.to_string_lossy().to_string())
295 .unwrap_or_else(|| project_path.to_string());
296
297 let first_user_message = first_user_text(&main.1);
298
299 Ok(ConversationMetadata {
300 session_uuid: session_id.to_string(),
301 project_path: project_root,
302 file_path: session_dir,
303 message_count,
304 started_at,
305 last_activity,
306 sub_agent_count,
307 first_user_message,
308 })
309 }
310
311 pub fn list_session_metadata(&self, project_path: &str) -> Result<Vec<ConversationMetadata>> {
312 let sessions = self.list_sessions(project_path)?;
313 let mut out = Vec::new();
314 for uuid in sessions {
315 match self.read_session_metadata(project_path, &uuid) {
316 Ok(meta) => out.push(meta),
317 Err(e) => eprintln!("Warning: Failed to read metadata for {}: {}", uuid, e),
318 }
319 }
320 out.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
321 Ok(out)
322 }
323
324 pub fn read_logs(&self, project_path: &str) -> Result<Vec<LogEntry>> {
325 let path = self.resolver.logs_file(project_path)?;
326 ConversationReader::read_logs(&path)
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use std::fs;
334 use tempfile::TempDir;
335
336 fn setup() -> (TempDir, ConvoIO) {
337 let temp = TempDir::new().unwrap();
338 let gemini = temp.path().join(".gemini");
339 let project_slot = gemini.join("tmp/myrepo");
340 let session_dir = project_slot.join("chats/session-uuid");
341 fs::create_dir_all(&session_dir).unwrap();
342 fs::write(
343 gemini.join("projects.json"),
344 r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
345 )
346 .unwrap();
347 fs::write(project_slot.join(".project_root"), "/abs/myrepo").unwrap();
348
349 let main = r#"{
350 "sessionId":"main-s",
351 "projectHash":"h",
352 "startTime":"2026-04-17T15:00:00Z",
353 "lastUpdated":"2026-04-17T15:10:00Z",
354 "directories":["/abs/myrepo"],
355 "messages":[
356 {"id":"m1","timestamp":"2026-04-17T15:00:00Z","type":"user","content":[{"text":"Fix the bug"}]},
357 {"id":"m2","timestamp":"2026-04-17T15:01:00Z","type":"gemini","content":"Sure.","model":"gemini-3-flash-preview"}
358 ]
359}"#;
360 fs::write(session_dir.join("main.json"), main).unwrap();
361
362 let sub = r#"{
363 "sessionId":"sub-s",
364 "projectHash":"h",
365 "startTime":"2026-04-17T15:05:00Z",
366 "lastUpdated":"2026-04-17T15:08:00Z",
367 "kind":"subagent",
368 "summary":"found it",
369 "messages":[
370 {"id":"s1","timestamp":"2026-04-17T15:05:00Z","type":"user","content":[{"text":"Search"}]}
371 ]
372}"#;
373 fs::write(session_dir.join("sub-s.json"), sub).unwrap();
374
375 let resolver = PathResolver::new().with_gemini_dir(&gemini);
376 (temp, ConvoIO::with_resolver(resolver))
377 }
378
379 #[test]
380 fn test_list_projects() {
381 let (_t, io) = setup();
382 let p = io.list_projects().unwrap();
383 assert_eq!(p, vec!["/abs/myrepo".to_string()]);
384 }
385
386 #[test]
387 fn test_list_sessions() {
388 let (_t, io) = setup();
389 let s = io.list_sessions("/abs/myrepo").unwrap();
390 assert_eq!(s, vec!["session-uuid".to_string()]);
391 }
392
393 #[test]
394 fn test_list_chat_files() {
395 let (_t, io) = setup();
396 let files = io.list_chat_files("/abs/myrepo", "session-uuid").unwrap();
397 assert_eq!(files, vec!["main".to_string(), "sub-s".to_string()]);
398 }
399
400 #[test]
401 fn test_read_session_picks_main() {
402 let (_t, io) = setup();
403 let convo = io.read_session("/abs/myrepo", "session-uuid").unwrap();
404 assert_eq!(convo.main.session_id, "main-s");
405 assert!(convo.main.kind.is_none());
406 assert_eq!(convo.sub_agents.len(), 1);
407 assert_eq!(convo.sub_agents[0].session_id, "sub-s");
408 assert_eq!(convo.sub_agents[0].summary.as_deref(), Some("found it"));
409 assert_eq!(convo.project_path.as_deref(), Some("/abs/myrepo"));
410 }
411
412 #[test]
413 fn test_read_session_metadata() {
414 let (_t, io) = setup();
415 let meta = io
416 .read_session_metadata("/abs/myrepo", "session-uuid")
417 .unwrap();
418 assert_eq!(meta.session_uuid, "session-uuid");
419 assert_eq!(meta.message_count, 3); assert_eq!(meta.sub_agent_count, 1);
421 assert!(meta.started_at.is_some());
422 assert!(meta.last_activity.is_some());
423 }
424
425 #[test]
426 fn test_list_session_metadata() {
427 let (_t, io) = setup();
428 let metas = io.list_session_metadata("/abs/myrepo").unwrap();
429 assert_eq!(metas.len(), 1);
430 assert_eq!(metas[0].session_uuid, "session-uuid");
431 }
432
433 #[test]
434 fn test_read_chat_by_name() {
435 let (_t, io) = setup();
436 let chat = io
437 .read_chat("/abs/myrepo", "session-uuid", "sub-s")
438 .unwrap();
439 assert_eq!(chat.kind.as_deref(), Some("subagent"));
440 }
441
442 #[test]
443 fn test_session_exists() {
444 let (_t, io) = setup();
445 assert!(io.session_exists("/abs/myrepo", "session-uuid").unwrap());
446 assert!(!io.session_exists("/abs/myrepo", "missing").unwrap());
447 }
448
449 #[test]
450 fn test_project_exists() {
451 let (_t, io) = setup();
452 assert!(io.project_exists("/abs/myrepo"));
453 assert!(!io.project_exists("/never"));
454 }
455
456 #[test]
457 fn test_read_session_missing() {
458 let (_t, io) = setup();
459 let err = io.read_session("/abs/myrepo", "missing").unwrap_err();
460 matches!(err, crate::error::ConvoError::ConversationNotFound(_));
461 }
462
463 #[test]
464 fn test_read_logs_absent() {
465 let (_t, io) = setup();
466 let logs = io.read_logs("/abs/myrepo").unwrap();
467 assert!(logs.is_empty());
468 }
469
470 #[test]
471 fn test_read_logs_present() {
472 let (t, io) = setup();
473 fs::write(
474 t.path().join(".gemini/tmp/myrepo/logs.json"),
475 r#"[{"sessionId":"s","messageId":0,"type":"user","message":"hi","timestamp":"t"}]"#,
476 )
477 .unwrap();
478 let logs = io.read_logs("/abs/myrepo").unwrap();
479 assert_eq!(logs.len(), 1);
480 }
481
482 #[test]
483 fn test_read_session_only_subagents_uses_first() {
484 let temp = TempDir::new().unwrap();
486 let gemini = temp.path().join(".gemini");
487 let session = gemini.join("tmp/p/chats/sess");
488 fs::create_dir_all(&session).unwrap();
489 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
490 fs::write(
491 session.join("a.json"),
492 r#"{"sessionId":"a","kind":"subagent","messages":[]}"#,
493 )
494 .unwrap();
495 fs::write(
496 session.join("b.json"),
497 r#"{"sessionId":"b","kind":"subagent","messages":[]}"#,
498 )
499 .unwrap();
500
501 let io = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
502 let convo = io.read_session("/p", "sess").unwrap();
503 assert_eq!(convo.sub_agents.len(), 1);
505 }
506
507 fn setup_main_with_sibling_subagent() -> (TempDir, ConvoIO) {
513 let temp = TempDir::new().unwrap();
514 let gemini = temp.path().join(".gemini");
515 let chats = gemini.join("tmp/p/chats");
516 fs::create_dir_all(&chats).unwrap();
517 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
518
519 fs::write(
521 chats.join("session-2026-04-17-b26d.json"),
522 r#"{
523 "sessionId":"b26d-full-uuid-abc",
524 "projectHash":"h",
525 "kind":"main",
526 "startTime":"2026-04-17T10:00:00Z",
527 "lastUpdated":"2026-04-17T10:20:00Z",
528 "directories":["/abs/p"],
529 "messages":[
530 {"id":"u1","timestamp":"2026-04-17T10:00:00Z","type":"user","content":[{"text":"go"}]},
531 {"id":"a1","timestamp":"2026-04-17T10:00:01Z","type":"gemini","content":"delegating","model":"gemini-3-flash-preview","toolCalls":[
532 {"id":"t","name":"task","args":{"prompt":"search"},"status":"success","timestamp":"2026-04-17T10:00:01Z","result":[{"functionResponse":{"id":"t","name":"task","response":{"output":"done"}}}]}
533 ]}
534 ]
535}"#,
536 )
537 .unwrap();
538
539 let sub_dir = chats.join("b26d-full-uuid-abc");
541 fs::create_dir_all(&sub_dir).unwrap();
542 fs::write(
543 sub_dir.join("helper.json"),
544 r#"{
545 "sessionId":"helper-sub",
546 "projectHash":"h",
547 "kind":"subagent",
548 "summary":"found it in auth.rs",
549 "startTime":"2026-04-17T10:05:00Z",
550 "lastUpdated":"2026-04-17T10:10:00Z",
551 "messages":[
552 {"id":"s1","timestamp":"2026-04-17T10:05:00Z","type":"user","content":[{"text":"search for auth bug"}]}
553 ]
554}"#,
555 )
556 .unwrap();
557
558 let io = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
559 (temp, io)
560 }
561
562 #[test]
563 fn test_read_session_real_world_layout() {
564 let (_t, io) = setup_main_with_sibling_subagent();
565 let convo = io.read_session("/p", "session-2026-04-17-b26d").unwrap();
566 assert_eq!(convo.main.session_id, "b26d-full-uuid-abc");
567 assert_eq!(convo.main.kind.as_deref(), Some("main"));
568 assert_eq!(convo.main.messages.len(), 2);
569 assert_eq!(convo.sub_agents.len(), 1);
570 assert_eq!(convo.sub_agents[0].session_id, "helper-sub");
571 assert_eq!(
572 convo.sub_agents[0].summary.as_deref(),
573 Some("found it in auth.rs")
574 );
575 assert_eq!(convo.project_path.as_deref(), Some("/abs/p"));
576 }
577
578 #[test]
579 fn test_read_session_metadata_real_world_layout() {
580 let (_t, io) = setup_main_with_sibling_subagent();
581 let meta = io
582 .read_session_metadata("/p", "session-2026-04-17-b26d")
583 .unwrap();
584 assert_eq!(meta.message_count, 3);
586 assert_eq!(meta.sub_agent_count, 1);
587 assert!(meta.started_at.is_some());
588 assert!(meta.last_activity.is_some());
589 }
590
591 #[test]
592 fn test_list_session_metadata_real_world() {
593 let (_t, io) = setup_main_with_sibling_subagent();
594 let metas = io.list_session_metadata("/p").unwrap();
595 assert_eq!(metas.len(), 1);
596 assert_eq!(metas[0].session_uuid, "session-2026-04-17-b26d");
597 assert_eq!(metas[0].sub_agent_count, 1);
598 }
599
600 #[test]
601 fn test_read_session_main_without_sibling_dir() {
602 let temp = TempDir::new().unwrap();
604 let gemini = temp.path().join(".gemini");
605 let chats = gemini.join("tmp/p/chats");
606 fs::create_dir_all(&chats).unwrap();
607 fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
608 fs::write(
609 chats.join("session-solo.json"),
610 r#"{"sessionId":"solo-uuid","projectHash":"h","kind":"main","messages":[
611 {"id":"u","timestamp":"ts","type":"user","content":"hi"}
612]}"#,
613 )
614 .unwrap();
615
616 let io = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir(&gemini));
617 let convo = io.read_session("/p", "session-solo").unwrap();
618 assert_eq!(convo.main.session_id, "solo-uuid");
619 assert!(convo.sub_agents.is_empty());
620 }
621
622 #[test]
623 fn test_session_exists_main_file_case() {
624 let (_t, io) = setup_main_with_sibling_subagent();
625 assert!(io.session_exists("/p", "session-2026-04-17-b26d").unwrap());
626 assert!(!io.session_exists("/p", "nope").unwrap());
627 }
628
629 #[test]
630 fn test_list_sessions_real_world_has_no_duplicates() {
631 let (_t, io) = setup_main_with_sibling_subagent();
632 let sessions = io.list_sessions("/p").unwrap();
633 assert_eq!(sessions, vec!["session-2026-04-17-b26d".to_string()]);
636 }
637
638 #[test]
641 fn test_resolver_accessor() {
642 let (_t, io) = setup();
643 assert!(io.resolver().exists());
644 }
645
646 #[test]
647 fn test_gemini_dir_path_accessor() {
648 let (temp, io) = setup();
649 let p = io.gemini_dir_path().unwrap();
650 assert_eq!(p, temp.path().join(".gemini"));
651 }
652
653 #[test]
654 fn test_exists_accessor() {
655 let (_t, io) = setup();
656 assert!(io.exists());
657 let missing = ConvoIO::with_resolver(PathResolver::new().with_gemini_dir("/nowhere"));
658 assert!(!missing.exists());
659 }
660
661 #[test]
662 fn test_read_all_chats_returns_all_files() {
663 let (_t, io) = setup();
664 let chats = io.read_all_chats("/abs/myrepo", "session-uuid").unwrap();
665 assert_eq!(chats.len(), 2);
666 let names: Vec<_> = chats.iter().map(|(s, _)| s.as_str()).collect();
667 assert!(names.contains(&"main"));
668 assert!(names.contains(&"sub-s"));
669 }
670}