//! Resolves the on-disk path to a Claude Code session transcript JSONL file. use std::path::{Path, PathBuf}; /// Resolve the path to a Claude Code session transcript. /// /// Claude Code stores each session's transcript at /// `$HOME/.claude/projects//.jsonl`, where /// `` is the absolute working directory with every `/` and `.` /// replaced by `-` (e.g. `/workspace/.huskies/worktrees/1186` becomes /// `-workspace--huskies-worktrees-1186`). pub fn transcript_path(cwd: &Path, session_id: &str) -> PathBuf { #[cfg(test)] if let Some(home) = test_home::get() { return transcript_path_under_home(&home, cwd, session_id); } let home = std::env::var("HOME").unwrap_or_else(|_| "/home/huskies".to_string()); transcript_path_under_home(Path::new(&home), cwd, session_id) } /// Per-thread `$HOME` override for tests, so a test can sandbox where /// [`transcript_path`] looks without mutating the process-global `$HOME` /// env var (which every thread shares, including unrelated `git` /// subprocesses spawned by other tests reading `$HOME` for /// `~/.gitconfig`). Thread-local storage gives each test's thread its own /// independent value — no lock, no serialization, no race, since /// `#[tokio::test]` (current-thread flavor, used throughout this crate) /// pins a test's whole async call graph to the one thread that set it. #[cfg(test)] pub(crate) mod test_home { use std::cell::RefCell; use std::path::{Path, PathBuf}; thread_local! { static HOME: RefCell> = const { RefCell::new(None) }; } /// Return the current thread's `$HOME` override, if one is set. pub(crate) fn get() -> Option { HOME.with(|h| h.borrow().clone()) } /// RAII guard: while held, this thread's [`super::transcript_path`] /// calls resolve under the overridden home instead of the real /// `$HOME`. Clears the override on drop. pub(crate) struct HomeGuard; impl Drop for HomeGuard { fn drop(&mut self) { HOME.with(|h| *h.borrow_mut() = None); } } /// Override `$HOME` resolution for [`super::transcript_path`] calls /// made on the current thread for the lifetime of the returned guard. pub(crate) fn set(home: &Path) -> HomeGuard { HOME.with(|h| *h.borrow_mut() = Some(home.to_path_buf())); HomeGuard } } /// Same as [`transcript_path`] but takes an explicit `$HOME` directory, /// keeping the path-joining logic testable without mutating process env vars. fn transcript_path_under_home(home: &Path, cwd: &Path, session_id: &str) -> PathBuf { let mangled = mangle_cwd(&cwd.to_string_lossy()); home.join(".claude") .join("projects") .join(mangled) .join(format!("{session_id}.jsonl")) } /// Replace every `/` and `.` in an absolute path string with `-`, matching /// the directory-naming convention Claude Code uses under `~/.claude/projects/`. fn mangle_cwd(cwd: &str) -> String { cwd.chars() .map(|c| if c == '/' || c == '.' { '-' } else { c }) .collect() } #[cfg(test)] mod tests { use super::*; #[test] fn mangles_worktree_path_matching_observed_convention() { // Verified against this very worktree's own transcript directory. assert_eq!( mangle_cwd("/workspace/.huskies/worktrees/1186"), "-workspace--huskies-worktrees-1186" ); } #[test] fn mangles_simple_home_path() { assert_eq!(mangle_cwd("/home/huskies"), "-home-huskies"); } #[test] fn transcript_path_joins_home_projects_dir_and_session_file() { let path = transcript_path_under_home( Path::new("/home/testuser"), Path::new("/workspace/proj"), "abc-123", ); assert_eq!( path, PathBuf::from("/home/testuser/.claude/projects/-workspace-proj/abc-123.jsonl") ); } }