claude_codes/transcript.rs
1//! On-disk transcript locations for Claude Code sessions.
2//!
3//! The CLI persists every session's journal at
4//! `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`. The encoding rule
5//! is unpublished CLI behavior, measured against real transcript stores:
6//! every `/` and `.` in the working directory's path becomes `-`; all other
7//! characters pass through. Observed pairs:
8//!
9//! - `/home/u/repos/inboxnegative.com` → `-home-u-repos-inboxnegative-com`
10//! - `/home/u/repos/x/.worktrees/y` → `-home-u-repos-x--worktrees-y`
11//!
12//! **The encoding is lossy** (not injective): distinct working directories
13//! can collide — `a/b.c` and `a/b/c` and `a-b-c` all encode identically.
14//! Treat encoded names as a lookup key for paths you already know, never as
15//! something to decode.
16//!
17//! Exported so consumers stop growing private copies of the rule: a
18//! consumer that re-implements it can silently diverge the day the CLI
19//! changes, and two implementations of one CLI behavior is one too many.
20
21use std::path::{Path, PathBuf};
22
23/// Encode a working directory the way the CLI names its per-project
24/// transcript folder: `/` and `.` become `-`, everything else unchanged.
25pub fn encode_project_dir(working_directory: &Path) -> String {
26 working_directory
27 .to_string_lossy()
28 .chars()
29 .map(|c| if c == '/' || c == '.' { '-' } else { c })
30 .collect()
31}
32
33/// Resolve `<home>/.claude/projects/<encoded-cwd>/<session-id>.jsonl` —
34/// the transcript file the CLI writes for `session_id` runs in
35/// `working_directory`. Takes `home` explicitly (pass a tempdir in tests;
36/// never let a test path resolve into a real transcript store).
37pub fn transcript_path(
38 home: &Path,
39 working_directory: &Path,
40 session_id: impl AsRef<str>,
41) -> PathBuf {
42 home.join(".claude")
43 .join("projects")
44 .join(encode_project_dir(working_directory))
45 .join(format!("{}.jsonl", session_id.as_ref()))
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 /// Pairs observed verbatim in a real ~/.claude/projects store.
53 #[test]
54 fn encoding_matches_observed_cli_behavior() {
55 for (dir, expect) in [
56 (
57 "/home/u/repos/rust-code-agent-sdks",
58 "-home-u-repos-rust-code-agent-sdks",
59 ),
60 (
61 "/home/u/repos/inboxnegative.com",
62 "-home-u-repos-inboxnegative-com",
63 ),
64 (
65 "/home/u/repos/x/.worktrees/y",
66 "-home-u-repos-x--worktrees-y",
67 ),
68 ("/tmp/reset-probe", "-tmp-reset-probe"),
69 ] {
70 assert_eq!(encode_project_dir(Path::new(dir)), expect);
71 }
72 }
73
74 #[test]
75 fn transcript_path_assembles_under_the_given_home() {
76 let p = transcript_path(Path::new("/fake/home"), Path::new("/work/dir.x"), "abc-123");
77 assert_eq!(
78 p,
79 Path::new("/fake/home/.claude/projects/-work-dir-x/abc-123.jsonl")
80 );
81 }
82
83 /// The rule is lossy — document-by-test so nobody builds a decoder.
84 #[test]
85 fn encoding_is_lossy_by_design() {
86 assert_eq!(
87 encode_project_dir(Path::new("/a/b.c")),
88 encode_project_dir(Path::new("/a/b/c")),
89 );
90 }
91}