Skip to main content

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}