Skip to main content

recall_echo/transcript/
claude_code.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Claude Code transcripts — `~/.claude/projects/<project>/<session>.jsonl`.
6//!
7//! The parser itself is [`crate::jsonl`], unchanged: Claude Code's SessionEnd
8//! hook has always used it, thousands of archives were written by it, and its
9//! output is what every other adapter is measured against. This adapter adds
10//! only the two things the hook never needed — where the files are, and which
11//! of them are new.
12
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16use super::{modified_at, newer_than, walk_files, Source, Transcript, TranscriptRef};
17use crate::conversation::Conversation;
18use crate::error::RecallError;
19
20/// Claude Code's session records.
21#[derive(Debug, Clone)]
22pub struct ClaudeCodeTranscripts {
23    projects_dir: PathBuf,
24}
25
26impl ClaudeCodeTranscripts {
27    /// Read sessions from an explicit `projects/` directory.
28    #[must_use]
29    pub fn new(projects_dir: PathBuf) -> Self {
30        Self { projects_dir }
31    }
32
33    /// Read sessions from this machine's Claude Code installation.
34    ///
35    /// Honours [`crate::paths::CLAUDE_DIR_ENV`], like every other Claude Code
36    /// path in the crate, so a test never reaches the real `~/.claude`.
37    #[must_use]
38    pub fn detect() -> Option<Self> {
39        let claude_dir = match std::env::var_os(crate::paths::CLAUDE_DIR_ENV) {
40            Some(dir) => PathBuf::from(dir),
41            None => dirs::home_dir()?.join(".claude"),
42        };
43        Some(Self::new(claude_dir.join("projects")))
44    }
45}
46
47impl Transcript for ClaudeCodeTranscripts {
48    fn source(&self) -> Source {
49        Source::ClaudeCode
50    }
51
52    fn sessions_root(&self) -> &Path {
53        &self.projects_dir
54    }
55
56    fn discover(&self, since: Option<SystemTime>) -> Result<Vec<TranscriptRef>, RecallError> {
57        let found = walk_files(&self.projects_dir, "jsonl", 0)
58            .into_iter()
59            .filter_map(|path| {
60                let session_id = path.file_stem()?.to_str()?.to_string();
61                Some(TranscriptRef {
62                    source: Source::ClaudeCode,
63                    session_id,
64                    modified: modified_at(&path),
65                    path,
66                    cwd: None,
67                })
68            })
69            .collect();
70        Ok(newer_than(found, since))
71    }
72
73    fn parse(&self, transcript: &TranscriptRef) -> Result<Conversation, RecallError> {
74        crate::jsonl::parse_transcript(&transcript.path.to_string_lossy(), &transcript.session_id)
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    /// One user turn, one thinking block, one tool call, one tool result — the
83    /// same shapes `jsonl`'s own fixture uses, scrubbed of anything personal.
84    const TRANSCRIPT: &str = concat!(
85        r#"{"type":"queue-operation","operation":"enqueue","timestamp":"2026-03-05T14:30:00.000Z"}"#,
86        "\n",
87        r#"{"type":"user","timestamp":"2026-03-05T14:30:00.100Z","message":{"role":"user","content":"Can you read the auth module?"}}"#,
88        "\n",
89        r#"{"type":"assistant","timestamp":"2026-03-05T14:30:05.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"private","signature":"sig"}]}}"#,
90        "\n",
91        r#"{"type":"assistant","timestamp":"2026-03-05T14:30:06.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Reading it now."}]}}"#,
92        "\n",
93        r#"{"type":"assistant","timestamp":"2026-03-05T14:30:07.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/src/auth.rs"}}]}}"#,
94        "\n",
95        r#"{"type":"user","timestamp":"2026-03-05T14:30:08.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"pub fn authenticate() {}"}]}}"#,
96        "\n",
97    );
98
99    fn fixture_tree() -> tempfile::TempDir {
100        let tmp = tempfile::tempdir().unwrap();
101        let project = tmp.path().join("projects").join("-home-dev-app");
102        std::fs::create_dir_all(&project).unwrap();
103        std::fs::write(project.join("sess-abc.jsonl"), TRANSCRIPT).unwrap();
104        tmp
105    }
106
107    #[test]
108    fn discovery_finds_sessions_under_project_directories() {
109        let tmp = fixture_tree();
110        let adapter = ClaudeCodeTranscripts::new(tmp.path().join("projects"));
111
112        let found = adapter.discover(None).unwrap();
113        assert_eq!(found.len(), 1);
114        assert_eq!(found[0].session_id, "sess-abc");
115        assert_eq!(found[0].source, Source::ClaudeCode);
116        assert!(adapter.is_installed());
117    }
118
119    /// The one adapter with existing users: its output must not move.
120    #[test]
121    fn parsing_is_identical_to_the_original_jsonl_parser() {
122        let tmp = fixture_tree();
123        let adapter = ClaudeCodeTranscripts::new(tmp.path().join("projects"));
124        let found = adapter.discover(None).unwrap();
125
126        let through_adapter = adapter.parse(&found[0]).unwrap();
127        let path = found[0].path.to_string_lossy().to_string();
128        let directly = crate::jsonl::parse_transcript(&path, "sess-abc").unwrap();
129
130        assert_eq!(
131            crate::conversation::conversation_to_markdown(&through_adapter, 1),
132            crate::conversation::conversation_to_markdown(&directly, 1)
133        );
134        assert_eq!(
135            through_adapter.user_message_count,
136            directly.user_message_count
137        );
138        assert_eq!(
139            through_adapter.assistant_message_count,
140            directly.assistant_message_count
141        );
142        assert_eq!(through_adapter.first_timestamp, directly.first_timestamp);
143        assert_eq!(through_adapter.last_timestamp, directly.last_timestamp);
144    }
145
146    #[test]
147    fn a_missing_installation_discovers_nothing() {
148        let adapter = ClaudeCodeTranscripts::new(PathBuf::from("/nonexistent/.claude/projects"));
149        assert!(!adapter.is_installed());
150        assert!(adapter.discover(None).unwrap().is_empty());
151    }
152}