1use 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 pub fn list_rollout_files(&self) -> Result<Vec<PathBuf>> {
39 self.resolver.list_rollout_files()
40 }
41
42 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 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 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 pub fn read_session_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Session> {
78 RolloutReader::read_session(path)
79 }
80
81 pub fn read_metadata<P: AsRef<std::path::Path>>(&self, path: P) -> Result<SessionMetadata> {
84 let path = path.as_ref();
85 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 #[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}