use crate::paths::Paths;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq)]
pub struct Found {
pub account: String,
pub session_id: String,
pub project: String,
pub modified: u64,
pub config_dir: PathBuf,
pub tool: &'static str,
}
impl Found {
pub fn resume_command(&self) -> String {
match self.tool {
"codex" => format!(
"CODEX_HOME={} codex resume {}",
self.config_dir.display(),
self.session_id
),
_ => format!(
"CLAUDE_CONFIG_DIR={} claude -r {}",
self.config_dir.display(),
self.session_id
),
}
}
}
pub fn codex_stores(paths: &Paths) -> Vec<(String, PathBuf)> {
let mut out: Vec<(String, PathBuf)> = crate::slots::Slots::open_for(paths, "codex")
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default();
let bare = paths.codex_dir().to_path_buf();
if !out.iter().any(|(_, d)| d == &bare) {
out.push(("(default ~/.codex)".to_string(), bare));
}
out
}
fn codex_cwd(path: &Path) -> Option<String> {
use std::io::Read;
let mut head = vec![0u8; 4096];
let mut f = std::fs::File::open(path).ok()?;
let n = f.read(&mut head).ok()?;
cwd_in(&String::from_utf8_lossy(&head[..n]))
}
fn cwd_in(text: &str) -> Option<String> {
let at = text.find("\"cwd\"")?;
let rest = &text[at + 5..];
let open = rest.find('"')?;
let after = &rest[open + 1..];
let close = after.find('"')?;
let v = &after[..close];
(!v.is_empty()).then(|| v.to_string())
}
pub fn find_codex(paths: &Paths, project_filter: Option<&str>, limit: usize) -> Vec<Found> {
let mut out = Vec::new();
for (account, dir) in codex_stores(paths) {
let mut files = Vec::new();
collect_rollouts(&dir.join("sessions"), &mut files);
files.sort_by_key(|p| std::cmp::Reverse(mtime_secs(p)));
for path in files.into_iter().take(400) {
let Some(cwd) = codex_cwd(&path) else {
continue;
};
if project_filter.is_some_and(|f| !matches_path(&cwd, f)) {
continue;
}
let Some(id) = session_id_of(&path) else {
continue;
};
out.push(Found {
account: account.clone(),
session_id: id,
project: cwd,
modified: mtime_secs(&path),
config_dir: dir.clone(),
tool: "codex",
});
}
}
out.sort_by_key(|f| std::cmp::Reverse(f.modified));
out.truncate(limit);
out
}
fn session_id_of(path: &Path) -> Option<String> {
let stem = path.file_stem()?.to_string_lossy();
stem.rsplit('-').next().filter(|s| s.len() >= 8).map(|s| {
let parts: Vec<&str> = stem.split('-').collect();
if parts.len() >= 5 {
parts[parts.len() - 5..].join("-")
} else {
s.to_string()
}
})
}
fn collect_rollouts(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
collect_rollouts(&p, out);
} else if p.extension().is_some_and(|x| x == "jsonl")
&& p.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("rollout-"))
{
out.push(p);
}
}
}
fn matches_path(haystack: &str, needle: &str) -> bool {
let h = haystack.to_ascii_lowercase();
let n = needle.to_ascii_lowercase();
h.contains(&n) || h.contains(&n.replace('/', "-"))
}
pub fn stores(paths: &Paths) -> Vec<(String, PathBuf)> {
let mut out: Vec<(String, PathBuf)> = crate::slots::Slots::open_for(paths, "claude-code")
.map(|s| {
s.list()
.into_iter()
.map(|r| (r.name, r.config_dir))
.collect()
})
.unwrap_or_default();
let bare = paths.claude_dir().to_path_buf();
if !out.iter().any(|(_, d)| d == &bare) {
out.push(("(default ~/.claude)".to_string(), bare));
}
out
}
pub fn find(paths: &Paths, project_filter: Option<&str>, limit: usize) -> Vec<Found> {
let mut out = Vec::new();
for (account, dir) in stores(paths) {
collect_store(&account, &dir, project_filter, &mut out);
}
out.sort_by_key(|f| std::cmp::Reverse(f.modified));
out.truncate(limit);
out
}
fn collect_store(account: &str, dir: &Path, filter: Option<&str>, out: &mut Vec<Found>) {
let Ok(projects) = std::fs::read_dir(dir.join("projects")) else {
return;
};
for p in projects.flatten() {
let project = p.file_name().to_string_lossy().to_string();
if filter.is_some_and(|f| !matches_project(&project, f)) {
continue;
}
let Ok(files) = std::fs::read_dir(p.path()) else {
continue;
};
for f in files.flatten() {
let path = f.path();
if path.extension().is_none_or(|e| e != "jsonl") {
continue;
}
let Some(session_id) = path.file_stem().map(|s| s.to_string_lossy().to_string()) else {
continue;
};
out.push(Found {
account: account.to_string(),
session_id,
project: project.clone(),
modified: mtime_secs(&path),
config_dir: dir.to_path_buf(),
tool: "claude-code",
});
}
}
}
fn matches_project(encoded: &str, needle: &str) -> bool {
let hay = encoded.to_ascii_lowercase();
let n = needle.to_ascii_lowercase().replace(['/', '_'], "-");
hay.contains(n.trim_matches('-'))
}
fn mtime_secs(p: &Path) -> u64 {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_codex_working_directory_is_read_from_a_fragment() {
let head = r#"{"timestamp":"2026-03-10T11:53:39Z","type":"session_meta","payload":{"id":"019cd798","cwd":"/Users/me/Project/ROS","originator":"codex_cli","#;
assert_eq!(cwd_in(head).as_deref(), Some("/Users/me/Project/ROS"));
assert!(cwd_in(r#"{"payload":{"id":"x","cwd""#).is_none());
assert!(cwd_in(r#"{"payload":{"id":"x""#).is_none());
assert!(cwd_in(r#"{"cwd":""}"#).is_none());
}
#[test]
fn a_codex_session_id_is_the_uuid_in_its_name() {
let p = Path::new("rollout-2026-03-10T20-53-35-019cd798-673e-7c31-bc00-6b91428a80c6.jsonl");
assert_eq!(
session_id_of(p).as_deref(),
Some("019cd798-673e-7c31-bc00-6b91428a80c6")
);
}
fn write_session(dir: &Path, project: &str, id: &str) {
let d = dir.join("projects").join(project);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join(format!("{id}.jsonl")), b"{}\n").unwrap();
}
#[test]
fn a_conversation_is_found_in_whichever_account_holds_it() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
write_session(paths.claude_dir(), "-Users-me-Project-ROS", "sess-ros");
let slot = root.path().join("company");
write_session(&slot, "-Users-me-other", "sess-other");
std::fs::create_dir_all(paths.store_dir()).unwrap();
std::fs::write(
paths.store_dir().join("slots.json"),
serde_json::to_vec(&serde_json::json!([{
"name": "work", "id": "i1", "config_dir": slot, "adopted": true,
"tool": "claude-code"
}]))
.unwrap(),
)
.unwrap();
let all = find(&paths, None, 10);
assert_eq!(all.len(), 2, "both stores are searched: {all:?}");
let ros = find(&paths, Some("Project/ROS"), 10);
assert_eq!(ros.len(), 1, "a path spelled the human way still matches");
assert_eq!(ros[0].session_id, "sess-ros");
assert_eq!(
ros[0].account, "(default ~/.claude)",
"and it names the account whose store has it"
);
let cmd = ros[0].resume_command();
assert!(cmd.contains("CLAUDE_CONFIG_DIR="), "{cmd}");
assert!(
!cmd.contains('~'),
"a path meant to be RUN cannot be tilde-shortened: {cmd}"
);
assert!(
cmd.contains(&paths.claude_dir().display().to_string()),
"it names the store in full: {cmd}"
);
assert!(cmd.ends_with("claude -r sess-ros"), "{cmd}");
let other = find(&paths, Some("other"), 10);
assert_eq!(other.len(), 1);
assert_eq!(other[0].account, "work");
}
#[test]
fn a_store_with_no_conversations_is_simply_empty() {
let root = tempfile::tempdir().unwrap();
let paths = Paths::rooted(root.path());
assert!(find(&paths, None, 10).is_empty(), "no stores, no results");
std::fs::create_dir_all(paths.claude_dir().join("projects")).unwrap();
assert!(find(&paths, None, 10).is_empty());
let d = paths.claude_dir().join("projects").join("-p");
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("notes.txt"), b"x").unwrap();
assert!(find(&paths, None, 10).is_empty());
}
}