1use crate::format::Format;
2use anyhow::{Context, Result};
3use std::io::{self, BufRead, Write};
4use std::path::{Path, PathBuf};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7#[derive(Debug, Clone)]
8pub struct SessionFile {
9 pub path: PathBuf,
10 pub format: Format,
11 pub modified_secs: Option<u64>,
12}
13
14pub fn discover_all() -> Vec<SessionFile> {
17 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
18 return Vec::new();
19 };
20
21 let mut files = Vec::new();
22 files.extend(discover_claude_code(&home));
23 files.extend(discover_codex(&home));
24 files.extend(discover_gemini(&home));
25
26 files.sort_by_key(|f| std::cmp::Reverse(f.modified_secs));
29 files
30}
31
32fn discover_claude_code(home: &Path) -> Vec<SessionFile> {
33 let root = home.join(".claude").join("projects");
35 let mut out = Vec::new();
36 let Ok(projects) = std::fs::read_dir(&root) else {
37 return out;
38 };
39 for project in projects.flatten() {
40 let Ok(files) = std::fs::read_dir(project.path()) else {
41 continue;
42 };
43 for file in files.flatten() {
44 let path = file.path();
45 if path.extension().and_then(|e| e.to_str()) == Some("jsonl")
46 && let Some(modified_secs) = mtime_secs(&file)
47 {
48 out.push(SessionFile {
49 path,
50 format: Format::ClaudeCode,
51 modified_secs: Some(modified_secs),
52 });
53 }
54 }
55 }
56 out
57}
58
59fn discover_codex(home: &Path) -> Vec<SessionFile> {
60 let root = home.join(".codex").join("sessions");
62 let mut out = Vec::new();
63 walk_depth(&root, 3, &mut |entry| {
64 let path = entry.path();
65 let name_ok = path
66 .file_name()
67 .and_then(|f| f.to_str())
68 .is_some_and(|n| n.starts_with("rollout-"));
69 let ext_ok = path
70 .extension()
71 .and_then(|e| e.to_str())
72 .is_some_and(|e| e.eq_ignore_ascii_case("jsonl"));
73 if name_ok
74 && ext_ok
75 && let Some(modified_secs) = mtime_secs(entry)
76 {
77 out.push(SessionFile {
78 path,
79 format: Format::Codex,
80 modified_secs: Some(modified_secs),
81 });
82 }
83 });
84 out
85}
86
87fn discover_gemini(home: &Path) -> Vec<SessionFile> {
88 let root = home.join(".gemini").join("tmp");
90 let mut out = Vec::new();
91 let Ok(projects) = std::fs::read_dir(&root) else {
92 return out;
93 };
94 for project in projects.flatten() {
95 let chats = project.path().join("chats");
96 let Ok(files) = std::fs::read_dir(&chats) else {
97 continue;
98 };
99 for file in files.flatten() {
100 let path = file.path();
101 let name_ok = path
102 .file_name()
103 .and_then(|f| f.to_str())
104 .is_some_and(|n| n.starts_with("session-"));
105 let ext_ok = path
106 .extension()
107 .and_then(|e| e.to_str())
108 .is_some_and(|e| e.eq_ignore_ascii_case("json"));
109 if name_ok
110 && ext_ok
111 && let Some(modified_secs) = mtime_secs(&file)
112 {
113 out.push(SessionFile {
114 path,
115 format: Format::Gemini,
116 modified_secs: Some(modified_secs),
117 });
118 }
119 }
120 }
121 out
122}
123
124fn walk_depth(root: &Path, depth: usize, visit: &mut dyn FnMut(&std::fs::DirEntry)) {
125 let Ok(entries) = std::fs::read_dir(root) else {
126 return;
127 };
128 for entry in entries.flatten() {
129 let path = entry.path();
130 if path.is_dir() {
131 if depth > 0 {
132 walk_depth(&path, depth - 1, visit);
133 }
134 } else {
135 visit(&entry);
136 }
137 }
138}
139
140fn mtime_secs(entry: &std::fs::DirEntry) -> Option<u64> {
141 let meta = entry.metadata().ok()?;
142 let modified = meta.modified().ok()?;
143 modified
144 .duration_since(UNIX_EPOCH)
145 .ok()
146 .map(|d| d.as_secs())
147}
148
149pub fn format_relative_time(modified_secs: Option<u64>) -> String {
151 let Some(m) = modified_secs else {
152 return "?".into();
153 };
154 let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
155 return "?".into();
156 };
157 let now_secs = now.as_secs();
158 if m > now_secs {
159 return "future".into();
160 }
161 let delta = now_secs - m;
162 if delta < 60 {
163 "just now".into()
164 } else if delta < 3_600 {
165 format!("{}m ago", delta / 60)
166 } else if delta < 86_400 {
167 format!("{}h ago", delta / 3_600)
168 } else if delta < 2_592_000 {
169 format!("{}d ago", delta / 86_400)
170 } else {
171 format!("{}mo ago", delta / 2_592_000)
172 }
173}
174
175fn short_path(path: &Path) -> String {
176 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from)
177 && let Ok(rest) = path.strip_prefix(&home)
178 {
179 return format!("~/{}", rest.display());
180 }
181 path.display().to_string()
182}
183
184pub fn prompt_user_to_choose(files: &[SessionFile]) -> Result<Option<PathBuf>> {
188 const MAX_SHOWN: usize = 30;
189 if files.is_empty() {
190 println!("agx: no session files found in ~/.claude, ~/.codex, or ~/.gemini");
191 return Ok(None);
192 }
193
194 let shown = files.len().min(MAX_SHOWN);
195 println!(
196 "agx — {} recent session(s) found, showing {shown}:\n",
197 files.len()
198 );
199 for (i, f) in files.iter().take(MAX_SHOWN).enumerate() {
200 let format_tag = match f.format {
201 Format::ClaudeCode => "[Claude]",
202 Format::Codex => "[Codex ]",
203 Format::Gemini => "[Gemini]",
204 Format::Generic => "[Generic]",
205 Format::Langchain => "[LChain]",
206 Format::OtelJson => "[OTelJS]",
207 Format::OtelProto => "[OTelPB]",
208 Format::VercelAi => "[Vercel]",
209 };
210 let when = format_relative_time(f.modified_secs);
211 let display_path = short_path(&f.path);
212 println!(" {:>3}. {format_tag} {:>9} {display_path}", i + 1, when);
213 }
214 if files.len() > MAX_SHOWN {
215 println!(" ... ({} more, not shown)", files.len() - MAX_SHOWN);
216 }
217 print!("\nEnter number (1-{shown}) or q to quit: ");
218 io::stdout().flush().context("flushing stdout")?;
219
220 let mut line = String::new();
221 let stdin = io::stdin();
222 let read = stdin.lock().read_line(&mut line).context("reading stdin")?;
223 if read == 0 {
224 return Ok(None);
226 }
227 let trimmed = line.trim();
228 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("q") {
229 return Ok(None);
230 }
231 match trimmed.parse::<usize>() {
232 Ok(n) if n >= 1 && n <= shown => Ok(Some(files[n - 1].path.clone())),
233 _ => {
234 println!("agx: invalid selection '{trimmed}'");
235 Ok(None)
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn format_relative_time_handles_recent() {
246 let now = SystemTime::now()
248 .duration_since(UNIX_EPOCH)
249 .unwrap()
250 .as_secs();
251 assert_eq!(format_relative_time(Some(now - 30)), "just now");
252 }
253
254 #[test]
255 fn format_relative_time_handles_minutes() {
256 let now = SystemTime::now()
257 .duration_since(UNIX_EPOCH)
258 .unwrap()
259 .as_secs();
260 let s = format_relative_time(Some(now - 120));
261 assert!(s.ends_with("m ago"));
262 }
263
264 #[test]
265 fn format_relative_time_handles_hours() {
266 let now = SystemTime::now()
267 .duration_since(UNIX_EPOCH)
268 .unwrap()
269 .as_secs();
270 let s = format_relative_time(Some(now - 7_200));
271 assert!(s.ends_with("h ago"));
272 }
273
274 #[test]
275 fn format_relative_time_handles_days() {
276 let now = SystemTime::now()
277 .duration_since(UNIX_EPOCH)
278 .unwrap()
279 .as_secs();
280 let s = format_relative_time(Some(now - 172_800));
281 assert!(s.ends_with("d ago"));
282 }
283
284 #[test]
285 fn format_relative_time_handles_none() {
286 assert_eq!(format_relative_time(None), "?");
287 }
288
289 #[test]
290 fn format_relative_time_handles_future() {
291 let now = SystemTime::now()
292 .duration_since(UNIX_EPOCH)
293 .unwrap()
294 .as_secs();
295 assert_eq!(format_relative_time(Some(now + 1000)), "future");
296 }
297
298 #[test]
299 fn short_path_shortens_home_prefix() {
300 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
301 let p = home.join("foo/bar");
302 let s = short_path(&p);
303 assert!(s.starts_with("~/"), "expected tilde prefix, got: {s}");
304 }
305 }
306}