1use crate::error::{ConvoError, Result};
9use crate::types::{RolloutLine, Session};
10use std::fs::File;
11use std::io::{BufRead, BufReader};
12use std::path::{Path, PathBuf};
13
14pub struct RolloutReader;
15
16impl RolloutReader {
17 pub fn read_session<P: AsRef<Path>>(path: P) -> Result<Session> {
22 let path = path.as_ref();
23 if !path.exists() {
24 return Err(ConvoError::SessionNotFound(path.display().to_string()));
25 }
26
27 let file = File::open(path)?;
28 let reader = BufReader::new(file);
29 let mut lines: Vec<RolloutLine> = Vec::new();
30 for (idx, raw) in reader.lines().enumerate() {
31 let raw = match raw {
32 Ok(s) => s,
33 Err(e) => {
34 eprintln!(
35 "Warning: IO error reading {} line {}: {}",
36 path.display(),
37 idx + 1,
38 e
39 );
40 continue;
41 }
42 };
43 if raw.trim().is_empty() {
44 continue;
45 }
46 match serde_json::from_str::<RolloutLine>(&raw) {
47 Ok(line) => lines.push(line),
48 Err(e) => {
49 if std::env::var("CODEX_ROLLOUT_STRICT").is_ok() {
52 return Err(ConvoError::Json(e));
53 }
54 eprintln!(
55 "Warning: unparseable rollout line {} in {}: {}",
56 idx + 1,
57 path.file_name().and_then(|n| n.to_str()).unwrap_or("<?>"),
58 e
59 );
60 }
61 }
62 }
63
64 let id = Self::derive_session_id(&lines, path);
65 Ok(Session {
66 id,
67 file_path: path.to_path_buf(),
68 lines,
69 })
70 }
71
72 pub fn peek_session_id<P: AsRef<Path>>(path: P) -> Option<String> {
75 let file = File::open(path).ok()?;
76 let mut reader = BufReader::new(file);
77 let mut first = String::new();
78 reader.read_line(&mut first).ok()?;
79 let line: RolloutLine = serde_json::from_str(first.trim()).ok()?;
80 if line.kind != "session_meta" {
81 return None;
82 }
83 line.payload
84 .get("id")
85 .and_then(|v| v.as_str())
86 .map(str::to_string)
87 }
88
89 pub fn file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
91 let path = path.as_ref();
92 if !path.exists() {
93 return Err(ConvoError::SessionNotFound(path.display().to_string()));
94 }
95 Ok(std::fs::metadata(path)?.len())
96 }
97
98 fn derive_session_id(lines: &[RolloutLine], path: &Path) -> String {
99 if let Some(first) = lines.first()
101 && first.kind == "session_meta"
102 && let Some(id) = first.payload.get("id").and_then(|v| v.as_str())
103 {
104 return id.to_string();
105 }
106 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
108 return crate::paths::session_id_from_stem(stem).to_string();
109 }
110 "unknown".to_string()
111 }
112}
113
114pub type RolloutPath = PathBuf;
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use std::io::Write;
121 use tempfile::NamedTempFile;
122
123 fn sample_rollout() -> String {
124 [
125 r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli"}}"#,
126 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"019dabc7","cwd":"/tmp/proj"}}"#,
127 r#"{"timestamp":"2026-04-20T16:44:37.775Z","type":"event_msg","payload":{"type":"task_started","turn_id":"019dabc7"}}"#,
128 r#"{"timestamp":"2026-04-20T16:44:38.000Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}}"#,
129 ]
130 .join("\n")
131 }
132
133 fn write_fixture(body: &str) -> NamedTempFile {
134 let mut f = NamedTempFile::new().unwrap();
135 f.write_all(body.as_bytes()).unwrap();
136 f.flush().unwrap();
137 f
138 }
139
140 #[test]
141 fn read_session_basic() {
142 let f = write_fixture(&sample_rollout());
143 let s = RolloutReader::read_session(f.path()).unwrap();
144 assert_eq!(s.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
145 assert_eq!(s.lines.len(), 4);
146 assert!(s.meta().is_some());
147 }
148
149 #[test]
150 fn read_session_nonexistent_errors() {
151 let err = RolloutReader::read_session("/nonexistent").unwrap_err();
152 assert!(matches!(err, ConvoError::SessionNotFound(_)));
153 }
154
155 fn strict_env_lock() -> std::sync::MutexGuard<'static, ()> {
160 use std::sync::{Mutex, OnceLock};
161 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
162 LOCK.get_or_init(|| Mutex::new(()))
163 .lock()
164 .unwrap_or_else(|p| p.into_inner())
165 }
166
167 #[test]
168 fn read_session_handles_truncated_last_line() {
169 let _g = strict_env_lock();
170 unsafe { std::env::remove_var("CODEX_ROLLOUT_STRICT") };
173 let body = sample_rollout() + "\n{\"timestamp\":\"broken"; let f = write_fixture(&body);
176 let s = RolloutReader::read_session(f.path()).unwrap();
177 assert_eq!(s.lines.len(), 4, "truncated line dropped, others kept");
178 }
179
180 #[test]
181 fn read_session_respects_strict_env() {
182 let _g = strict_env_lock();
183 let body = sample_rollout() + "\n{\"timestamp\":\"broken";
184 let f = write_fixture(&body);
185 unsafe { std::env::set_var("CODEX_ROLLOUT_STRICT", "1") };
186 let err = RolloutReader::read_session(f.path()).unwrap_err();
187 unsafe { std::env::remove_var("CODEX_ROLLOUT_STRICT") };
188 assert!(matches!(err, ConvoError::Json(_)));
189 }
190
191 #[test]
192 fn peek_session_id_reads_first_line_only() {
193 let f = write_fixture(&sample_rollout());
194 let id = RolloutReader::peek_session_id(f.path()).unwrap();
195 assert_eq!(id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
196 }
197
198 #[test]
199 fn peek_session_id_missing_when_first_line_not_meta() {
200 let body = r#"{"timestamp":"t","type":"event_msg","payload":{"type":"x"}}"#;
201 let f = write_fixture(body);
202 assert!(RolloutReader::peek_session_id(f.path()).is_none());
203 }
204
205 #[test]
206 fn session_started_at_and_last_activity() {
207 let f = write_fixture(&sample_rollout());
208 let s = RolloutReader::read_session(f.path()).unwrap();
209 assert!(s.started_at().is_some());
210 assert!(s.last_activity() >= s.started_at());
211 }
212
213 #[test]
214 fn session_first_user_text() {
215 let f = write_fixture(&sample_rollout());
216 let s = RolloutReader::read_session(f.path()).unwrap();
217 assert_eq!(s.first_user_text().as_deref(), Some("hello"));
218 }
219
220 #[test]
221 fn file_size_works() {
222 let f = write_fixture(&sample_rollout());
223 let size = RolloutReader::file_size(f.path()).unwrap();
224 assert!(size > 0);
225 }
226
227 #[test]
228 fn derive_session_id_falls_back_to_stem_uuid() {
229 let body = r#"{"timestamp":"t","type":"event_msg","payload":{"type":"x"}}"#;
230 let f = NamedTempFile::new().unwrap();
231 let path = f
232 .path()
233 .parent()
234 .unwrap()
235 .join("rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d.jsonl");
236 std::fs::write(&path, body).unwrap();
237 let s = RolloutReader::read_session(&path).unwrap();
238 assert_eq!(s.id, "019dabc6-8fef-7681-a054-b5bb75fcb97d");
239 drop(f);
241 let _ = std::fs::remove_file(path);
242 }
243}