1use 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)]
23pub struct SessionEntry {
24 pub id: String,
27 pub session_uuid: Option<String>,
30 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 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 let hashed = project_hash(project_path);
102 let candidate = tmp.join(&hashed);
103 if candidate.exists() {
104 return Ok(candidate);
105 }
106
107 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 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 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 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 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 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 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 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 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 pub fn resolve_main_file(
326 &self,
327 project_path: &str,
328 session_id: &str,
329 ) -> Result<Option<PathBuf>> {
330 let direct = self.main_session_file(project_path, session_id)?;
332 if direct.exists() {
333 return Ok(Some(direct));
334 }
335
336 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 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
391const PEEK_BYTES: usize = 4096;
394
395fn 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 #[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
421fn prefix_session_id(prefix: &[u8]) -> Option<String> {
425 let text = match std::str::from_utf8(prefix) {
426 Ok(t) => t,
427 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
446pub 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 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 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 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 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 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 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 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 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 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 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 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 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 fs::write(
814 chats.join("my-id.json"),
815 r#"{"sessionId":"other-uuid","projectHash":"","messages":[]}"#,
816 )
817 .unwrap();
818 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 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 fs::write(
857 chats.join("session-2026-abc.json"),
858 r#"{"sessionId":"sess-uuid-full","projectHash":"","messages":[]}"#,
859 )
860 .unwrap();
861 fs::create_dir_all(chats.join("sess-uuid-full")).unwrap();
864 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 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 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 let ids = resolver.list_sessions("/proj").unwrap();
950 assert_eq!(ids, vec!["session-2026-01-01T00-00-aaaa", "uuid-b"]);
951 }
952}