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)]
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 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 let hashed = project_hash(project_path);
88 let candidate = tmp.join(&hashed);
89 if candidate.exists() {
90 return Ok(candidate);
91 }
92
93 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 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 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 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 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 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 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 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 pub fn resolve_main_file(
289 &self,
290 project_path: &str,
291 session_id: &str,
292 ) -> Result<Option<PathBuf>> {
293 let direct = self.main_session_file(project_path, session_id)?;
295 if direct.exists() {
296 return Ok(Some(direct));
297 }
298
299 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 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
354fn 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
368pub 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 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 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 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 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 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 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 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 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 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 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 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 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 fs::write(
736 chats.join("my-id.json"),
737 r#"{"sessionId":"other-uuid","projectHash":"","messages":[]}"#,
738 )
739 .unwrap();
740 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 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 fs::write(
779 chats.join("session-2026-abc.json"),
780 r#"{"sessionId":"sess-uuid-full","projectHash":"","messages":[]}"#,
781 )
782 .unwrap();
783 fs::create_dir_all(chats.join("sess-uuid-full")).unwrap();
786 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}