pub mod claude_code;
pub mod codex;
pub mod grok;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::conversation::Conversation;
use crate::error::RecallError;
pub use claude_code::ClaudeCodeTranscripts;
pub use codex::CodexTranscripts;
pub use grok::GrokTranscripts;
const MAX_DISCOVERY_DEPTH: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
ClaudeCode,
Codex,
Grok,
}
impl Source {
pub const ALL: [Source; 3] = [Source::ClaudeCode, Source::Codex, Source::Grok];
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Source::ClaudeCode => "claude-code",
Source::Codex => "codex",
Source::Grok => "grok",
}
}
pub fn from_str_loose(s: &str) -> Result<Self, RecallError> {
match s.trim().to_lowercase().as_str() {
"claude-code" | "claudecode" | "claude" => Ok(Source::ClaudeCode),
"codex" | "codex-cli" => Ok(Source::Codex),
"grok" | "grok-cli" => Ok(Source::Grok),
other => Err(RecallError::Config(format!(
"unknown transcript source: {other} (use 'claude-code', 'codex', or 'grok')"
))),
}
}
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptRef {
pub source: Source,
pub session_id: String,
pub path: PathBuf,
pub modified: SystemTime,
pub cwd: Option<String>,
}
impl TranscriptRef {
#[must_use]
pub fn age_at(&self, now: SystemTime) -> std::time::Duration {
now.duration_since(self.modified).unwrap_or_default()
}
}
pub trait Transcript: Send + Sync {
fn source(&self) -> Source;
fn sessions_root(&self) -> &Path;
fn discover(&self, since: Option<SystemTime>) -> Result<Vec<TranscriptRef>, RecallError>;
fn parse(&self, transcript: &TranscriptRef) -> Result<Conversation, RecallError>;
fn is_installed(&self) -> bool {
self.sessions_root().exists()
}
}
#[must_use]
pub fn adapter_for(source: Source) -> Option<Box<dyn Transcript>> {
match source {
Source::ClaudeCode => ClaudeCodeTranscripts::detect().map(boxed),
Source::Codex => CodexTranscripts::detect().map(boxed),
Source::Grok => GrokTranscripts::detect().map(boxed),
}
}
fn boxed<T: Transcript + 'static>(adapter: T) -> Box<dyn Transcript> {
Box::new(adapter)
}
#[must_use]
pub fn detect_installed() -> Vec<Box<dyn Transcript>> {
Source::ALL
.iter()
.filter_map(|source| adapter_for(*source))
.filter(|adapter| adapter.is_installed())
.collect()
}
pub(crate) fn content_text(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Array(blocks) => {
let parts: Vec<String> = blocks.iter().map(content_text).collect();
parts
.iter()
.filter(|part| !part.is_empty())
.cloned()
.collect::<Vec<_>>()
.join("")
}
serde_json::Value::Object(map) => map
.get("text")
.and_then(|text| text.as_str())
.unwrap_or_default()
.to_string(),
_ => String::new(),
}
}
pub(crate) fn unwrap_tag(text: &str, tag: &str) -> String {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let trimmed = text.trim();
match trimmed
.strip_prefix(&open)
.and_then(|rest| rest.strip_suffix(&close))
{
Some(inner) => inner.trim().to_string(),
None => text.to_string(),
}
}
#[must_use]
pub(crate) fn percent_decode(encoded: &str) -> String {
let bytes = encoded.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hex = &encoded[index + 1..index + 3];
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte);
index += 3;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
String::from_utf8(out).unwrap_or_else(|_| encoded.to_string())
}
pub(crate) fn iso_timestamp(time: SystemTime) -> String {
chrono::DateTime::<chrono::Utc>::from(time)
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
pub(crate) fn modified_at(path: &Path) -> SystemTime {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub(crate) fn walk_files(dir: &Path, extension: &str, depth: usize) -> Vec<PathBuf> {
if depth > MAX_DISCOVERY_DEPTH {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut files = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walk_files(&path, extension, depth + 1));
} else if path.extension().is_some_and(|ext| ext == extension) {
files.push(path);
}
}
files
}
pub(crate) fn newer_than(
mut found: Vec<TranscriptRef>,
since: Option<SystemTime>,
) -> Vec<TranscriptRef> {
if let Some(watermark) = since {
found.retain(|transcript| transcript.modified > watermark);
}
found.sort_by(|a, b| {
a.modified
.cmp(&b.modified)
.then_with(|| a.session_id.cmp(&b.session_id))
});
found
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_names_round_trip() {
for source in Source::ALL {
assert_eq!(Source::from_str_loose(source.as_str()).unwrap(), source);
}
assert_eq!(
Source::from_str_loose(" CLAUDE ").unwrap(),
Source::ClaudeCode
);
assert!(Source::from_str_loose("cursor").is_err());
}
#[test]
fn content_text_reads_a_bare_string() {
assert_eq!(content_text(&serde_json::json!("OK")), "OK");
}
#[test]
fn content_text_reads_an_array_of_blocks() {
let value = serde_json::json!([
{"type": "text", "text": "first"},
{"type": "text", "text": " second"},
]);
assert_eq!(content_text(&value), "first second");
}
#[test]
fn content_text_ignores_blocks_without_text() {
let value = serde_json::json!([{"type": "image", "url": "http://x"}, {"text": "kept"}]);
assert_eq!(content_text(&value), "kept");
}
#[test]
fn unwrap_tag_strips_only_a_whole_wrapper() {
assert_eq!(
unwrap_tag("<user_query>\nhello\n</user_query>", "user_query"),
"hello"
);
assert_eq!(
unwrap_tag("prefix <user_query>hello</user_query>", "user_query"),
"prefix <user_query>hello</user_query>"
);
}
#[test]
fn percent_decode_handles_grok_session_dirs() {
assert_eq!(percent_decode("%2Froot"), "/root");
assert_eq!(percent_decode("%2Fopt%2Frecall-echo"), "/opt/recall-echo");
assert_eq!(percent_decode("plain"), "plain");
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz"), "%zz");
}
#[test]
fn newer_than_drops_the_watermark_itself_and_sorts_oldest_first() {
let epoch = SystemTime::UNIX_EPOCH;
let make = |id: &str, secs: u64| TranscriptRef {
source: Source::Codex,
session_id: id.to_string(),
path: PathBuf::from(format!("/tmp/{id}")),
modified: epoch + std::time::Duration::from_secs(secs),
cwd: None,
};
let found = vec![make("c", 30), make("a", 10), make("b", 20)];
let kept = newer_than(found, Some(epoch + std::time::Duration::from_secs(10)));
let ids: Vec<&str> = kept.iter().map(|t| t.session_id.as_str()).collect();
assert_eq!(ids, ["b", "c"]);
}
#[test]
fn walking_a_missing_directory_finds_nothing() {
assert!(walk_files(Path::new("/nonexistent/nowhere"), "jsonl", 0).is_empty());
}
#[test]
fn walking_finds_nested_files_and_ignores_other_extensions() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("2026/08/05");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("rollout-a.jsonl"), "").unwrap();
std::fs::write(nested.join("notes.txt"), "").unwrap();
let found = walk_files(tmp.path(), "jsonl", 0);
assert_eq!(found.len(), 1);
assert!(found[0].ends_with("rollout-a.jsonl"));
}
}