claudex 0.12.0

Reusable library for indexing and querying Claude Code, Codex, Copilot, Pi, and OpenClaw coding sessions
Documentation
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"),
        })
    }

    /// Construct a store rooted at an explicit `projects` directory. Used by
    /// integration tests so they don't have to mutate `$HOME`.
    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)
    }
}

/// Decode a Claude Code project directory name into a filesystem path.
///
/// Claude Code encodes CWD paths with two rules applied in order:
///   `/.hidden` → `--hidden`  (slash + dot for hidden dirs → double dash)
///   `/segment` → `-segment`  (path separator → single dash)
///
/// Decoding reverses this left-to-right: `--` → `/.`, `-` → `/`
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
}

/// Return the canonical project path used as an index key.
///
/// Worktree paths (`…/.claude/worktrees/branch`) are resolved to their parent
/// so worktree sessions aggregate with the parent project automatically.
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
    }
}

/// Convert a decoded project path to a human-readable display name.
///
/// Paths containing `/.claude/worktrees/` are shown as "projectname (worktree)"
/// to avoid long branch-hash suffixes obscuring the actual project name.
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()
}

/// Shorten a decoded project path to at most 55 bytes for display.
pub fn short_name(path: &str) -> String {
    const MAX: usize = 55;
    // '…' is 3 bytes; reserve space for it so total stays within MAX
    const SUFFIX_MAX: usize = MAX - 3;
    if path.len() <= MAX {
        return path.to_string();
    }
    let suffix_start = path.len() - SUFFIX_MAX;
    // Advance to the next '/' so we don't cut mid-component
    let adjusted = path[suffix_start..]
        .find('/')
        .map_or(suffix_start, |p| suffix_start + p);
    format!("{}", &path[adjusted..])
}

/// Match session files by session-ID prefix first, then by project-name
/// substring unless the selector looks like a Claude UUID prefix.
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(())
}

/// Collect the subagent transcript files belonging to a top-level session.
///
/// Claude Code nests them at `<project>/<parent-session>/subagents/**/agent-*.jsonl`,
/// so for a parent transcript `<project>/<parent-session>.jsonl` the directory
/// is the file's stem joined with `subagents`. Returns an empty vec when
/// `session_file` is itself a subagent transcript (children don't nest further)
/// or when no `subagents/` directory exists. Workflow `journal.jsonl` files and
/// `agent-*.meta.json` sidecars are skipped by [`collect_subagent_transcripts`],
/// matching index-time discovery.
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-"))
}

/// Return the parent top-level session id for a nested subagent transcript.
///
/// Claude Code stores subagent transcripts under
/// `<project>/<parent-session>/subagents/**/agent-*.jsonl`. Workflow
/// `journal.jsonl` files and `agent-*.meta.json` sidecars are deliberately
/// ignored by discovery, so only transcript files return a parent id here.
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();
        // These must be ignored.
        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"));

        // A leaf subagent transcript rolls nothing up further.
        assert!(subagent_transcripts_for(&child).unwrap().is_empty());

        // A top-level session with no subagents/ dir → empty.
        let lonely = proj.join("parent-2.jsonl");
        std::fs::write(&lonely, "{}\n").unwrap();
        assert!(subagent_transcripts_for(&lonely).unwrap().is_empty());
    }
}