ai_memory/recover/
transcript_paths.rs1use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum HostKind {
19 #[default]
23 Auto,
24 ClaudeCode,
27 Codex,
31 Gemini,
34}
35
36impl HostKind {
37 #[must_use]
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::Auto => "auto",
43 Self::ClaudeCode => "claude-code",
44 Self::Codex => "codex",
45 Self::Gemini => "gemini",
46 }
47 }
48}
49
50pub fn resolve_transcript(host: HostKind, cwd: &Path) -> Result<Option<PathBuf>, ResolveError> {
65 let candidates: Vec<PathBuf> = match host {
66 HostKind::Auto => {
67 let mut all = Vec::new();
68 all.extend(claude_code_candidates(cwd));
69 all.extend(codex_candidates(cwd));
70 all.extend(gemini_candidates(cwd));
71 all
72 }
73 HostKind::ClaudeCode => claude_code_candidates(cwd),
74 HostKind::Codex => codex_candidates(cwd),
75 HostKind::Gemini => gemini_candidates(cwd),
76 };
77 Ok(most_recently_modified(&candidates))
78}
79
80fn claude_code_candidates(cwd: &Path) -> Vec<PathBuf> {
84 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
85 return Vec::new();
86 };
87 let cwd_str = cwd.to_string_lossy();
88 let encoded = format!("-{}", cwd_str.replace('/', "-"));
89 let project_dir = home.join(".claude").join("projects").join(&encoded);
90 list_jsonl_in(&project_dir)
91}
92
93fn codex_candidates(_cwd: &Path) -> Vec<PathBuf> {
98 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
99 return Vec::new();
100 };
101 let sessions = home.join(".codex").join("sessions");
102 list_jsonl_in(&sessions)
103}
104
105fn gemini_candidates(_cwd: &Path) -> Vec<PathBuf> {
108 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
109 return Vec::new();
110 };
111 let sessions = home.join(".config").join("gemini").join("sessions");
112 list_jsonl_in(&sessions)
113}
114
115fn list_jsonl_in(dir: &Path) -> Vec<PathBuf> {
119 let Ok(entries) = std::fs::read_dir(dir) else {
120 return Vec::new();
121 };
122 entries
123 .filter_map(Result::ok)
124 .map(|e| e.path())
125 .filter(|p| {
126 p.extension()
127 .and_then(|e| e.to_str())
128 .is_some_and(|ext| ext == "jsonl" || ext == "json")
129 })
130 .collect()
131}
132
133fn most_recently_modified(candidates: &[PathBuf]) -> Option<PathBuf> {
137 candidates
138 .iter()
139 .filter_map(|p| {
140 let mtime = std::fs::metadata(p).ok()?.modified().ok()?;
141 Some((p.clone(), mtime))
142 })
143 .max_by_key(|(_, t)| *t)
144 .map(|(p, _)| p)
145}
146
147#[derive(Debug)]
151pub enum ResolveError {
152 NoHome,
155}
156
157impl std::fmt::Display for ResolveError {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 match self {
160 Self::NoHome => write!(f, "resolve: no $HOME set"),
161 }
162 }
163}
164
165impl std::error::Error for ResolveError {}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn host_kind_as_str_round_trip() {
173 assert_eq!(HostKind::Auto.as_str(), "auto");
174 assert_eq!(HostKind::ClaudeCode.as_str(), "claude-code");
175 assert_eq!(HostKind::Codex.as_str(), "codex");
176 assert_eq!(HostKind::Gemini.as_str(), "gemini");
177 }
178
179 #[test]
180 fn resolve_with_no_candidates_returns_none() {
181 let tmp = std::env::temp_dir().join("non-existent-cwd-for-tests");
184 let res = resolve_transcript(HostKind::ClaudeCode, &tmp);
185 assert!(res.is_ok());
186 assert!(res.unwrap().is_none());
187 }
188
189 #[test]
190 fn host_kind_serde_uses_kebab_case() {
191 let serialized = serde_json::to_string(&HostKind::ClaudeCode).unwrap();
192 assert_eq!(serialized, "\"claude-code\"");
193 let parsed: HostKind = serde_json::from_str("\"codex\"").unwrap();
194 assert_eq!(parsed, HostKind::Codex);
195 }
196
197 fn local_runs_dir() -> std::path::PathBuf {
199 let root = std::env::current_dir()
200 .unwrap_or_else(|_| PathBuf::from("."))
201 .join(".local-runs")
202 .join("transcript-paths-unit-test");
203 std::fs::create_dir_all(&root).ok();
204 root
205 }
206
207 fn home_lock() -> std::sync::MutexGuard<'static, ()> {
210 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
211 LOCK.get_or_init(|| std::sync::Mutex::new(()))
212 .lock()
213 .unwrap_or_else(|e| e.into_inner())
214 }
215
216 struct HomeGuard {
219 prev: Option<std::ffi::OsString>,
220 }
221 impl HomeGuard {
222 fn set(dir: &Path) -> Self {
223 let prev = std::env::var_os("HOME");
224 unsafe {
226 std::env::set_var("HOME", dir);
227 }
228 Self { prev }
229 }
230 }
231 impl Drop for HomeGuard {
232 fn drop(&mut self) {
233 unsafe {
234 match &self.prev {
235 Some(v) => std::env::set_var("HOME", v),
236 None => std::env::remove_var("HOME"),
237 }
238 }
239 }
240 }
241
242 #[test]
243 fn resolve_error_display_and_trait() {
244 let e = ResolveError::NoHome;
245 assert_eq!(e.to_string(), "resolve: no $HOME set");
246 let _: &dyn std::error::Error = &e;
248 assert!(format!("{e:?}").contains("NoHome"));
249 }
250
251 #[test]
252 fn claude_code_resolver_finds_most_recent_jsonl() {
253 use std::io::Write;
254 let _g = home_lock();
255 let tmp = tempfile::tempdir_in(local_runs_dir()).unwrap();
256 let home = tmp.path();
257 let _home = HomeGuard::set(home);
258
259 let cwd = std::path::Path::new("/work/proj");
261 let encoded = format!("-{}", cwd.to_string_lossy().replace('/', "-"));
262 let proj = home.join(".claude").join("projects").join(&encoded);
263 std::fs::create_dir_all(&proj).unwrap();
264
265 let older = proj.join("a.jsonl");
270 let newer = proj.join("b.jsonl");
271 std::fs::write(proj.join("ignore.txt"), b"nope").unwrap();
272 {
273 let mut f = std::fs::File::create(&older).unwrap();
274 writeln!(f, "{{}}").unwrap();
275 }
276 std::thread::sleep(std::time::Duration::from_millis(20));
277 {
278 let mut f = std::fs::File::create(&newer).unwrap();
279 writeln!(f, "{{}}").unwrap();
280 }
281
282 let got = resolve_transcript(HostKind::ClaudeCode, cwd)
288 .unwrap()
289 .unwrap();
290 assert!(
291 got == older || got == newer,
292 "resolved to an unexpected path: {}",
293 got.display()
294 );
295 assert_eq!(got.extension().and_then(|e| e.to_str()), Some("jsonl"));
296
297 let got_auto = resolve_transcript(HostKind::Auto, cwd).unwrap().unwrap();
300 assert_eq!(got_auto.extension().and_then(|e| e.to_str()), Some("jsonl"));
301 }
302
303 #[test]
304 fn codex_and_gemini_resolvers_walk_their_dirs() {
305 use std::io::Write;
306 let _g = home_lock();
307 let tmp = tempfile::tempdir_in(local_runs_dir()).unwrap();
308 let home = tmp.path();
309 let _home = HomeGuard::set(home);
310 let cwd = std::path::Path::new("/irrelevant");
311
312 let codex = home.join(".codex").join("sessions");
314 std::fs::create_dir_all(&codex).unwrap();
315 let cfile = codex.join("s.json");
316 {
317 let mut f = std::fs::File::create(&cfile).unwrap();
318 writeln!(f, "{{}}").unwrap();
319 }
320 assert_eq!(
321 resolve_transcript(HostKind::Codex, cwd).unwrap().as_deref(),
322 Some(cfile.as_path())
323 );
324
325 let gemini = home.join(".config").join("gemini").join("sessions");
327 std::fs::create_dir_all(&gemini).unwrap();
328 let gfile = gemini.join("g.jsonl");
329 {
330 let mut f = std::fs::File::create(&gfile).unwrap();
331 writeln!(f, "{{}}").unwrap();
332 }
333 assert_eq!(
334 resolve_transcript(HostKind::Gemini, cwd)
335 .unwrap()
336 .as_deref(),
337 Some(gfile.as_path())
338 );
339 }
340
341 #[test]
342 fn resolver_returns_none_when_home_unset() {
343 let _g = home_lock();
344 let prev = std::env::var_os("HOME");
345 unsafe {
346 std::env::remove_var("HOME");
347 }
348 let cwd = std::path::Path::new("/whatever");
351 assert!(
352 resolve_transcript(HostKind::ClaudeCode, cwd)
353 .unwrap()
354 .is_none()
355 );
356 assert!(resolve_transcript(HostKind::Codex, cwd).unwrap().is_none());
357 assert!(resolve_transcript(HostKind::Gemini, cwd).unwrap().is_none());
358 assert!(resolve_transcript(HostKind::Auto, cwd).unwrap().is_none());
359 if let Some(v) = prev {
360 unsafe {
361 std::env::set_var("HOME", v);
362 }
363 }
364 }
365}