Skip to main content

toolpath_gemini/
io.rs

1//! Higher-level filesystem operations over `PathResolver`.
2
3use crate::error::Result;
4use crate::paths::PathResolver;
5use crate::reader::ConversationReader;
6use crate::types::{ChatFile, Conversation, ConversationMetadata, GeminiRole, LogEntry};
7use std::path::PathBuf;
8
9/// First non-empty `"user"` prompt in a chat file, used as a session "title".
10fn 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        // A session is "present" if ANY of: a main session file with that
80        // stem, a main session file whose inner sessionId matches, or a
81        // UUID directory of that name exists.
82        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    /// Read a single chat file by name.
94    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    /// Read every chat file inside a session UUID directory.
107    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    /// Load a full session.
122    ///
123    /// `session_id` may be:
124    /// - A main-session file stem (e.g. `session-2026-04-17T18-09-b26d7f99`)
125    ///   — the file is read, and a sibling `<inner-sessionId>/` dir (if
126    ///   present) contributes sub-agent chats.
127    /// - A full session UUID (the `sessionId` field inside a main chat
128    ///   file, e.g. `f7cc36c0-980c-4914-ae79-439567272478`) — `chats/*.json`
129    ///   is scanned for a file whose inner `sessionId` matches. This is
130    ///   how Gemini CLI itself resolves `--resume <uuid>`.
131    /// - A UUID directory name with no backing main file — every
132    ///   `*.json` file inside is loaded; the one without `kind: "subagent"`
133    ///   becomes the main.
134    pub fn read_session(&self, project_path: &str, session_id: &str) -> Result<Conversation> {
135        // Strategy A: resolve the main file either by file stem or by
136        // inner sessionId.
137        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        // Strategy B: treat session_id as a UUID directory.
174        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    /// Lightweight metadata for a single session.
204    ///
205    /// Accepts any identifier [`ConvoIO::read_session`] accepts:
206    /// filename stem, inner session UUID, or a bare UUID directory name.
207    pub fn read_session_metadata(
208        &self,
209        project_path: &str,
210        session_id: &str,
211    ) -> Result<ConversationMetadata> {
212        // Case A: main session file resolvable by stem or inner sessionId.
213        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        // Case B: orphan UUID directory.
268        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); // 2 main + 1 sub-agent
420        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        // Edge case: session dir where every file is a sub-agent.
485        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        // Fell back to the first file as "main"
504        assert_eq!(convo.sub_agents.len(), 1);
505    }
506
507    // ── Real-world layout: flat main file + sibling <uuid>/ sub-agent dir ──
508
509    /// Build the canonical real-world layout:
510    ///   chats/session-<ts>-<short>.json       (kind: "main")
511    ///   chats/<full-uuid>/<name>.json         (kind: "subagent")
512    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        // Main session file at top of chats/
520        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        // Sibling sub-agent dir named with the full inner sessionId
540        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        // 2 main + 1 sub-agent
585        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        // Main file exists but no sub-agent UUID dir — sub_agents stays empty.
603        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        // One main file → one session listed. Sibling UUID dir must not
634        // show up as its own session.
635        assert_eq!(sessions, vec!["session-2026-04-17-b26d".to_string()]);
636    }
637
638    // ── Small accessors ───────────────────────────────────────────────
639
640    #[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}