1use crate::error::{ConvoError, Result};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13const SESSIONS_SUBDIR: &str = "sessions";
14const HISTORY_FILE: &str = "history.jsonl";
15const LOG_FILE: &str = "log/codex-tui.log";
16
17#[derive(Debug, Clone)]
19pub struct PathResolver {
20 home_dir: Option<PathBuf>,
21 codex_dir: Option<PathBuf>,
22}
23
24impl Default for PathResolver {
25 fn default() -> Self {
26 Self::new()
27 }
28}
29
30impl PathResolver {
31 pub fn new() -> Self {
32 Self {
33 home_dir: dirs::home_dir(),
34 codex_dir: None,
35 }
36 }
37
38 pub fn with_home<P: Into<PathBuf>>(mut self, home: P) -> Self {
39 self.home_dir = Some(home.into());
40 self
41 }
42
43 pub fn with_codex_dir<P: Into<PathBuf>>(mut self, codex_dir: P) -> Self {
45 self.codex_dir = Some(codex_dir.into());
46 self
47 }
48
49 pub fn home_dir(&self) -> Result<&Path> {
50 self.home_dir.as_deref().ok_or(ConvoError::NoHomeDirectory)
51 }
52
53 pub fn codex_dir(&self) -> Result<PathBuf> {
54 if let Some(d) = &self.codex_dir {
55 return Ok(d.clone());
56 }
57 Ok(self.home_dir()?.join(".codex"))
58 }
59
60 pub fn sessions_root(&self) -> Result<PathBuf> {
61 Ok(self.codex_dir()?.join(SESSIONS_SUBDIR))
62 }
63
64 pub fn history_file(&self) -> Result<PathBuf> {
65 Ok(self.codex_dir()?.join(HISTORY_FILE))
66 }
67
68 pub fn log_file(&self) -> Result<PathBuf> {
69 Ok(self.codex_dir()?.join(LOG_FILE))
70 }
71
72 pub fn exists(&self) -> bool {
73 self.codex_dir().map(|p| p.exists()).unwrap_or(false)
74 }
75
76 pub fn list_rollout_files(&self) -> Result<Vec<PathBuf>> {
79 let root = self.sessions_root()?;
80 if !root.exists() {
81 return Ok(Vec::new());
82 }
83 let mut files = Vec::new();
84 walk_for_rollouts(&root, &mut files)?;
85 files.sort_by_key(|p| {
87 fs::metadata(p)
88 .and_then(|m| m.modified())
89 .ok()
90 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
91 .map(|d| std::cmp::Reverse(d.as_secs()))
92 .unwrap_or(std::cmp::Reverse(0))
93 });
94 Ok(files)
95 }
96
97 pub fn find_rollout_file(&self, session_id: &str) -> Result<PathBuf> {
106 if let Some(direct) = self.rollout_file_for_stem(session_id)? {
107 return Ok(direct);
108 }
109 let all = self.list_rollout_files()?;
110 for p in &all {
112 if let Some(stem) = p.file_stem().and_then(|s| s.to_str())
113 && stem == session_id
114 {
115 return Ok(p.clone());
116 }
117 }
118 let matches: Vec<&PathBuf> = all
120 .iter()
121 .filter(|p| {
122 p.file_stem()
123 .and_then(|s| s.to_str())
124 .map(|s| s.contains(session_id))
125 .unwrap_or(false)
126 })
127 .collect();
128 match matches.len() {
129 0 => Err(ConvoError::SessionNotFound(session_id.to_string())),
130 1 => Ok(matches[0].clone()),
131 _ => Err(ConvoError::SessionNotFound(format!(
132 "{} (ambiguous — {} matches)",
133 session_id,
134 matches.len()
135 ))),
136 }
137 }
138
139 fn rollout_file_for_stem(&self, session_id: &str) -> Result<Option<PathBuf>> {
144 let Some(date) = session_id.strip_prefix("rollout-").and_then(|r| r.get(..10)) else {
145 return Ok(None);
146 };
147 let parts: Vec<&str> = date.split('-').collect();
148 let [y, m, d] = parts.as_slice() else {
149 return Ok(None);
150 };
151 if y.len() != 4 || m.len() != 2 || d.len() != 2 {
152 return Ok(None);
153 }
154 let candidate = self
155 .sessions_root()?
156 .join(y)
157 .join(m)
158 .join(d)
159 .join(format!("{session_id}.jsonl"));
160 Ok(candidate.is_file().then_some(candidate))
161 }
162}
163
164pub fn session_id_from_stem(stem: &str) -> &str {
171 match find_uuid_start(stem) {
172 Some(at) => &stem[at..],
173 None => stem,
174 }
175}
176
177fn find_uuid_start(stem: &str) -> Option<usize> {
180 let mut idx = 0usize;
181 let bytes = stem.as_bytes();
182 while idx + 36 <= bytes.len() {
183 if is_uuid_shape(&stem[idx..idx + 36]) {
184 return Some(idx);
185 }
186 idx += 1;
187 }
188 None
189}
190
191fn is_uuid_shape(s: &str) -> bool {
192 let b = s.as_bytes();
193 if b.len() != 36 {
194 return false;
195 }
196 for (i, c) in b.iter().enumerate() {
197 match i {
198 8 | 13 | 18 | 23 => {
199 if *c != b'-' {
200 return false;
201 }
202 }
203 _ => {
204 if !c.is_ascii_hexdigit() {
205 return false;
206 }
207 }
208 }
209 }
210 true
211}
212
213fn walk_for_rollouts(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
215 for entry in fs::read_dir(dir)?.flatten() {
216 let path = entry.path();
217 let ft = match entry.file_type() {
218 Ok(ft) => ft,
219 Err(_) => continue,
220 };
221 if ft.is_dir() {
222 walk_for_rollouts(&path, out)?;
223 } else if ft.is_file()
224 && path.extension().and_then(|e| e.to_str()) == Some("jsonl")
225 && path
226 .file_name()
227 .and_then(|n| n.to_str())
228 .map(|n| n.starts_with("rollout-"))
229 .unwrap_or(false)
230 {
231 out.push(path);
232 }
233 }
234 Ok(())
235}
236
237mod dirs {
238 use std::env;
239 use std::path::PathBuf;
240
241 pub fn home_dir() -> Option<PathBuf> {
242 env::var_os("HOME")
243 .or_else(|| env::var_os("USERPROFILE"))
244 .map(PathBuf::from)
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use tempfile::TempDir;
252
253 fn setup() -> (TempDir, PathResolver) {
254 let temp = TempDir::new().unwrap();
255 let codex = temp.path().join(".codex");
256 fs::create_dir_all(&codex).unwrap();
257 let resolver = PathResolver::new()
258 .with_home(temp.path())
259 .with_codex_dir(&codex);
260 (temp, resolver)
261 }
262
263 #[test]
264 fn codex_dir_defaults_to_home() {
265 let temp = TempDir::new().unwrap();
266 let r = PathResolver::new().with_home(temp.path());
267 assert_eq!(r.codex_dir().unwrap(), temp.path().join(".codex"));
268 }
269
270 #[test]
271 fn sessions_root_under_codex_dir() {
272 let (_t, r) = setup();
273 assert!(r.sessions_root().unwrap().ends_with(".codex/sessions"));
274 }
275
276 #[test]
277 fn list_rollouts_walks_date_tree() {
278 let (_t, r) = setup();
279 let day = r.sessions_root().unwrap().join("2026/04/20");
280 fs::create_dir_all(&day).unwrap();
281 fs::write(day.join("rollout-2026-04-20T10-00-00-aaa.jsonl"), "{}").unwrap();
282 fs::write(day.join("rollout-2026-04-20T11-00-00-bbb.jsonl"), "{}").unwrap();
283 fs::write(day.join("other.jsonl"), "{}").unwrap();
285 fs::write(day.join("rollout-2026-04-20T12-00-00-ccc.txt"), "{}").unwrap();
287
288 let files = r.list_rollout_files().unwrap();
289 assert_eq!(files.len(), 2);
290 let names: Vec<_> = files
291 .iter()
292 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
293 .collect();
294 assert!(names.iter().any(|n| n.contains("aaa")));
295 assert!(names.iter().any(|n| n.contains("bbb")));
296 }
297
298 #[test]
299 fn list_rollouts_empty_when_no_sessions() {
300 let (_t, r) = setup();
301 assert!(r.list_rollout_files().unwrap().is_empty());
302 }
303
304 #[test]
305 fn find_rollout_by_full_stem() {
306 let (_t, r) = setup();
307 let day = r.sessions_root().unwrap().join("2026/04/20");
308 fs::create_dir_all(&day).unwrap();
309 let stem = "rollout-2026-04-20T10-00-00-abc-xyz";
310 fs::write(day.join(format!("{}.jsonl", stem)), "{}").unwrap();
311 let p = r.find_rollout_file(stem).unwrap();
312 assert_eq!(p.file_stem().unwrap(), stem);
313 }
314
315 #[test]
316 fn find_rollout_by_uuid_suffix() {
317 let (_t, r) = setup();
318 let day = r.sessions_root().unwrap().join("2026/04/20");
319 fs::create_dir_all(&day).unwrap();
320 fs::write(
321 day.join("rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d.jsonl"),
322 "{}",
323 )
324 .unwrap();
325 let p = r
326 .find_rollout_file("019dabc6-8fef-7681-a054-b5bb75fcb97d")
327 .unwrap();
328 assert!(
329 p.to_string_lossy()
330 .contains("019dabc6-8fef-7681-a054-b5bb75fcb97d")
331 );
332 }
333
334 #[test]
335 fn find_rollout_by_short_prefix() {
336 let (_t, r) = setup();
337 let day = r.sessions_root().unwrap().join("2026/04/20");
338 fs::create_dir_all(&day).unwrap();
339 fs::write(
340 day.join("rollout-2026-04-20T10-00-00-019dabc6-unique.jsonl"),
341 "{}",
342 )
343 .unwrap();
344 let p = r.find_rollout_file("019dabc6-unique").unwrap();
345 assert!(p.exists());
346 }
347
348 #[test]
349 fn is_uuid_shape_accepts_v7() {
350 assert!(is_uuid_shape("019dabc6-8fef-7681-a054-b5bb75fcb97d"));
351 assert!(!is_uuid_shape("019dabc6-8fef-7681-a054-b5bb75fcb97")); assert!(!is_uuid_shape("zzz"));
353 }
354
355 #[test]
356 fn session_id_from_stem_extracts_uuid_or_passes_through() {
357 assert_eq!(
358 session_id_from_stem(
359 "rollout-2026-04-20T12-43-30-019dabc6-8fef-7681-a054-b5bb75fcb97d"
360 ),
361 "019dabc6-8fef-7681-a054-b5bb75fcb97d"
362 );
363 assert_eq!(
364 session_id_from_stem("rollout-2026-04-20T10-00-00-abc-xyz"),
365 "rollout-2026-04-20T10-00-00-abc-xyz"
366 );
367 assert_eq!(session_id_from_stem("not-a-rollout"), "not-a-rollout");
368 }
369
370 #[test]
373 fn find_rollout_stem_in_mismatched_date_dir_falls_back_to_walk() {
374 let (_t, r) = setup();
375 let day = r.sessions_root().unwrap().join("2026/04/21");
376 fs::create_dir_all(&day).unwrap();
377 let stem = "rollout-2026-04-20T10-00-00-abc-xyz";
378 fs::write(day.join(format!("{}.jsonl", stem)), "{}").unwrap();
379 let p = r.find_rollout_file(stem).unwrap();
380 assert_eq!(p.file_stem().unwrap(), stem);
381 }
382
383 #[test]
384 fn find_rollout_missing_errors() {
385 let (_t, r) = setup();
386 let err = r.find_rollout_file("does-not-exist").unwrap_err();
387 assert!(matches!(err, ConvoError::SessionNotFound(_)));
388 }
389
390 #[test]
391 fn find_rollout_ambiguous_prefix_errors() {
392 let (_t, r) = setup();
393 let day = r.sessions_root().unwrap().join("2026/04/20");
394 fs::create_dir_all(&day).unwrap();
395 fs::write(
396 day.join("rollout-2026-04-20T10-00-00-019dabc6-a.jsonl"),
397 "{}",
398 )
399 .unwrap();
400 fs::write(
401 day.join("rollout-2026-04-20T11-00-00-019dabc6-b.jsonl"),
402 "{}",
403 )
404 .unwrap();
405 let err = r.find_rollout_file("019dabc6").unwrap_err();
406 assert!(matches!(err, ConvoError::SessionNotFound(_)));
407 }
408
409 #[test]
410 fn history_and_log_file_paths() {
411 let (t, r) = setup();
412 assert_eq!(
413 r.history_file().unwrap(),
414 t.path().join(".codex/history.jsonl")
415 );
416 assert_eq!(
417 r.log_file().unwrap(),
418 t.path().join(".codex/log/codex-tui.log")
419 );
420 }
421
422 #[test]
423 fn exists_reflects_codex_dir() {
424 let (_t, r) = setup();
425 assert!(r.exists());
426 let missing = PathResolver::new().with_codex_dir("/never/exists");
427 assert!(!missing.exists());
428 }
429}