1use std::path::{Component, Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5pub struct SessionStore {
6 pub base_dir: PathBuf,
7}
8
9impl SessionStore {
10 pub fn new() -> Result<Self> {
11 let home = dirs::home_dir().context("could not find home directory")?;
12 Ok(Self {
13 base_dir: home.join(".claude").join("projects"),
14 })
15 }
16
17 pub fn at(base_dir: PathBuf) -> Self {
20 Self { base_dir }
21 }
22
23 pub fn project_dirs(&self) -> Result<Vec<(String, PathBuf)>> {
24 if !self.base_dir.exists() {
25 return Ok(Vec::new());
26 }
27 let mut projects = Vec::new();
28 let entries = std::fs::read_dir(&self.base_dir)
29 .with_context(|| format!("reading {}", self.base_dir.display()))?;
30 for entry in entries.flatten() {
31 let path = entry.path();
32 if path.is_dir() {
33 let name = entry.file_name().to_string_lossy().into_owned();
34 projects.push((name, path));
35 }
36 }
37 projects.sort_by(|a, b| a.0.cmp(&b.0));
38 Ok(projects)
39 }
40
41 pub fn session_files(&self, project_path: &Path) -> Result<Vec<PathBuf>> {
42 let mut files = Vec::new();
43 for entry in std::fs::read_dir(project_path)?.flatten() {
44 let path = entry.path();
45 if path.is_file() && path.extension().is_some_and(|e| e == "jsonl") {
46 files.push(path);
47 } else if path.is_dir() {
48 collect_subagent_transcripts(&path.join("subagents"), &mut files)?;
49 }
50 }
51 files.sort();
52 Ok(files)
53 }
54
55 pub fn all_session_files(
56 &self,
57 project_filter: Option<&str>,
58 ) -> Result<Vec<(String, PathBuf)>> {
59 let mut result = Vec::new();
60 for (project, dir) in self.project_dirs()? {
61 if let Some(filter) = project_filter {
62 let decoded = decode_project_name(&project);
63 if !project.contains(filter) && !decoded.contains(filter) {
64 continue;
65 }
66 }
67 for file in self.session_files(&dir)? {
68 result.push((project.clone(), file));
69 }
70 }
71 Ok(result)
72 }
73}
74
75pub fn decode_project_name(encoded: &str) -> String {
83 let mut result = String::with_capacity(encoded.len() + 1);
84 let bytes = encoded.as_bytes();
85 let mut i = 0;
86 while i < bytes.len() {
87 if bytes[i] == b'-' {
88 if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
89 result.push_str("/.");
90 i += 2;
91 } else {
92 result.push('/');
93 i += 1;
94 }
95 } else {
96 result.push(bytes[i] as char);
97 i += 1;
98 }
99 }
100 result
101}
102
103pub fn canonical_project_path(decoded_path: &str) -> &str {
108 const MARKER: &str = "/.claude/worktrees/";
109 if let Some(idx) = decoded_path.find(MARKER) {
110 &decoded_path[..idx]
111 } else {
112 decoded_path
113 }
114}
115
116pub fn display_project_name(decoded_path: &str) -> String {
121 const MARKER: &str = "/.claude/worktrees/";
122 if let Some(idx) = decoded_path.find(MARKER) {
123 let base = &decoded_path[..idx];
124 let proj = base.rsplit('/').next().unwrap_or(base);
125 return format!("{} (worktree)", proj);
126 }
127 decoded_path.to_string()
128}
129
130pub fn short_name(path: &str) -> String {
132 const MAX: usize = 55;
133 const SUFFIX_MAX: usize = MAX - 3;
135 if path.len() <= MAX {
136 return path.to_string();
137 }
138 let suffix_start = path.len() - SUFFIX_MAX;
139 let adjusted = path[suffix_start..]
141 .find('/')
142 .map_or(suffix_start, |p| suffix_start + p);
143 format!("…{}", &path[adjusted..])
144}
145
146pub fn find_matching_sessions<'a>(
149 files: &'a [(String, PathBuf)],
150 selector: &str,
151) -> Vec<&'a (String, PathBuf)> {
152 let sel = selector.to_lowercase();
153
154 let id_matches: Vec<_> = files
155 .iter()
156 .filter(|(_, path)| {
157 let stem = path
158 .file_stem()
159 .map(|s| s.to_string_lossy().to_lowercase())
160 .unwrap_or_default();
161 stem.starts_with(&sel)
162 })
163 .collect();
164
165 if !id_matches.is_empty() || looks_like_session_id_prefix(selector) {
166 return id_matches;
167 }
168
169 files
170 .iter()
171 .filter(|(project_raw, _)| {
172 let decoded = decode_project_name(project_raw).to_lowercase();
173 project_raw.to_lowercase().contains(&sel) || decoded.contains(&sel)
174 })
175 .collect()
176}
177
178fn looks_like_session_id_prefix(selector: &str) -> bool {
179 let compact = selector.replace('-', "");
180 compact.len() >= 6 && compact.chars().all(|c| c.is_ascii_hexdigit())
181}
182
183fn collect_subagent_transcripts(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
184 if !dir.exists() {
185 return Ok(());
186 }
187 for entry in std::fs::read_dir(dir)?.flatten() {
188 let path = entry.path();
189 if path.is_dir() {
190 collect_subagent_transcripts(&path, files)?;
191 } else if is_subagent_transcript_file(&path) {
192 files.push(path);
193 }
194 }
195 Ok(())
196}
197
198pub fn subagent_transcripts_for(session_file: &Path) -> Result<Vec<PathBuf>> {
208 if is_subagent_transcript_file(session_file) {
209 return Ok(Vec::new());
210 }
211 let (Some(parent), Some(stem)) = (session_file.parent(), session_file.file_stem()) else {
212 return Ok(Vec::new());
213 };
214 let dir = parent.join(stem).join("subagents");
215 let mut files = Vec::new();
216 collect_subagent_transcripts(&dir, &mut files)?;
217 Ok(files)
218}
219
220fn is_subagent_transcript_file(path: &Path) -> bool {
221 path.extension().is_some_and(|e| e == "jsonl")
222 && path
223 .file_name()
224 .and_then(|s| s.to_str())
225 .is_some_and(|name| name.starts_with("agent-"))
226}
227
228pub fn parent_session_id_for_path(project_path: &Path, session_file: &Path) -> Option<String> {
235 let rel = session_file.strip_prefix(project_path).ok()?;
236 let mut components = rel.components();
237 let parent = match components.next()? {
238 Component::Normal(s) => s.to_string_lossy().into_owned(),
239 _ => return None,
240 };
241 if parent.ends_with(".jsonl") {
242 return None;
243 }
244 match components.next()? {
245 Component::Normal(s) if s == "subagents" => {}
246 _ => return None,
247 }
248 if is_subagent_transcript_file(session_file) {
249 Some(parent)
250 } else {
251 None
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn decode_simple() {
261 assert_eq!(
262 decode_project_name("-Users-jamesbrink"),
263 "/Users/jamesbrink"
264 );
265 }
266
267 #[test]
268 fn decode_hidden_dir() {
269 assert_eq!(
270 decode_project_name("-Users-jamesbrink-Projects-claudex--claude-worktrees"),
271 "/Users/jamesbrink/Projects/claudex/.claude/worktrees"
272 );
273 }
274
275 #[test]
276 fn display_name_worktree() {
277 assert_eq!(
278 display_project_name(
279 "/Users/jamesbrink/Projects/claudex/.claude/worktrees/strange-borg-c09522"
280 ),
281 "claudex (worktree)"
282 );
283 }
284
285 #[test]
286 fn display_name_normal() {
287 assert_eq!(
288 display_project_name("/Users/jamesbrink/Projects/foo"),
289 "/Users/jamesbrink/Projects/foo"
290 );
291 }
292
293 #[test]
294 fn short_name_short() {
295 assert_eq!(short_name("/Users/foo"), "/Users/foo");
296 }
297
298 #[test]
299 fn short_name_long() {
300 let long = "/Users/jamesbrink/Projects/utensils/claudex/.claude/worktrees/something";
301 let s = short_name(long);
302 assert!(s.starts_with('…'));
303 assert!(s.len() <= 55);
304 }
305
306 #[test]
307 fn find_matching_sessions_prefers_id_prefixes() {
308 let files = vec![
309 (
310 "-Users-test-Projects-alpha".to_string(),
311 PathBuf::from("/tmp/e1a2f4b8-session.jsonl"),
312 ),
313 (
314 "-Users-test-Projects-e1a2f4-app".to_string(),
315 PathBuf::from("/tmp/other-session.jsonl"),
316 ),
317 ];
318
319 let matches = find_matching_sessions(&files, "e1a2f4");
320 assert_eq!(matches.len(), 1);
321 assert_eq!(matches[0].1, PathBuf::from("/tmp/e1a2f4b8-session.jsonl"));
322 }
323
324 #[test]
325 fn find_matching_sessions_does_not_fallback_for_uuid_like_misses() {
326 let files = vec![(
327 "-Users-test-Projects-e1a2f4-app".to_string(),
328 PathBuf::from("/tmp/other-session.jsonl"),
329 )];
330
331 let matches = find_matching_sessions(&files, "e1a2f4");
332 assert!(matches.is_empty());
333 }
334
335 #[test]
336 fn find_matching_sessions_falls_back_to_project_names() {
337 let files = vec![(
338 "-Users-test-Projects-alpha".to_string(),
339 PathBuf::from("/tmp/other-session.jsonl"),
340 )];
341
342 let matches = find_matching_sessions(&files, "alpha");
343 assert_eq!(matches.len(), 1);
344 assert_eq!(matches[0].1, PathBuf::from("/tmp/other-session.jsonl"));
345 }
346
347 #[test]
348 fn parent_session_id_detects_subagent_transcripts() {
349 let project = PathBuf::from("/tmp/projects/p");
350 let child = project.join("parent-123/subagents/workflows/run-1/agent-abc.jsonl");
351 assert_eq!(
352 parent_session_id_for_path(&project, &child).as_deref(),
353 Some("parent-123")
354 );
355
356 let top_level = project.join("parent-123.jsonl");
357 assert_eq!(parent_session_id_for_path(&project, &top_level), None);
358
359 let journal = project.join("parent-123/subagents/workflows/run-1/journal.jsonl");
360 assert_eq!(parent_session_id_for_path(&project, &journal), None);
361 }
362
363 #[test]
364 fn subagent_transcripts_for_finds_children_and_skips_leaves() {
365 use tempfile::TempDir;
366 let tmp = TempDir::new().unwrap();
367 let proj = tmp.path().join("-Users-test-Projects-x");
368 let subdir = proj.join("parent-1/subagents/workflows/run-1");
369 std::fs::create_dir_all(&subdir).unwrap();
370 let parent = proj.join("parent-1.jsonl");
371 std::fs::write(&parent, "{}\n").unwrap();
372 let child = subdir.join("agent-abc.jsonl");
373 std::fs::write(&child, "{}\n").unwrap();
374 std::fs::write(subdir.join("journal.jsonl"), "{}\n").unwrap();
376 std::fs::write(subdir.join("agent-abc.meta.json"), "{}\n").unwrap();
377
378 let found = subagent_transcripts_for(&parent).unwrap();
379 assert_eq!(found.len(), 1);
380 assert!(found[0].ends_with("agent-abc.jsonl"));
381
382 assert!(subagent_transcripts_for(&child).unwrap().is_empty());
384
385 let lonely = proj.join("parent-2.jsonl");
387 std::fs::write(&lonely, "{}\n").unwrap();
388 assert!(subagent_transcripts_for(&lonely).unwrap().is_empty());
389 }
390}