Skip to main content

toolpath_gemini/
paths.rs

1//! Filesystem layout for Gemini CLI conversation logs.
2//!
3//! Gemini CLI stores per-project chat logs under `~/.gemini/tmp/<slot>/`,
4//! where `<slot>` is either the friendly project name from
5//! `~/.gemini/projects.json` or the SHA-256 hex of the absolute project
6//! path. Both are supported: the resolver prefers the friendly name when
7//! it exists on disk, and falls back to the hash otherwise.
8
9use crate::error::{ConvoError, Result};
10use serde::Deserialize;
11use sha2::{Digest, Sha256};
12use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16const PROJECTS_FILE: &str = "projects.json";
17const TMP_DIR: &str = "tmp";
18const CHATS_SUBDIR: &str = "chats";
19const LOGS_FILE: &str = "logs.json";
20
21#[derive(Debug, Clone)]
22pub struct PathResolver {
23    home_dir: Option<PathBuf>,
24    gemini_dir: Option<PathBuf>,
25}
26
27impl Default for PathResolver {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl PathResolver {
34    pub fn new() -> Self {
35        Self {
36            home_dir: dirs::home_dir(),
37            gemini_dir: None,
38        }
39    }
40
41    pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
42        self.home_dir = Some(home.into());
43        self
44    }
45
46    pub fn with_gemini_dir<P: Into<PathBuf>>(mut self, gemini_dir: P) -> Self {
47        self.gemini_dir = Some(gemini_dir.into());
48        self
49    }
50
51    pub fn home_dir(&self) -> Result<&Path> {
52        self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
53    }
54
55    pub fn gemini_dir(&self) -> Result<PathBuf> {
56        if let Some(d) = &self.gemini_dir {
57            return Ok(d.clone());
58        }
59        Ok(self.home_dir()?.join(".gemini"))
60    }
61
62    pub fn projects_file(&self) -> Result<PathBuf> {
63        Ok(self.gemini_dir()?.join(PROJECTS_FILE))
64    }
65
66    pub fn tmp_dir(&self) -> Result<PathBuf> {
67        Ok(self.gemini_dir()?.join(TMP_DIR))
68    }
69
70    /// Absolute path to the project slot directory under `tmp/`.
71    ///
72    /// Looks up `project_path` in `projects.json` for its friendly name
73    /// first; if that directory doesn't exist, falls back to
74    /// `tmp/<sha256(project_path)>/`. The returned path may not exist
75    /// yet — callers decide how to handle that.
76    pub fn project_dir(&self, project_path: &str) -> Result<PathBuf> {
77        let tmp = self.tmp_dir()?;
78
79        if let Some(friendly) = self.friendly_name_for(project_path)? {
80            let candidate = tmp.join(&friendly);
81            if candidate.exists() {
82                return Ok(candidate);
83            }
84        }
85
86        // Fall back to the SHA-256 slot.
87        let hashed = project_hash(project_path);
88        let candidate = tmp.join(&hashed);
89        if candidate.exists() {
90            return Ok(candidate);
91        }
92
93        // If neither exists, try the friendly name anyway (the caller
94        // may intend to create the directory) — otherwise return the
95        // hash path as a stable default.
96        if let Some(friendly) = self.friendly_name_for(project_path)? {
97            return Ok(tmp.join(friendly));
98        }
99        Ok(candidate)
100    }
101
102    pub fn chats_dir(&self, project_path: &str) -> Result<PathBuf> {
103        Ok(self.project_dir(project_path)?.join(CHATS_SUBDIR))
104    }
105
106    pub fn session_dir(&self, project_path: &str, session_uuid: &str) -> Result<PathBuf> {
107        Ok(self.chats_dir(project_path)?.join(session_uuid))
108    }
109
110    pub fn chat_file(
111        &self,
112        project_path: &str,
113        session_uuid: &str,
114        chat_name: &str,
115    ) -> Result<PathBuf> {
116        let stem = if chat_name.ends_with(".json") {
117            chat_name.to_string()
118        } else {
119            format!("{}.json", chat_name)
120        };
121        Ok(self.session_dir(project_path, session_uuid)?.join(stem))
122    }
123
124    pub fn logs_file(&self, project_path: &str) -> Result<PathBuf> {
125        Ok(self.project_dir(project_path)?.join(LOGS_FILE))
126    }
127
128    /// Read `projects.json` and reverse-lookup a friendly name for the
129    /// given absolute project path.
130    pub fn friendly_name_for(&self, project_path: &str) -> Result<Option<String>> {
131        let file = match self.projects_file() {
132            Ok(p) if p.exists() => p,
133            _ => return Ok(None),
134        };
135        let bytes = fs::read(&file)?;
136        let projects: ProjectsFile = match serde_json::from_slice(&bytes) {
137            Ok(p) => p,
138            Err(_) => return Ok(None),
139        };
140        Ok(projects.projects.get(project_path).cloned())
141    }
142
143    /// Return every project path known to Gemini: the union of
144    /// `projects.json` keys and any project slots present under `tmp/`
145    /// that have a `.project_root` marker.
146    pub fn list_project_dirs(&self) -> Result<Vec<String>> {
147        let mut paths: Vec<String> = Vec::new();
148        let mut seen = std::collections::HashSet::new();
149
150        // projects.json entries.
151        if let Ok(file) = self.projects_file()
152            && file.exists()
153            && let Ok(bytes) = fs::read(&file)
154            && let Ok(projects) = serde_json::from_slice::<ProjectsFile>(&bytes)
155        {
156            for key in projects.projects.keys() {
157                if seen.insert(key.clone()) {
158                    paths.push(key.clone());
159                }
160            }
161        }
162
163        // `.project_root` markers under tmp/.
164        if let Ok(tmp) = self.tmp_dir()
165            && tmp.exists()
166        {
167            for entry in fs::read_dir(&tmp)?.flatten() {
168                if entry.file_type().ok().is_some_and(|ft| ft.is_dir()) {
169                    let marker = entry.path().join(".project_root");
170                    if marker.exists()
171                        && let Ok(text) = fs::read_to_string(&marker)
172                    {
173                        let p = text.trim().to_string();
174                        if !p.is_empty() && seen.insert(p.clone()) {
175                            paths.push(p);
176                        }
177                    }
178                }
179            }
180        }
181
182        paths.sort();
183        Ok(paths)
184    }
185
186    /// List sessions under a project's `chats/` directory.
187    ///
188    /// A session is either a top-level `session-*.json` main-chat file
189    /// (listed by its file stem) or an orphan `<uuid>/` directory that
190    /// has no corresponding main file (listed by the dir name).
191    ///
192    /// When both a `session-*.json` *and* a `<uuid>/` dir point at the
193    /// same `sessionId`, the UUID dir is considered the main file's
194    /// sub-agent bucket and is **not** surfaced as a separate session —
195    /// it gets merged into the main session by `read_session`.
196    pub fn list_sessions(&self, project_path: &str) -> Result<Vec<String>> {
197        let chats = match self.chats_dir(project_path) {
198            Ok(p) => p,
199            Err(_) => return Ok(Vec::new()),
200        };
201        if !chats.exists() {
202            return Ok(Vec::new());
203        }
204
205        let mut main_stems: Vec<String> = Vec::new();
206        let mut main_session_uuids: std::collections::HashSet<String> = Default::default();
207        let mut dir_uuids: Vec<String> = Vec::new();
208
209        for entry in fs::read_dir(&chats)?.flatten() {
210            let ft = match entry.file_type() {
211                Ok(ft) => ft,
212                Err(_) => continue,
213            };
214            let path = entry.path();
215            if ft.is_file() {
216                if path.extension().and_then(|s| s.to_str()) != Some("json") {
217                    continue;
218                }
219                let stem = match path.file_stem().and_then(|s| s.to_str()) {
220                    Some(s) => s.to_string(),
221                    None => continue,
222                };
223                main_stems.push(stem);
224                if let Some(uuid) = peek_session_id(&path) {
225                    main_session_uuids.insert(uuid);
226                }
227            } else if ft.is_dir()
228                && let Some(name) = entry.file_name().to_str()
229            {
230                dir_uuids.push(name.to_string());
231            }
232        }
233
234        let mut out = main_stems;
235        for uuid in dir_uuids {
236            if !main_session_uuids.contains(&uuid) {
237                out.push(uuid);
238            }
239        }
240        out.sort();
241        Ok(out)
242    }
243
244    /// List just the top-level main session file stems (no UUID dirs).
245    pub fn list_main_session_stems(&self, project_path: &str) -> Result<Vec<String>> {
246        let chats = match self.chats_dir(project_path) {
247            Ok(p) => p,
248            Err(_) => return Ok(Vec::new()),
249        };
250        if !chats.exists() {
251            return Ok(Vec::new());
252        }
253        let mut out = Vec::new();
254        for entry in fs::read_dir(&chats)?.flatten() {
255            let path = entry.path();
256            if path.is_file()
257                && path.extension().and_then(|s| s.to_str()) == Some("json")
258                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
259            {
260                out.push(stem.to_string());
261            }
262        }
263        out.sort();
264        Ok(out)
265    }
266
267    /// Path to a main session JSON at the top of `chats/`.
268    pub fn main_session_file(&self, project_path: &str, stem: &str) -> Result<PathBuf> {
269        let name = if stem.ends_with(".json") {
270            stem.to_string()
271        } else {
272            format!("{}.json", stem)
273        };
274        Ok(self.chats_dir(project_path)?.join(name))
275    }
276
277    /// Locate a main chat file whose *identity* (either the filename stem
278    /// or the inner `sessionId` field) matches `session_id`.
279    ///
280    /// This mirrors how Gemini CLI itself resolves `--resume <id>`: it
281    /// accepts both the on-disk stem (e.g. `session-2026-04-17T18-09-b26d7f99`)
282    /// and the full session UUID (which lives inside the file as
283    /// `"sessionId"`). Returns `Ok(None)` if nothing matches.
284    ///
285    /// Does *not* consider UUID subdirectories — those are handled
286    /// separately in [`crate::ConvoIO::read_session`] as an orphan
287    /// sub-agent bucket.
288    pub fn resolve_main_file(
289        &self,
290        project_path: &str,
291        session_id: &str,
292    ) -> Result<Option<PathBuf>> {
293        // Fast path: direct stem match at chats/<session_id>.json.
294        let direct = self.main_session_file(project_path, session_id)?;
295        if direct.exists() {
296            return Ok(Some(direct));
297        }
298
299        // Fallback: scan chats/*.json and match on inner sessionId.
300        let chats = match self.chats_dir(project_path) {
301            Ok(p) => p,
302            Err(_) => return Ok(None),
303        };
304        if !chats.exists() {
305            return Ok(None);
306        }
307        for entry in fs::read_dir(&chats)?.flatten() {
308            let p = entry.path();
309            if !p.is_file() || p.extension().and_then(|s| s.to_str()) != Some("json") {
310                continue;
311            }
312            if let Some(inner) = peek_session_id(&p)
313                && inner == session_id
314            {
315                return Ok(Some(p));
316            }
317        }
318        Ok(None)
319    }
320
321    /// List chat file stems in a session directory (without `.json`).
322    pub fn list_chat_files(&self, project_path: &str, session_uuid: &str) -> Result<Vec<String>> {
323        let dir = match self.session_dir(project_path, session_uuid) {
324            Ok(p) => p,
325            Err(_) => return Ok(Vec::new()),
326        };
327        if !dir.exists() {
328            return Ok(Vec::new());
329        }
330        let mut stems: Vec<String> = Vec::new();
331        for entry in fs::read_dir(&dir)?.flatten() {
332            let path = entry.path();
333            if path.extension().and_then(|s| s.to_str()) == Some("json")
334                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
335            {
336                stems.push(stem.to_string());
337            }
338        }
339        stems.sort();
340        Ok(stems)
341    }
342
343    pub fn exists(&self) -> bool {
344        self.gemini_dir().map(|p| p.exists()).unwrap_or(false)
345    }
346}
347
348#[derive(Debug, Deserialize)]
349struct ProjectsFile {
350    #[serde(default)]
351    projects: HashMap<String, String>,
352}
353
354/// Read just the top-level `sessionId` field from a chat JSON file
355/// without materialising the whole document. Used by `list_sessions` to
356/// correlate main files with sibling sub-agent UUID directories.
357fn peek_session_id(path: &std::path::Path) -> Option<String> {
358    #[derive(Deserialize)]
359    struct Peek {
360        #[serde(rename = "sessionId")]
361        session_id: Option<String>,
362    }
363    let bytes = fs::read(path).ok()?;
364    let peek: Peek = serde_json::from_slice(&bytes).ok()?;
365    peek.session_id.filter(|s| !s.is_empty())
366}
367
368/// Canonical `projectHash`: SHA-256 hex of the absolute project path.
369pub fn project_hash(project_path: &str) -> String {
370    let mut hasher = Sha256::new();
371    hasher.update(project_path.as_bytes());
372    let digest = hasher.finalize();
373    let mut s = String::with_capacity(64);
374    for byte in digest {
375        use std::fmt::Write;
376        let _ = write!(s, "{:02x}", byte);
377    }
378    s
379}
380
381mod dirs {
382    use std::env;
383    use std::path::PathBuf;
384
385    pub fn home_dir() -> Option<PathBuf> {
386        env::var_os("HOME")
387            .or_else(|| env::var_os("USERPROFILE"))
388            .map(PathBuf::from)
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use tempfile::TempDir;
396
397    fn setup() -> (TempDir, PathResolver) {
398        let temp = TempDir::new().unwrap();
399        let gemini = temp.path().join(".gemini");
400        fs::create_dir_all(&gemini).unwrap();
401        let resolver = PathResolver::new()
402            .with_home(temp.path())
403            .with_gemini_dir(&gemini);
404        (temp, resolver)
405    }
406
407    #[test]
408    fn test_project_hash_stable() {
409        let h1 = project_hash("/Users/ben/empathic/oss/toolpath");
410        let h2 = project_hash("/Users/ben/empathic/oss/toolpath");
411        assert_eq!(h1, h2);
412        assert_eq!(h1.len(), 64);
413        assert!(h1.chars().all(|c| c.is_ascii_hexdigit()));
414    }
415
416    #[test]
417    fn test_project_hash_matches_known_value() {
418        // Value observed in real local chat file for this project
419        let h = project_hash("/Users/ben/empathic/oss/toolpath");
420        assert_eq!(
421            h,
422            "384e9530e99733805bc2c98a596ab23e67d4c29a6ef263cdc1c89b3bcd022c69"
423        );
424    }
425
426    #[test]
427    fn test_gemini_dir_default() {
428        let (temp, resolver) = setup();
429        let dir = resolver.gemini_dir().unwrap();
430        assert_eq!(dir, temp.path().join(".gemini"));
431    }
432
433    #[test]
434    fn test_gemini_dir_from_home() {
435        let temp = TempDir::new().unwrap();
436        let resolver = PathResolver::new().with_home(temp.path());
437        assert_eq!(resolver.gemini_dir().unwrap(), temp.path().join(".gemini"));
438    }
439
440    #[test]
441    fn test_project_dir_friendly_name() {
442        let (_temp, resolver) = setup();
443        let gemini = resolver.gemini_dir().unwrap();
444        fs::write(
445            gemini.join("projects.json"),
446            r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
447        )
448        .unwrap();
449        fs::create_dir_all(gemini.join("tmp/myrepo")).unwrap();
450
451        let dir = resolver.project_dir("/abs/myrepo").unwrap();
452        assert_eq!(dir, gemini.join("tmp/myrepo"));
453    }
454
455    #[test]
456    fn test_project_dir_hash_fallback() {
457        let (_temp, resolver) = setup();
458        let gemini = resolver.gemini_dir().unwrap();
459        let hashed = project_hash("/abs/other");
460        fs::create_dir_all(gemini.join("tmp").join(&hashed)).unwrap();
461
462        let dir = resolver.project_dir("/abs/other").unwrap();
463        assert_eq!(dir, gemini.join("tmp").join(hashed));
464    }
465
466    #[test]
467    fn test_project_dir_no_dir_returns_hash_path() {
468        let (_temp, resolver) = setup();
469        let gemini = resolver.gemini_dir().unwrap();
470        let dir = resolver.project_dir("/never/exists").unwrap();
471        assert_eq!(dir, gemini.join("tmp").join(project_hash("/never/exists")));
472    }
473
474    #[test]
475    fn test_project_dir_prefers_friendly_name_even_without_tmp() {
476        let (_temp, resolver) = setup();
477        let gemini = resolver.gemini_dir().unwrap();
478        // Friendly name is present in projects.json, but tmp/<friendly>/
479        // doesn't exist. When no slot exists, we still prefer the friendly
480        // path so callers targeting the known name work.
481        fs::write(
482            gemini.join("projects.json"),
483            r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
484        )
485        .unwrap();
486        let dir = resolver.project_dir("/abs/myrepo").unwrap();
487        assert_eq!(dir, gemini.join("tmp/myrepo"));
488    }
489
490    #[test]
491    fn test_session_dir_chat_file() {
492        let (_temp, resolver) = setup();
493        let gemini = resolver.gemini_dir().unwrap();
494        fs::create_dir_all(gemini.join("tmp/myrepo/chats/session-uuid")).unwrap();
495        fs::write(
496            gemini.join("projects.json"),
497            r#"{"projects":{"/abs/myrepo":"myrepo"}}"#,
498        )
499        .unwrap();
500
501        let session = resolver.session_dir("/abs/myrepo", "session-uuid").unwrap();
502        assert_eq!(session, gemini.join("tmp/myrepo/chats/session-uuid"));
503
504        let file = resolver
505            .chat_file("/abs/myrepo", "session-uuid", "main")
506            .unwrap();
507        assert_eq!(file, gemini.join("tmp/myrepo/chats/session-uuid/main.json"));
508
509        let file_with_ext = resolver
510            .chat_file("/abs/myrepo", "session-uuid", "main.json")
511            .unwrap();
512        assert_eq!(file, file_with_ext);
513    }
514
515    #[test]
516    fn test_logs_file() {
517        let (_temp, resolver) = setup();
518        let gemini = resolver.gemini_dir().unwrap();
519        let logs = resolver.logs_file("/abs/myrepo").unwrap();
520        assert!(logs.ends_with("logs.json"));
521        // Should live inside the project slot
522        assert!(logs.starts_with(gemini.join("tmp")));
523    }
524
525    #[test]
526    fn test_friendly_name_lookup_missing_file() {
527        let (_temp, resolver) = setup();
528        assert_eq!(resolver.friendly_name_for("/nope").unwrap(), None);
529    }
530
531    #[test]
532    fn test_friendly_name_lookup_malformed_file() {
533        let (_temp, resolver) = setup();
534        let gemini = resolver.gemini_dir().unwrap();
535        fs::write(gemini.join("projects.json"), "not json").unwrap();
536        assert_eq!(resolver.friendly_name_for("/nope").unwrap(), None);
537    }
538
539    #[test]
540    fn test_list_project_dirs_union() {
541        let (_temp, resolver) = setup();
542        let gemini = resolver.gemini_dir().unwrap();
543
544        fs::write(
545            gemini.join("projects.json"),
546            r#"{"projects":{"/a":"a","/b":"b"}}"#,
547        )
548        .unwrap();
549
550        // Add a C slot that only has a .project_root marker
551        fs::create_dir_all(gemini.join("tmp/c")).unwrap();
552        fs::write(gemini.join("tmp/c/.project_root"), "/c\n").unwrap();
553
554        let projects = resolver.list_project_dirs().unwrap();
555        assert!(projects.contains(&"/a".to_string()));
556        assert!(projects.contains(&"/b".to_string()));
557        assert!(projects.contains(&"/c".to_string()));
558        assert_eq!(projects.len(), 3);
559    }
560
561    #[test]
562    fn test_list_project_dirs_empty() {
563        let (_temp, resolver) = setup();
564        let projects = resolver.list_project_dirs().unwrap();
565        assert!(projects.is_empty());
566    }
567
568    #[test]
569    fn test_list_sessions() {
570        let (_temp, resolver) = setup();
571        let gemini = resolver.gemini_dir().unwrap();
572        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
573        fs::create_dir_all(gemini.join("tmp/p/chats/session-a")).unwrap();
574        fs::create_dir_all(gemini.join("tmp/p/chats/session-b")).unwrap();
575        // A stray file should be ignored
576        fs::write(gemini.join("tmp/p/chats/stray.txt"), "x").unwrap();
577
578        let sessions = resolver.list_sessions("/p").unwrap();
579        assert_eq!(
580            sessions,
581            vec!["session-a".to_string(), "session-b".to_string()]
582        );
583    }
584
585    #[test]
586    fn test_list_sessions_no_project() {
587        let (_temp, resolver) = setup();
588        let sessions = resolver.list_sessions("/never").unwrap();
589        assert!(sessions.is_empty());
590    }
591
592    #[test]
593    fn test_list_chat_files() {
594        let (_temp, resolver) = setup();
595        let gemini = resolver.gemini_dir().unwrap();
596        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
597        fs::create_dir_all(gemini.join("tmp/p/chats/session-x")).unwrap();
598        fs::write(gemini.join("tmp/p/chats/session-x/main.json"), "{}").unwrap();
599        fs::write(gemini.join("tmp/p/chats/session-x/qclszz.json"), "{}").unwrap();
600        fs::write(gemini.join("tmp/p/chats/session-x/ignore.txt"), "x").unwrap();
601
602        let stems = resolver.list_chat_files("/p", "session-x").unwrap();
603        assert_eq!(stems, vec!["main".to_string(), "qclszz".to_string()]);
604    }
605
606    #[test]
607    fn test_exists() {
608        let (_temp, resolver) = setup();
609        assert!(resolver.exists());
610
611        let missing = PathResolver::new().with_gemini_dir("/never/exists");
612        assert!(!missing.exists());
613    }
614
615    #[test]
616    fn test_home_dir_from_env() {
617        let home = dirs::home_dir();
618        // Most test environments have one of HOME/USERPROFILE set
619        assert!(home.is_some());
620    }
621
622    #[test]
623    fn test_tmp_dir() {
624        let (_t, r) = setup();
625        let tmp = r.tmp_dir().unwrap();
626        assert!(tmp.ends_with(".gemini/tmp"));
627    }
628
629    #[test]
630    fn test_chats_dir() {
631        let (_t, r) = setup();
632        let gemini = r.gemini_dir().unwrap();
633        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
634        let chats = r.chats_dir("/p").unwrap();
635        assert_eq!(chats, gemini.join("tmp/p/chats"));
636    }
637
638    #[test]
639    fn test_list_main_session_stems() {
640        // Flat main files at the top of `chats/` are enumerated; UUID
641        // subdirectories are not.
642        let (_t, r) = setup();
643        let gemini = r.gemini_dir().unwrap();
644        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
645        let chats = gemini.join("tmp/p/chats");
646        fs::create_dir_all(&chats).unwrap();
647        fs::write(
648            chats.join("session-2026-04-17-abc.json"),
649            r#"{"sessionId":"abc","projectHash":"","messages":[]}"#,
650        )
651        .unwrap();
652        fs::write(
653            chats.join("session-2026-04-18-def.json"),
654            r#"{"sessionId":"def","projectHash":"","messages":[]}"#,
655        )
656        .unwrap();
657        // UUID dir next to the main files — ignored by this listing
658        fs::create_dir_all(chats.join("abc-1234-5678-9abc")).unwrap();
659
660        let stems = r.list_main_session_stems("/p").unwrap();
661        assert_eq!(
662            stems,
663            vec![
664                "session-2026-04-17-abc".to_string(),
665                "session-2026-04-18-def".to_string(),
666            ]
667        );
668    }
669
670    #[test]
671    fn test_main_session_file_path() {
672        let (_t, r) = setup();
673        let gemini = r.gemini_dir().unwrap();
674        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
675        let p = r.main_session_file("/p", "session-2026-04-17-abc").unwrap();
676        assert_eq!(p, gemini.join("tmp/p/chats/session-2026-04-17-abc.json"));
677        // .json suffix is optional
678        let p2 = r
679            .main_session_file("/p", "session-2026-04-17-abc.json")
680            .unwrap();
681        assert_eq!(p, p2);
682    }
683
684    #[test]
685    fn test_resolve_main_file_by_stem() {
686        let (_t, r) = setup();
687        let gemini = r.gemini_dir().unwrap();
688        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
689        let chats = gemini.join("tmp/p/chats");
690        fs::create_dir_all(&chats).unwrap();
691        fs::write(
692            chats.join("session-2026-04-17-abc.json"),
693            r#"{"sessionId":"abc-uuid","projectHash":"","messages":[]}"#,
694        )
695        .unwrap();
696
697        let found = r.resolve_main_file("/p", "session-2026-04-17-abc").unwrap();
698        assert_eq!(found, Some(chats.join("session-2026-04-17-abc.json")));
699    }
700
701    #[test]
702    fn test_resolve_main_file_by_inner_session_id() {
703        // Matches the way Gemini CLI's `--resume <uuid>` resolves: scans
704        // all main files and matches on inner `sessionId`.
705        let (_t, r) = setup();
706        let gemini = r.gemini_dir().unwrap();
707        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
708        let chats = gemini.join("tmp/p/chats");
709        fs::create_dir_all(&chats).unwrap();
710        fs::write(
711            chats.join("session-2026-04-17-abc.json"),
712            r#"{"sessionId":"f7cc36c0-980c-4914-ae79-439567272478","projectHash":"","messages":[]}"#,
713        )
714        .unwrap();
715
716        // `--resume f7cc36c0-...` should resolve to the file above even
717        // though its on-disk stem is different.
718        let found = r
719            .resolve_main_file("/p", "f7cc36c0-980c-4914-ae79-439567272478")
720            .unwrap();
721        assert_eq!(found, Some(chats.join("session-2026-04-17-abc.json")));
722    }
723
724    #[test]
725    fn test_resolve_main_file_prefers_stem_over_inner_id() {
726        // If a file's stem *and* another file's inner sessionId both
727        // match, the direct stem lookup wins — it's the fast path and
728        // mirrors CLI lookup order.
729        let (_t, r) = setup();
730        let gemini = r.gemini_dir().unwrap();
731        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
732        let chats = gemini.join("tmp/p/chats");
733        fs::create_dir_all(&chats).unwrap();
734        // File whose stem matches the query
735        fs::write(
736            chats.join("my-id.json"),
737            r#"{"sessionId":"other-uuid","projectHash":"","messages":[]}"#,
738        )
739        .unwrap();
740        // File whose inner sessionId matches the query
741        fs::write(
742            chats.join("session-other.json"),
743            r#"{"sessionId":"my-id","projectHash":"","messages":[]}"#,
744        )
745        .unwrap();
746
747        let found = r.resolve_main_file("/p", "my-id").unwrap();
748        assert_eq!(found, Some(chats.join("my-id.json")));
749    }
750
751    #[test]
752    fn test_resolve_main_file_returns_none_when_unmatched() {
753        let (_t, r) = setup();
754        let gemini = r.gemini_dir().unwrap();
755        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
756        let chats = gemini.join("tmp/p/chats");
757        fs::create_dir_all(&chats).unwrap();
758        fs::write(
759            chats.join("session-other.json"),
760            r#"{"sessionId":"uuid-a","projectHash":"","messages":[]}"#,
761        )
762        .unwrap();
763
764        let found = r.resolve_main_file("/p", "uuid-that-doesnt-exist").unwrap();
765        assert_eq!(found, None);
766    }
767
768    #[test]
769    fn test_list_sessions_dedupes_main_and_sibling_uuid() {
770        // A main file whose inner sessionId matches a sibling UUID dir
771        // should surface once as the main stem, not twice.
772        let (_t, r) = setup();
773        let gemini = r.gemini_dir().unwrap();
774        fs::write(gemini.join("projects.json"), r#"{"projects":{"/p":"p"}}"#).unwrap();
775        let chats = gemini.join("tmp/p/chats");
776        fs::create_dir_all(&chats).unwrap();
777        // Main file carrying sessionId "sess-uuid-full"
778        fs::write(
779            chats.join("session-2026-abc.json"),
780            r#"{"sessionId":"sess-uuid-full","projectHash":"","messages":[]}"#,
781        )
782        .unwrap();
783        // Sibling sub-agent dir matching that UUID — should NOT be listed
784        // as its own session.
785        fs::create_dir_all(chats.join("sess-uuid-full")).unwrap();
786        // An orphan UUID dir that does NOT correspond to any main — should
787        // be listed.
788        fs::create_dir_all(chats.join("orphan-uuid-zzz")).unwrap();
789
790        let sessions = r.list_sessions("/p").unwrap();
791        assert!(sessions.contains(&"session-2026-abc".to_string()));
792        assert!(sessions.contains(&"orphan-uuid-zzz".to_string()));
793        assert!(!sessions.contains(&"sess-uuid-full".to_string()));
794        assert_eq!(sessions.len(), 2);
795    }
796}