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> {
104 let all = self.list_rollout_files()?;
105 for p in &all {
107 if let Some(stem) = p.file_stem().and_then(|s| s.to_str())
108 && stem == session_id
109 {
110 return Ok(p.clone());
111 }
112 }
113 let matches: Vec<&PathBuf> = all
115 .iter()
116 .filter(|p| {
117 p.file_stem()
118 .and_then(|s| s.to_str())
119 .map(|s| s.contains(session_id))
120 .unwrap_or(false)
121 })
122 .collect();
123 match matches.len() {
124 0 => Err(ConvoError::SessionNotFound(session_id.to_string())),
125 1 => Ok(matches[0].clone()),
126 _ => Err(ConvoError::SessionNotFound(format!(
127 "{} (ambiguous — {} matches)",
128 session_id,
129 matches.len()
130 ))),
131 }
132 }
133}
134
135fn walk_for_rollouts(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
137 for entry in fs::read_dir(dir)?.flatten() {
138 let path = entry.path();
139 let ft = match entry.file_type() {
140 Ok(ft) => ft,
141 Err(_) => continue,
142 };
143 if ft.is_dir() {
144 walk_for_rollouts(&path, out)?;
145 } else if ft.is_file()
146 && path.extension().and_then(|e| e.to_str()) == Some("jsonl")
147 && path
148 .file_name()
149 .and_then(|n| n.to_str())
150 .map(|n| n.starts_with("rollout-"))
151 .unwrap_or(false)
152 {
153 out.push(path);
154 }
155 }
156 Ok(())
157}
158
159mod dirs {
160 use std::env;
161 use std::path::PathBuf;
162
163 pub fn home_dir() -> Option<PathBuf> {
164 env::var_os("HOME")
165 .or_else(|| env::var_os("USERPROFILE"))
166 .map(PathBuf::from)
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use tempfile::TempDir;
174
175 fn setup() -> (TempDir, PathResolver) {
176 let temp = TempDir::new().unwrap();
177 let codex = temp.path().join(".codex");
178 fs::create_dir_all(&codex).unwrap();
179 let resolver = PathResolver::new()
180 .with_home(temp.path())
181 .with_codex_dir(&codex);
182 (temp, resolver)
183 }
184
185 #[test]
186 fn codex_dir_defaults_to_home() {
187 let temp = TempDir::new().unwrap();
188 let r = PathResolver::new().with_home(temp.path());
189 assert_eq!(r.codex_dir().unwrap(), temp.path().join(".codex"));
190 }
191
192 #[test]
193 fn sessions_root_under_codex_dir() {
194 let (_t, r) = setup();
195 assert!(r.sessions_root().unwrap().ends_with(".codex/sessions"));
196 }
197
198 #[test]
199 fn list_rollouts_walks_date_tree() {
200 let (_t, r) = setup();
201 let day = r.sessions_root().unwrap().join("2026/04/20");
202 fs::create_dir_all(&day).unwrap();
203 fs::write(day.join("rollout-2026-04-20T10-00-00-aaa.jsonl"), "{}").unwrap();
204 fs::write(day.join("rollout-2026-04-20T11-00-00-bbb.jsonl"), "{}").unwrap();
205 fs::write(day.join("other.jsonl"), "{}").unwrap();
207 fs::write(day.join("rollout-2026-04-20T12-00-00-ccc.txt"), "{}").unwrap();
209
210 let files = r.list_rollout_files().unwrap();
211 assert_eq!(files.len(), 2);
212 let names: Vec<_> = files
213 .iter()
214 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
215 .collect();
216 assert!(names.iter().any(|n| n.contains("aaa")));
217 assert!(names.iter().any(|n| n.contains("bbb")));
218 }
219
220 #[test]
221 fn list_rollouts_empty_when_no_sessions() {
222 let (_t, r) = setup();
223 assert!(r.list_rollout_files().unwrap().is_empty());
224 }
225
226 #[test]
227 fn find_rollout_by_full_stem() {
228 let (_t, r) = setup();
229 let day = r.sessions_root().unwrap().join("2026/04/20");
230 fs::create_dir_all(&day).unwrap();
231 let stem = "rollout-2026-04-20T10-00-00-abc-xyz";
232 fs::write(day.join(format!("{}.jsonl", stem)), "{}").unwrap();
233 let p = r.find_rollout_file(stem).unwrap();
234 assert_eq!(p.file_stem().unwrap(), stem);
235 }
236
237 #[test]
238 fn find_rollout_by_uuid_suffix() {
239 let (_t, r) = setup();
240 let day = r.sessions_root().unwrap().join("2026/04/20");
241 fs::create_dir_all(&day).unwrap();
242 fs::write(
243 day.join("rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d.jsonl"),
244 "{}",
245 )
246 .unwrap();
247 let p = r
248 .find_rollout_file("019dabc6-8fef-7681-a054-b5bb75fcb97d")
249 .unwrap();
250 assert!(
251 p.to_string_lossy()
252 .contains("019dabc6-8fef-7681-a054-b5bb75fcb97d")
253 );
254 }
255
256 #[test]
257 fn find_rollout_by_short_prefix() {
258 let (_t, r) = setup();
259 let day = r.sessions_root().unwrap().join("2026/04/20");
260 fs::create_dir_all(&day).unwrap();
261 fs::write(
262 day.join("rollout-2026-04-20T10-00-00-019dabc6-unique.jsonl"),
263 "{}",
264 )
265 .unwrap();
266 let p = r.find_rollout_file("019dabc6-unique").unwrap();
267 assert!(p.exists());
268 }
269
270 #[test]
271 fn find_rollout_missing_errors() {
272 let (_t, r) = setup();
273 let err = r.find_rollout_file("does-not-exist").unwrap_err();
274 assert!(matches!(err, ConvoError::SessionNotFound(_)));
275 }
276
277 #[test]
278 fn find_rollout_ambiguous_prefix_errors() {
279 let (_t, r) = setup();
280 let day = r.sessions_root().unwrap().join("2026/04/20");
281 fs::create_dir_all(&day).unwrap();
282 fs::write(
283 day.join("rollout-2026-04-20T10-00-00-019dabc6-a.jsonl"),
284 "{}",
285 )
286 .unwrap();
287 fs::write(
288 day.join("rollout-2026-04-20T11-00-00-019dabc6-b.jsonl"),
289 "{}",
290 )
291 .unwrap();
292 let err = r.find_rollout_file("019dabc6").unwrap_err();
293 assert!(matches!(err, ConvoError::SessionNotFound(_)));
294 }
295
296 #[test]
297 fn history_and_log_file_paths() {
298 let (t, r) = setup();
299 assert_eq!(
300 r.history_file().unwrap(),
301 t.path().join(".codex/history.jsonl")
302 );
303 assert_eq!(
304 r.log_file().unwrap(),
305 t.path().join(".codex/log/codex-tui.log")
306 );
307 }
308
309 #[test]
310 fn exists_reflects_codex_dir() {
311 let (_t, r) = setup();
312 assert!(r.exists());
313 let missing = PathResolver::new().with_codex_dir("/never/exists");
314 assert!(!missing.exists());
315 }
316}