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