1pub mod claude_code;
37pub mod codex;
38pub mod grok;
39
40use std::fmt;
41use std::path::{Path, PathBuf};
42use std::time::SystemTime;
43
44use serde::{Deserialize, Serialize};
45
46use crate::conversation::Conversation;
47use crate::error::RecallError;
48
49pub use claude_code::ClaudeCodeTranscripts;
50pub use codex::CodexTranscripts;
51pub use grok::GrokTranscripts;
52
53const MAX_DISCOVERY_DEPTH: usize = 4;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum Source {
70 ClaudeCode,
71 Codex,
72 Grok,
73}
74
75impl Source {
76 pub const ALL: [Source; 3] = [Source::ClaudeCode, Source::Codex, Source::Grok];
78
79 #[must_use]
80 pub fn as_str(&self) -> &'static str {
81 match self {
82 Source::ClaudeCode => "claude-code",
83 Source::Codex => "codex",
84 Source::Grok => "grok",
85 }
86 }
87
88 pub fn from_str_loose(s: &str) -> Result<Self, RecallError> {
89 match s.trim().to_lowercase().as_str() {
90 "claude-code" | "claudecode" | "claude" => Ok(Source::ClaudeCode),
91 "codex" | "codex-cli" => Ok(Source::Codex),
92 "grok" | "grok-cli" => Ok(Source::Grok),
93 other => Err(RecallError::Config(format!(
94 "unknown transcript source: {other} (use 'claude-code', 'codex', or 'grok')"
95 ))),
96 }
97 }
98}
99
100impl fmt::Display for Source {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 f.write_str(self.as_str())
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TranscriptRef {
111 pub source: Source,
112 pub session_id: String,
114 pub path: PathBuf,
115 pub modified: SystemTime,
117 pub cwd: Option<String>,
119}
120
121impl TranscriptRef {
122 #[must_use]
124 pub fn age_at(&self, now: SystemTime) -> std::time::Duration {
125 now.duration_since(self.modified).unwrap_or_default()
126 }
127}
128
129pub trait Transcript: Send + Sync {
136 fn source(&self) -> Source;
138
139 fn sessions_root(&self) -> &Path;
141
142 fn discover(&self, since: Option<SystemTime>) -> Result<Vec<TranscriptRef>, RecallError>;
147
148 fn parse(&self, transcript: &TranscriptRef) -> Result<Conversation, RecallError>;
150
151 fn is_installed(&self) -> bool {
153 self.sessions_root().exists()
154 }
155}
156
157#[must_use]
162pub fn adapter_for(source: Source) -> Option<Box<dyn Transcript>> {
163 match source {
164 Source::ClaudeCode => ClaudeCodeTranscripts::detect().map(boxed),
165 Source::Codex => CodexTranscripts::detect().map(boxed),
166 Source::Grok => GrokTranscripts::detect().map(boxed),
167 }
168}
169
170fn boxed<T: Transcript + 'static>(adapter: T) -> Box<dyn Transcript> {
171 Box::new(adapter)
172}
173
174#[must_use]
179pub fn detect_installed() -> Vec<Box<dyn Transcript>> {
180 Source::ALL
181 .iter()
182 .filter_map(|source| adapter_for(*source))
183 .filter(|adapter| adapter.is_installed())
184 .collect()
185}
186
187pub(crate) fn content_text(value: &serde_json::Value) -> String {
197 match value {
198 serde_json::Value::String(text) => text.clone(),
199 serde_json::Value::Array(blocks) => {
200 let parts: Vec<String> = blocks.iter().map(content_text).collect();
201 parts
202 .iter()
203 .filter(|part| !part.is_empty())
204 .cloned()
205 .collect::<Vec<_>>()
206 .join("")
207 }
208 serde_json::Value::Object(map) => map
209 .get("text")
210 .and_then(|text| text.as_str())
211 .unwrap_or_default()
212 .to_string(),
213 _ => String::new(),
214 }
215}
216
217pub(crate) fn unwrap_tag(text: &str, tag: &str) -> String {
222 let open = format!("<{tag}>");
223 let close = format!("</{tag}>");
224 let trimmed = text.trim();
225 match trimmed
226 .strip_prefix(&open)
227 .and_then(|rest| rest.strip_suffix(&close))
228 {
229 Some(inner) => inner.trim().to_string(),
230 None => text.to_string(),
231 }
232}
233
234#[must_use]
242pub(crate) fn percent_decode(encoded: &str) -> String {
243 let bytes = encoded.as_bytes();
244 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
245 let mut index = 0;
246 while index < bytes.len() {
247 if bytes[index] == b'%' && index + 2 < bytes.len() {
248 let hex = &encoded[index + 1..index + 3];
249 if let Ok(byte) = u8::from_str_radix(hex, 16) {
250 out.push(byte);
251 index += 3;
252 continue;
253 }
254 }
255 out.push(bytes[index]);
256 index += 1;
257 }
258 String::from_utf8(out).unwrap_or_else(|_| encoded.to_string())
259}
260
261pub(crate) fn iso_timestamp(time: SystemTime) -> String {
263 chrono::DateTime::<chrono::Utc>::from(time)
264 .format("%Y-%m-%dT%H:%M:%SZ")
265 .to_string()
266}
267
268pub(crate) fn modified_at(path: &Path) -> SystemTime {
270 std::fs::metadata(path)
271 .and_then(|meta| meta.modified())
272 .unwrap_or(SystemTime::UNIX_EPOCH)
273}
274
275pub(crate) fn walk_files(dir: &Path, extension: &str, depth: usize) -> Vec<PathBuf> {
278 if depth > MAX_DISCOVERY_DEPTH {
279 return Vec::new();
280 }
281 let Ok(entries) = std::fs::read_dir(dir) else {
282 return Vec::new();
283 };
284 let mut files = Vec::new();
285 for entry in entries.flatten() {
286 let path = entry.path();
287 if path.is_dir() {
288 files.extend(walk_files(&path, extension, depth + 1));
289 } else if path.extension().is_some_and(|ext| ext == extension) {
290 files.push(path);
291 }
292 }
293 files
294}
295
296pub(crate) fn newer_than(
302 mut found: Vec<TranscriptRef>,
303 since: Option<SystemTime>,
304) -> Vec<TranscriptRef> {
305 if let Some(watermark) = since {
306 found.retain(|transcript| transcript.modified > watermark);
307 }
308 found.sort_by(|a, b| {
309 a.modified
310 .cmp(&b.modified)
311 .then_with(|| a.session_id.cmp(&b.session_id))
312 });
313 found
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn source_names_round_trip() {
322 for source in Source::ALL {
323 assert_eq!(Source::from_str_loose(source.as_str()).unwrap(), source);
324 }
325 assert_eq!(
326 Source::from_str_loose(" CLAUDE ").unwrap(),
327 Source::ClaudeCode
328 );
329 assert!(Source::from_str_loose("cursor").is_err());
330 }
331
332 #[test]
333 fn content_text_reads_a_bare_string() {
334 assert_eq!(content_text(&serde_json::json!("OK")), "OK");
335 }
336
337 #[test]
340 fn content_text_reads_an_array_of_blocks() {
341 let value = serde_json::json!([
342 {"type": "text", "text": "first"},
343 {"type": "text", "text": " second"},
344 ]);
345 assert_eq!(content_text(&value), "first second");
346 }
347
348 #[test]
349 fn content_text_ignores_blocks_without_text() {
350 let value = serde_json::json!([{"type": "image", "url": "http://x"}, {"text": "kept"}]);
351 assert_eq!(content_text(&value), "kept");
352 }
353
354 #[test]
355 fn unwrap_tag_strips_only_a_whole_wrapper() {
356 assert_eq!(
357 unwrap_tag("<user_query>\nhello\n</user_query>", "user_query"),
358 "hello"
359 );
360 assert_eq!(
361 unwrap_tag("prefix <user_query>hello</user_query>", "user_query"),
362 "prefix <user_query>hello</user_query>"
363 );
364 }
365
366 #[test]
367 fn percent_decode_handles_grok_session_dirs() {
368 assert_eq!(percent_decode("%2Froot"), "/root");
369 assert_eq!(percent_decode("%2Fopt%2Frecall-echo"), "/opt/recall-echo");
370 assert_eq!(percent_decode("plain"), "plain");
371 assert_eq!(percent_decode("100%"), "100%");
373 assert_eq!(percent_decode("%zz"), "%zz");
374 }
375
376 #[test]
377 fn newer_than_drops_the_watermark_itself_and_sorts_oldest_first() {
378 let epoch = SystemTime::UNIX_EPOCH;
379 let make = |id: &str, secs: u64| TranscriptRef {
380 source: Source::Codex,
381 session_id: id.to_string(),
382 path: PathBuf::from(format!("/tmp/{id}")),
383 modified: epoch + std::time::Duration::from_secs(secs),
384 cwd: None,
385 };
386 let found = vec![make("c", 30), make("a", 10), make("b", 20)];
387 let kept = newer_than(found, Some(epoch + std::time::Duration::from_secs(10)));
388 let ids: Vec<&str> = kept.iter().map(|t| t.session_id.as_str()).collect();
389 assert_eq!(ids, ["b", "c"]);
390 }
391
392 #[test]
393 fn walking_a_missing_directory_finds_nothing() {
394 assert!(walk_files(Path::new("/nonexistent/nowhere"), "jsonl", 0).is_empty());
395 }
396
397 #[test]
398 fn walking_finds_nested_files_and_ignores_other_extensions() {
399 let tmp = tempfile::tempdir().unwrap();
400 let nested = tmp.path().join("2026/08/05");
401 std::fs::create_dir_all(&nested).unwrap();
402 std::fs::write(nested.join("rollout-a.jsonl"), "").unwrap();
403 std::fs::write(nested.join("notes.txt"), "").unwrap();
404
405 let found = walk_files(tmp.path(), "jsonl", 0);
406 assert_eq!(found.len(), 1);
407 assert!(found[0].ends_with("rollout-a.jsonl"));
408 }
409}