use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result};
pub struct SessionStore {
pub base_dir: PathBuf,
}
impl SessionStore {
pub fn new() -> Result<Self> {
let home = dirs::home_dir().context("could not find home directory")?;
Ok(Self {
base_dir: home.join(".claude").join("projects"),
})
}
pub fn at(base_dir: PathBuf) -> Self {
Self { base_dir }
}
pub fn project_dirs(&self) -> Result<Vec<(String, PathBuf)>> {
if !self.base_dir.exists() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
let entries = std::fs::read_dir(&self.base_dir)
.with_context(|| format!("reading {}", self.base_dir.display()))?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
projects.push((name, path));
}
}
projects.sort_by(|a, b| a.0.cmp(&b.0));
Ok(projects)
}
pub fn session_files(&self, project_path: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in std::fs::read_dir(project_path)?.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|e| e == "jsonl") {
files.push(path);
} else if path.is_dir() {
collect_subagent_transcripts(&path.join("subagents"), &mut files)?;
}
}
files.sort();
Ok(files)
}
pub fn all_session_files(
&self,
project_filter: Option<&str>,
) -> Result<Vec<(String, PathBuf)>> {
let mut result = Vec::new();
for (project, dir) in self.project_dirs()? {
if let Some(filter) = project_filter {
let decoded = decode_project_name(&project);
if !project.contains(filter) && !decoded.contains(filter) {
continue;
}
}
for file in self.session_files(&dir)? {
result.push((project.clone(), file));
}
}
Ok(result)
}
}
pub fn decode_project_name(encoded: &str) -> String {
let mut result = String::with_capacity(encoded.len() + 1);
let bytes = encoded.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'-' {
if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
result.push_str("/.");
i += 2;
} else {
result.push('/');
i += 1;
}
} else {
result.push(bytes[i] as char);
i += 1;
}
}
result
}
pub fn canonical_project_path(decoded_path: &str) -> &str {
const MARKER: &str = "/.claude/worktrees/";
if let Some(idx) = decoded_path.find(MARKER) {
&decoded_path[..idx]
} else {
decoded_path
}
}
pub fn display_project_name(decoded_path: &str) -> String {
const MARKER: &str = "/.claude/worktrees/";
if let Some(idx) = decoded_path.find(MARKER) {
let base = &decoded_path[..idx];
let proj = base.rsplit('/').next().unwrap_or(base);
return format!("{} (worktree)", proj);
}
decoded_path.to_string()
}
pub fn short_name(path: &str) -> String {
const MAX: usize = 55;
const SUFFIX_MAX: usize = MAX - 3;
if path.len() <= MAX {
return path.to_string();
}
let suffix_start = path.len() - SUFFIX_MAX;
let adjusted = path[suffix_start..]
.find('/')
.map_or(suffix_start, |p| suffix_start + p);
format!("…{}", &path[adjusted..])
}
pub fn find_matching_sessions<'a>(
files: &'a [(String, PathBuf)],
selector: &str,
) -> Vec<&'a (String, PathBuf)> {
let sel = selector.to_lowercase();
let id_matches: Vec<_> = files
.iter()
.filter(|(_, path)| {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
stem.starts_with(&sel)
})
.collect();
if !id_matches.is_empty() || looks_like_session_id_prefix(selector) {
return id_matches;
}
files
.iter()
.filter(|(project_raw, _)| {
let decoded = decode_project_name(project_raw).to_lowercase();
project_raw.to_lowercase().contains(&sel) || decoded.contains(&sel)
})
.collect()
}
fn looks_like_session_id_prefix(selector: &str) -> bool {
let compact = selector.replace('-', "");
compact.len() >= 6 && compact.chars().all(|c| c.is_ascii_hexdigit())
}
fn collect_subagent_transcripts(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(dir)?.flatten() {
let path = entry.path();
if path.is_dir() {
collect_subagent_transcripts(&path, files)?;
} else if is_subagent_transcript_file(&path) {
files.push(path);
}
}
Ok(())
}
pub fn subagent_transcripts_for(session_file: &Path) -> Result<Vec<PathBuf>> {
if is_subagent_transcript_file(session_file) {
return Ok(Vec::new());
}
let (Some(parent), Some(stem)) = (session_file.parent(), session_file.file_stem()) else {
return Ok(Vec::new());
};
let dir = parent.join(stem).join("subagents");
let mut files = Vec::new();
collect_subagent_transcripts(&dir, &mut files)?;
Ok(files)
}
fn is_subagent_transcript_file(path: &Path) -> bool {
path.extension().is_some_and(|e| e == "jsonl")
&& path
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|name| name.starts_with("agent-"))
}
pub fn parent_session_id_for_path(project_path: &Path, session_file: &Path) -> Option<String> {
let rel = session_file.strip_prefix(project_path).ok()?;
let mut components = rel.components();
let parent = match components.next()? {
Component::Normal(s) => s.to_string_lossy().into_owned(),
_ => return None,
};
if parent.ends_with(".jsonl") {
return None;
}
match components.next()? {
Component::Normal(s) if s == "subagents" => {}
_ => return None,
}
if is_subagent_transcript_file(session_file) {
Some(parent)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_simple() {
assert_eq!(
decode_project_name("-Users-jamesbrink"),
"/Users/jamesbrink"
);
}
#[test]
fn decode_hidden_dir() {
assert_eq!(
decode_project_name("-Users-jamesbrink-Projects-claudex--claude-worktrees"),
"/Users/jamesbrink/Projects/claudex/.claude/worktrees"
);
}
#[test]
fn display_name_worktree() {
assert_eq!(
display_project_name(
"/Users/jamesbrink/Projects/claudex/.claude/worktrees/strange-borg-c09522"
),
"claudex (worktree)"
);
}
#[test]
fn display_name_normal() {
assert_eq!(
display_project_name("/Users/jamesbrink/Projects/foo"),
"/Users/jamesbrink/Projects/foo"
);
}
#[test]
fn short_name_short() {
assert_eq!(short_name("/Users/foo"), "/Users/foo");
}
#[test]
fn short_name_long() {
let long = "/Users/jamesbrink/Projects/utensils/claudex/.claude/worktrees/something";
let s = short_name(long);
assert!(s.starts_with('…'));
assert!(s.len() <= 55);
}
#[test]
fn find_matching_sessions_prefers_id_prefixes() {
let files = vec![
(
"-Users-test-Projects-alpha".to_string(),
PathBuf::from("/tmp/e1a2f4b8-session.jsonl"),
),
(
"-Users-test-Projects-e1a2f4-app".to_string(),
PathBuf::from("/tmp/other-session.jsonl"),
),
];
let matches = find_matching_sessions(&files, "e1a2f4");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].1, PathBuf::from("/tmp/e1a2f4b8-session.jsonl"));
}
#[test]
fn find_matching_sessions_does_not_fallback_for_uuid_like_misses() {
let files = vec![(
"-Users-test-Projects-e1a2f4-app".to_string(),
PathBuf::from("/tmp/other-session.jsonl"),
)];
let matches = find_matching_sessions(&files, "e1a2f4");
assert!(matches.is_empty());
}
#[test]
fn find_matching_sessions_falls_back_to_project_names() {
let files = vec![(
"-Users-test-Projects-alpha".to_string(),
PathBuf::from("/tmp/other-session.jsonl"),
)];
let matches = find_matching_sessions(&files, "alpha");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].1, PathBuf::from("/tmp/other-session.jsonl"));
}
#[test]
fn parent_session_id_detects_subagent_transcripts() {
let project = PathBuf::from("/tmp/projects/p");
let child = project.join("parent-123/subagents/workflows/run-1/agent-abc.jsonl");
assert_eq!(
parent_session_id_for_path(&project, &child).as_deref(),
Some("parent-123")
);
let top_level = project.join("parent-123.jsonl");
assert_eq!(parent_session_id_for_path(&project, &top_level), None);
let journal = project.join("parent-123/subagents/workflows/run-1/journal.jsonl");
assert_eq!(parent_session_id_for_path(&project, &journal), None);
}
#[test]
fn subagent_transcripts_for_finds_children_and_skips_leaves() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let proj = tmp.path().join("-Users-test-Projects-x");
let subdir = proj.join("parent-1/subagents/workflows/run-1");
std::fs::create_dir_all(&subdir).unwrap();
let parent = proj.join("parent-1.jsonl");
std::fs::write(&parent, "{}\n").unwrap();
let child = subdir.join("agent-abc.jsonl");
std::fs::write(&child, "{}\n").unwrap();
std::fs::write(subdir.join("journal.jsonl"), "{}\n").unwrap();
std::fs::write(subdir.join("agent-abc.meta.json"), "{}\n").unwrap();
let found = subagent_transcripts_for(&parent).unwrap();
assert_eq!(found.len(), 1);
assert!(found[0].ends_with("agent-abc.jsonl"));
assert!(subagent_transcripts_for(&child).unwrap().is_empty());
let lonely = proj.join("parent-2.jsonl");
std::fs::write(&lonely, "{}\n").unwrap();
assert!(subagent_transcripts_for(&lonely).unwrap().is_empty());
}
}