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_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 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 pub fn read_session_path<P: AsRef<std::path::Path>>(&self, path: P) -> Result<Session> {
66 RolloutReader::read_session(path)
67 }
68
69 pub fn read_metadata<P: AsRef<std::path::Path>>(&self, path: P) -> Result<SessionMetadata> {
72 let path = path.as_ref();
73 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}