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    ///   (resolved without walking the tree — the stem's date names its
102    ///   `YYYY/MM/DD` bucket)
103    /// - A bare session UUID (suffix match): `019dabc6-8fef-7681-a054-b5bb75fcb97d`
104    /// - A short UUID prefix: `019dabc6` (resolves if unique)
105    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        // Exact filename stem match first.
111        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        // UUID suffix match (full or partial prefix).
119        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    /// Direct lookup for a full filename stem: `rollout-<YYYY-MM-DD>T…`
140    /// lives at `sessions/YYYY/MM/DD/<stem>.jsonl`. `None` when the id
141    /// isn't stem-shaped or the file isn't at its dated path (the caller
142    /// falls back to the tree walk).
143    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
164/// The session id embedded in a rollout filename stem
165/// (`rollout-<timestamp>-<uuid>`): the trailing UUID when present,
166/// otherwise the whole stem. This is the same fallback identity the
167/// reader assigns a session whose file has no `session_meta` line, so
168/// ids derived from filenames agree with `Session.id` — and either
169/// form resolves through [`PathResolver::find_rollout_file`].
170pub 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
177/// Position of the first 36-char run matching a UUID shape
178/// (8-4-4-4-12 hex groups) in the stem.
179fn 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
213/// Recursively collect `rollout-*.jsonl` files under `root`.
214fn 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        // Non-rollout file is ignored.
284        fs::write(day.join("other.jsonl"), "{}").unwrap();
285        // Non-jsonl rollout file is ignored.
286        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")); // too short
352        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    /// A stem whose date doesn't match the directory it sits in misses
371    /// the direct dated-path lookup and must still resolve via the walk.
372    #[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}