111 lines
3.9 KiB
Rust
111 lines
3.9 KiB
Rust
//! 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/<mangled-cwd>/<session_id>.jsonl`, where
|
|
/// `<mangled-cwd>` 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<Option<PathBuf>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// Return the current thread's `$HOME` override, if one is set.
|
|
pub(crate) fn get() -> Option<PathBuf> {
|
|
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")
|
|
);
|
|
}
|
|
}
|