Skip to main content

toolpath_codex/
io.rs

1//! Higher-level filesystem operations over [`PathResolver`].
2
3use crate::error::Result;
4use crate::paths::PathResolver;
5use crate::reader::RolloutReader;
6use crate::types::{RolloutItem, Session, SessionMetadata};
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Default)]
10pub struct ConvoIO {
11    resolver: PathResolver,
12}
13
14impl ConvoIO {
15    pub fn new() -> Self {
16        Self {
17            resolver: PathResolver::new(),
18        }
19    }
20
21    pub fn with_resolver(resolver: PathResolver) -> Self {
22        Self { resolver }
23    }
24
25    pub fn resolver(&self) -> &PathResolver {
26        &self.resolver
27    }
28
29    pub fn exists(&self) -> bool {
30        self.resolver.exists()
31    }
32
33    pub fn codex_dir_path(&self) -> Result<PathBuf> {
34        self.resolver.codex_dir()
35    }
36
37    /// List every rollout file under `~/.codex/sessions/`, newest first.
38    pub fn list_rollout_files(&self) -> Result<Vec<PathBuf>> {
39        self.resolver.list_rollout_files()
40    }
41
42    /// Return lightweight metadata for every rollout, newest first.
43    pub fn list_sessions(&self) -> Result<Vec<SessionMetadata>> {
44        let files = self.list_rollout_files()?;
45        let mut metas = Vec::with_capacity(files.len());
46        for path in files {
47            match self.read_metadata(&path) {
48                Ok(m) => metas.push(m),
49                Err(e) => {
50                    eprintln!("Warning: failed to read {}: {}", path.display(), e);
51                }
52            }
53        }
54        metas.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
55        Ok(metas)
56    }
57
58    /// Read one session by id or filename stem.
59    pub fn read_session(&self, session_id: &str) -> Result<Session> {
60        let path = self.resolver.find_rollout_file(session_id)?;
61        RolloutReader::read_session(&path)
62    }
63
64    /// Read one session by absolute path.
65    pub fn read_session_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Session> {
66        RolloutReader::read_session(path)
67    }
68
69    /// Cheap per-file metadata: parses the session_meta line + walks
70    /// the file for first/last timestamps.
71    pub fn read_metadata<P: AsRef<std::path::Path>>(&self, path: P) -> Result<SessionMetadata> {
72        let path = path.as_ref();
73        // Full parse is simplest; rollout files are small (typical
74        // session 200-300 KB). If that becomes a bottleneck we'd peek
75        // the first line plus `stat` for mtime.
76        let session = RolloutReader::read_session(path)?;
77
78        let meta_line = session.items().find_map(|item| match item {
79            RolloutItem::SessionMeta(m) => Some(m),
80            _ => None,
81        });
82
83        let (cwd, cli_version, git_branch, git_commit) = match &meta_line {
84            Some(m) => (
85                Some(m.cwd.clone()),
86                Some(m.cli_version.clone()),
87                m.git.as_ref().and_then(|g| g.branch.clone()),
88                m.git.as_ref().and_then(|g| g.commit_hash.clone()),
89            ),
90            None => (None, None, None, None),
91        };
92
93        Ok(SessionMetadata {
94            id: session.id.clone(),
95            file_path: session.file_path.clone(),
96            started_at: session.started_at(),
97            last_activity: session.last_activity(),
98            cwd,
99            cli_version,
100            first_user_message: session.first_user_text(),
101            git_branch,
102            git_commit,
103            line_count: session.lines.len(),
104        })
105    }
106
107    pub fn session_exists(&self, session_id: &str) -> bool {
108        self.resolver.find_rollout_file(session_id).is_ok()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::fs;
116    use tempfile::TempDir;
117
118    fn setup() -> (TempDir, ConvoIO) {
119        let temp = TempDir::new().unwrap();
120        let codex = temp.path().join(".codex");
121        let day = codex.join("sessions/2026/04/20");
122        fs::create_dir_all(&day).unwrap();
123        let body = [
124            r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-aaa","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli","git":{"commit_hash":"abc","branch":"main"}}}"#,
125            r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#,
126        ]
127        .join("\n");
128        fs::write(
129            day.join("rollout-2026-04-20T10-00-00-019dabc6-aaa.jsonl"),
130            body,
131        )
132        .unwrap();
133
134        let resolver = PathResolver::new().with_codex_dir(&codex);
135        (temp, ConvoIO::with_resolver(resolver))
136    }
137
138    #[test]
139    fn lists_rollouts() {
140        let (_t, io) = setup();
141        let files = io.list_rollout_files().unwrap();
142        assert_eq!(files.len(), 1);
143    }
144
145    #[test]
146    fn list_sessions_returns_metadata() {
147        let (_t, io) = setup();
148        let sessions = io.list_sessions().unwrap();
149        assert_eq!(sessions.len(), 1);
150        assert_eq!(sessions[0].id, "019dabc6-aaa");
151        assert_eq!(sessions[0].first_user_message.as_deref(), Some("hi"));
152        assert_eq!(sessions[0].git_branch.as_deref(), Some("main"));
153        assert_eq!(sessions[0].git_commit.as_deref(), Some("abc"));
154        assert_eq!(sessions[0].cli_version.as_deref(), Some("0.118.0"));
155    }
156
157    #[test]
158    fn read_session_by_id() {
159        let (_t, io) = setup();
160        let s = io.read_session("019dabc6-aaa").unwrap();
161        assert_eq!(s.lines.len(), 2);
162    }
163
164    #[test]
165    fn read_session_by_partial_uuid() {
166        let (_t, io) = setup();
167        let s = io.read_session("019dabc6").unwrap();
168        assert_eq!(s.id, "019dabc6-aaa");
169    }
170
171    #[test]
172    fn session_exists() {
173        let (_t, io) = setup();
174        assert!(io.session_exists("019dabc6-aaa"));
175        assert!(!io.session_exists("nope"));
176    }
177
178    #[test]
179    fn metadata_line_count_accurate() {
180        let (_t, io) = setup();
181        let metas = io.list_sessions().unwrap();
182        assert_eq!(metas[0].line_count, 2);
183    }
184
185    #[test]
186    fn list_sessions_empty_when_no_root() {
187        let temp = TempDir::new().unwrap();
188        let codex = temp.path().join(".codex");
189        fs::create_dir_all(&codex).unwrap();
190        let io = ConvoIO::with_resolver(PathResolver::new().with_codex_dir(&codex));
191        assert!(io.list_sessions().unwrap().is_empty());
192    }
193}