Skip to main content

toolpath_codex/
paths.rs

1//! Filesystem layout for Codex CLI state.
2//!
3//! Sessions live at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
4//! Unlike Claude and Gemini, Codex sessions aren't keyed by project
5//! path — they're global, bucketed by date. A session is identified
6//! by its UUIDv7 (`session_meta.id`) or equivalently by its filename
7//! stem (`rollout-<timestamp>-<uuid>`).
8
9use 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/// Builder-style resolver over the `~/.codex/` filesystem.
18#[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    /// Override the codex directory directly (defaults to `~/.codex`).
44    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    /// Enumerate every `rollout-*.jsonl` file under `sessions/`, newest
77    /// first by file mtime.
78    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        // Sort newest first by mtime.
86        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    /// Resolve a session identifier to a rollout file path.
98    ///
99    /// Accepts:
100    /// - A full filename stem: `rollout-2026-04-20T12-43-30-019dabc6-8fef-7681-a054-b5bb75fcb97d`
101    /// - A bare session UUID (suffix match): `019dabc6-8fef-7681-a054-b5bb75fcb97d`
102    /// - A short UUID prefix: `019dabc6` (resolves if unique)
103    pub fn find_rollout_file(&self, session_id: &str) -> Result<PathBuf> {
104        let all = self.list_rollout_files()?;
105        // Exact filename stem match first.
106        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        // UUID suffix match (full or partial prefix).
114        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
135/// Recursively collect `rollout-*.jsonl` files under `root`.
136fn 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        // Non-rollout file is ignored.
206        fs::write(day.join("other.jsonl"), "{}").unwrap();
207        // Non-jsonl rollout file is ignored.
208        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}