Skip to main content

claudex/
store.rs

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    /// Construct a store rooted at an explicit `projects` directory. Used by
18    /// integration tests so they don't have to mutate `$HOME`.
19    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
75/// Decode a Claude Code project directory name into a filesystem path.
76///
77/// Claude Code encodes CWD paths with two rules applied in order:
78///   `/.hidden` → `--hidden`  (slash + dot for hidden dirs → double dash)
79///   `/segment` → `-segment`  (path separator → single dash)
80///
81/// Decoding reverses this left-to-right: `--` → `/.`, `-` → `/`
82pub 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
103/// Return the canonical project path used as an index key.
104///
105/// Worktree paths (`…/.claude/worktrees/branch`) are resolved to their parent
106/// so worktree sessions aggregate with the parent project automatically.
107pub 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
116/// Convert a decoded project path to a human-readable display name.
117///
118/// Paths containing `/.claude/worktrees/` are shown as "projectname (worktree)"
119/// to avoid long branch-hash suffixes obscuring the actual project name.
120pub 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
130/// Shorten a decoded project path to at most 55 bytes for display.
131pub fn short_name(path: &str) -> String {
132    const MAX: usize = 55;
133    // '…' is 3 bytes; reserve space for it so total stays within MAX
134    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    // Advance to the next '/' so we don't cut mid-component
140    let adjusted = path[suffix_start..]
141        .find('/')
142        .map_or(suffix_start, |p| suffix_start + p);
143    format!("…{}", &path[adjusted..])
144}
145
146/// Match session files by session-ID prefix first, then by project-name
147/// substring unless the selector looks like a Claude UUID prefix.
148pub 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
198/// Collect the subagent transcript files belonging to a top-level session.
199///
200/// Claude Code nests them at `<project>/<parent-session>/subagents/**/agent-*.jsonl`,
201/// so for a parent transcript `<project>/<parent-session>.jsonl` the directory
202/// is the file's stem joined with `subagents`. Returns an empty vec when
203/// `session_file` is itself a subagent transcript (children don't nest further)
204/// or when no `subagents/` directory exists. Workflow `journal.jsonl` files and
205/// `agent-*.meta.json` sidecars are skipped by [`collect_subagent_transcripts`],
206/// matching index-time discovery.
207pub 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
228/// Return the parent top-level session id for a nested subagent transcript.
229///
230/// Claude Code stores subagent transcripts under
231/// `<project>/<parent-session>/subagents/**/agent-*.jsonl`. Workflow
232/// `journal.jsonl` files and `agent-*.meta.json` sidecars are deliberately
233/// ignored by discovery, so only transcript files return a parent id here.
234pub 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        // These must be ignored.
375        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        // A leaf subagent transcript rolls nothing up further.
383        assert!(subagent_transcripts_for(&child).unwrap().is_empty());
384
385        // A top-level session with no subagents/ dir → empty.
386        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}