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