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    /// List every session id (the rollout filename stem, which
43    /// [`Self::read_session`] resolves without a tree walk), newest
44    /// first. One directory walk, no file reads — unlike
45    /// [`Self::list_sessions`], which parses every file for metadata.
46    pub fn list_session_ids(&self) -> Result<Vec<String>> {
47        Ok(self
48            .list_rollout_files()?
49            .iter()
50            .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from))
51            .collect())
52    }
53
54    /// Return lightweight metadata for every rollout, newest first.
55    pub fn list_sessions(&self) -> Result<Vec<SessionMetadata>> {
56        let files = self.list_rollout_files()?;
57        let mut metas = Vec::with_capacity(files.len());
58        for path in files {
59            match self.read_metadata(&path) {
60                Ok(m) => metas.push(m),
61                Err(e) => {
62                    eprintln!("Warning: failed to read {}: {}", path.display(), e);
63                }
64            }
65        }
66        metas.sort_by_key(|m| std::cmp::Reverse(m.last_activity));
67        Ok(metas)
68    }
69
70    /// Read one session by id or filename stem.
71    pub fn read_session(&self, session_id: &str) -> Result<Session> {
72        let path = self.resolver.find_rollout_file(session_id)?;
73        RolloutReader::read_session(&path)
74    }
75
76    /// Read one session by absolute path.
77    pub fn read_session_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Session> {
78        RolloutReader::read_session(path)
79    }
80
81    /// Cheap per-file metadata: parses the session_meta line + walks
82    /// the file for first/last timestamps.
83    pub fn read_metadata<P: AsRef<std::path::Path>>(&self, path: P) -> Result<SessionMetadata> {
84        let path = path.as_ref();
85        // Full parse is simplest; rollout files are small (typical
86        // session 200-300 KB). If that becomes a bottleneck we'd peek
87        // the first line plus `stat` for mtime.
88        let session = RolloutReader::read_session(path)?;
89
90        let meta_line = session.items().find_map(|item| match item {
91            RolloutItem::SessionMeta(m) => Some(m),
92            _ => None,
93        });
94
95        let (cwd, cli_version, git_branch, git_commit) = match &meta_line {
96            Some(m) => (
97                Some(m.cwd.clone()),
98                Some(m.cli_version.clone()),
99                m.git.as_ref().and_then(|g| g.branch.clone()),
100                m.git.as_ref().and_then(|g| g.commit_hash.clone()),
101            ),
102            None => (None, None, None, None),
103        };
104
105        Ok(SessionMetadata {
106            id: session.id.clone(),
107            file_path: session.file_path.clone(),
108            started_at: session.started_at(),
109            last_activity: session.last_activity(),
110            cwd,
111            cli_version,
112            first_user_message: session.first_user_text(),
113            git_branch,
114            git_commit,
115            line_count: session.lines.len(),
116        })
117    }
118
119    pub fn session_exists(&self, session_id: &str) -> bool {
120        self.resolver.find_rollout_file(session_id).is_ok()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::fs;
128    use tempfile::TempDir;
129
130    fn setup() -> (TempDir, ConvoIO) {
131        let temp = TempDir::new().unwrap();
132        let codex = temp.path().join(".codex");
133        let day = codex.join("sessions/2026/04/20");
134        fs::create_dir_all(&day).unwrap();
135        let body = [
136            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"}}}"#,
137            r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#,
138        ]
139        .join("\n");
140        fs::write(
141            day.join("rollout-2026-04-20T10-00-00-019dabc6-aaa.jsonl"),
142            body,
143        )
144        .unwrap();
145
146        let resolver = PathResolver::new().with_codex_dir(&codex);
147        (temp, ConvoIO::with_resolver(resolver))
148    }
149
150    #[test]
151    fn lists_rollouts() {
152        let (_t, io) = setup();
153        let files = io.list_rollout_files().unwrap();
154        assert_eq!(files.len(), 1);
155    }
156
157    #[test]
158    fn list_sessions_returns_metadata() {
159        let (_t, io) = setup();
160        let sessions = io.list_sessions().unwrap();
161        assert_eq!(sessions.len(), 1);
162        assert_eq!(sessions[0].id, "019dabc6-aaa");
163        assert_eq!(sessions[0].first_user_message.as_deref(), Some("hi"));
164        assert_eq!(sessions[0].git_branch.as_deref(), Some("main"));
165        assert_eq!(sessions[0].git_commit.as_deref(), Some("abc"));
166        assert_eq!(sessions[0].cli_version.as_deref(), Some("0.118.0"));
167    }
168
169    /// Ids come from filenames alone, so even a file whose body would
170    /// fail to parse is listed; the failure surfaces on read instead.
171    #[test]
172    fn list_session_ids_returns_stems_without_reading_bodies() {
173        let (_t, io) = setup();
174        let day = io.resolver().sessions_root().unwrap().join("2026/04/21");
175        fs::create_dir_all(&day).unwrap();
176        fs::write(day.join("rollout-2026-04-21T09-00-00-bbb.jsonl"), "not json").unwrap();
177
178        let ids = io.list_session_ids().unwrap();
179        assert_eq!(ids.len(), 2);
180        assert!(ids.contains(&"rollout-2026-04-20T10-00-00-019dabc6-aaa".to_string()));
181        assert!(ids.contains(&"rollout-2026-04-21T09-00-00-bbb".to_string()));
182        for id in &ids {
183            assert!(io.read_session(id).is_ok() || id.contains("bbb"));
184        }
185    }
186
187    #[test]
188    fn read_session_by_id() {
189        let (_t, io) = setup();
190        let s = io.read_session("019dabc6-aaa").unwrap();
191        assert_eq!(s.lines.len(), 2);
192    }
193
194    #[test]
195    fn read_session_by_partial_uuid() {
196        let (_t, io) = setup();
197        let s = io.read_session("019dabc6").unwrap();
198        assert_eq!(s.id, "019dabc6-aaa");
199    }
200
201    #[test]
202    fn session_exists() {
203        let (_t, io) = setup();
204        assert!(io.session_exists("019dabc6-aaa"));
205        assert!(!io.session_exists("nope"));
206    }
207
208    #[test]
209    fn metadata_line_count_accurate() {
210        let (_t, io) = setup();
211        let metas = io.list_sessions().unwrap();
212        assert_eq!(metas[0].line_count, 2);
213    }
214
215    #[test]
216    fn list_sessions_empty_when_no_root() {
217        let temp = TempDir::new().unwrap();
218        let codex = temp.path().join(".codex");
219        fs::create_dir_all(&codex).unwrap();
220        let io = ConvoIO::with_resolver(PathResolver::new().with_codex_dir(&codex));
221        assert!(io.list_sessions().unwrap().is_empty());
222    }
223}