1use std::collections::BTreeSet;
12use std::io;
13use std::path::{Path, PathBuf};
14
15use chrono::DateTime;
16use serde_json::Value;
17
18use super::{usage_key, Adapter, Capabilities, Kpi, Parsed, ParsedRecord};
19use crate::config::Config;
20use crate::store::{event_id, Event, EventIdentity, ToolCall};
21
22pub const AGENT: &str = "claude-code";
24pub const PROVIDER: &str = "anthropic";
26pub const DEFAULT_ROOT: &str = "~/.claude/projects";
28
29const CAPABILITIES: Capabilities = Capabilities::new(&[
32 Kpi::Tokens,
33 Kpi::CacheTokens,
34 Kpi::Cost,
35 Kpi::Prompts,
36 Kpi::ToolCalls,
37 Kpi::StopReason,
38 Kpi::Sidechain,
39]);
40
41const INGESTED_TYPES: [&str; 2] = ["assistant", "user"];
45
46const TARGET_KEYS: [&str; 6] = [
50 "file_path",
51 "notebook_path",
52 "path",
53 "pattern",
54 "url",
55 "file",
56];
57
58pub struct ClaudeCodeAdapter;
59
60impl Adapter for ClaudeCodeAdapter {
61 fn name(&self) -> &'static str {
62 AGENT
63 }
64
65 fn is_implemented(&self) -> bool {
66 true
67 }
68
69 fn capabilities(&self) -> Capabilities {
70 CAPABILITIES
71 }
72
73 fn root(&self, config: &Config) -> Option<PathBuf> {
74 Some(
75 config
76 .source(AGENT)
77 .path
78 .unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)),
79 )
80 }
81
82 fn discover(&self, root: &Path) -> io::Result<Vec<PathBuf>> {
83 super::jsonl_files(root)
84 }
85
86 fn session_count(&self, root: &Path) -> io::Result<usize> {
89 let sessions: BTreeSet<String> = self
90 .discover(root)?
91 .iter()
92 .filter_map(|path| path.file_stem()?.to_str().map(str::to_string))
93 .collect();
94 Ok(sessions.len())
95 }
96
97 fn parse_line(&self, _source: &Path, line: &str) -> Parsed {
98 parse_line(line)
99 }
100}
101
102fn parse_line(line: &str) -> Parsed {
103 if line.trim().is_empty() {
104 return Parsed::Skipped;
105 }
106 let Ok(value) = serde_json::from_str::<Value>(line) else {
107 return Parsed::Unparseable;
108 };
109 let Some(kind) = value.get("type").and_then(Value::as_str) else {
110 return Parsed::Unparseable;
111 };
112 if !INGESTED_TYPES.contains(&kind) {
113 return Parsed::Skipped;
114 }
115
116 let Some(ts) = value
119 .get("timestamp")
120 .and_then(Value::as_str)
121 .and_then(ts_ms)
122 else {
123 return Parsed::Unparseable;
124 };
125
126 let session_id = string(&value, "sessionId");
127 let source_id = value.get("uuid").and_then(Value::as_str);
128 let turn_id = string(&value, "requestId");
129
130 let id = event_id(EventIdentity {
131 agent: AGENT,
132 session_id: session_id.as_deref(),
133 source_id,
134 ts,
135 role: kind,
136 extra: None,
137 });
138
139 let mut event = Event::new(id, ts, AGENT, PROVIDER, kind);
140 event.session_id = session_id.clone();
141 event.turn_id = turn_id.clone();
142 event.project = value
143 .get("cwd")
144 .and_then(Value::as_str)
145 .and_then(project_from_cwd);
146 event.is_sidechain = value.get("isSidechain").and_then(Value::as_bool);
147
148 let message = value.get("message");
149 event.model = message.and_then(|m| string(m, "model"));
150 event.stop_reason = message.and_then(|m| string(m, "stop_reason"));
151 event.tool_calls = tool_calls(message);
152 let mut usage_dedup_key = None;
155 if kind == "assistant" {
156 if let Some(usage) = message.and_then(|m| m.get("usage")) {
157 event.input_tok = count(usage, "input_tokens");
158 event.output_tok = count(usage, "output_tokens");
159 event.cache_read_tok = count(usage, "cache_read_input_tokens");
160 event.cache_write_tok = count(usage, "cache_creation_input_tokens");
161 usage_dedup_key = turn_id
167 .as_deref()
168 .map(|turn| usage_key(AGENT, session_id.as_deref(), turn));
169 }
170 }
171
172 let prompt_text = if kind == "user" {
173 message.and_then(|m| m.get("content")).and_then(prompt_text)
174 } else {
175 None
176 };
177
178 Parsed::Record(Box::new(ParsedRecord {
179 event,
180 prompt_text,
181 usage_key: usage_dedup_key,
182 }))
183}
184
185fn ts_ms(text: &str) -> Option<i64> {
187 DateTime::parse_from_rfc3339(text)
188 .ok()
189 .map(|dt| dt.timestamp_millis())
190}
191
192fn project_from_cwd(cwd: &str) -> Option<String> {
195 Path::new(cwd)
196 .file_name()
197 .and_then(|name| name.to_str())
198 .filter(|name| !name.is_empty())
199 .map(str::to_string)
200}
201
202fn string(value: &Value, key: &str) -> Option<String> {
203 value.get(key).and_then(Value::as_str).map(str::to_string)
204}
205
206fn count(usage: &Value, key: &str) -> Option<u64> {
207 usage.get(key).and_then(Value::as_u64)
208}
209
210fn tool_calls(message: Option<&Value>) -> Vec<ToolCall> {
211 let Some(blocks) = message
212 .and_then(|m| m.get("content"))
213 .and_then(Value::as_array)
214 else {
215 return Vec::new();
216 };
217 blocks
218 .iter()
219 .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
220 .filter_map(|block| {
221 let name = block.get("name").and_then(Value::as_str)?;
222 Some(ToolCall::new(name, tool_target(block.get("input"))))
223 })
224 .collect()
225}
226
227fn tool_target(input: Option<&Value>) -> Option<String> {
228 let input = input?;
229 TARGET_KEYS
230 .iter()
231 .find_map(|key| input.get(key).and_then(Value::as_str))
232 .filter(|target| !target.is_empty())
233 .map(str::to_string)
234}
235
236fn prompt_text(content: &Value) -> Option<String> {
239 let text = match content {
240 Value::String(text) => text.clone(),
241 Value::Array(blocks) => blocks
242 .iter()
243 .filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
244 .filter_map(|block| block.get("text").and_then(Value::as_str))
245 .collect::<Vec<_>>()
246 .join("\n"),
247 _ => return None,
248 };
249 (!text.trim().is_empty()).then_some(text)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn record(line: &str) -> ParsedRecord {
257 match parse_line(line) {
258 Parsed::Record(record) => *record,
259 other => panic!("expected a record, got {other:?}"),
260 }
261 }
262
263 const ASSISTANT: &str = r#"{"type":"assistant","uuid":"u1","timestamp":"2026-08-01T10:00:00.000Z",
264 "sessionId":"s1","requestId":"req_1","cwd":"/home/me/code/acme-api","isSidechain":false,
265 "message":{"model":"claude-sonnet-4-6","stop_reason":"tool_use",
266 "usage":{"input_tokens":2,"output_tokens":295,"cache_read_input_tokens":18715,
267 "cache_creation_input_tokens":18472,"service_tier":"standard"},
268 "content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","limit":10}}]}}"#;
269
270 #[test]
271 fn maps_an_assistant_record_field_for_field() {
272 let parsed = record(ASSISTANT);
273 let event = &parsed.event;
274 assert_eq!(event.agent, "claude-code");
275 assert_eq!(event.provider, "anthropic");
276 assert_eq!(event.role, "assistant");
277 assert_eq!(event.ts, 1_785_578_400_000);
278 assert_eq!(event.model.as_deref(), Some("claude-sonnet-4-6"));
279 assert_eq!(event.session_id.as_deref(), Some("s1"));
280 assert_eq!(event.turn_id.as_deref(), Some("req_1"));
281 assert_eq!(event.project.as_deref(), Some("acme-api"));
282 assert_eq!(event.stop_reason.as_deref(), Some("tool_use"));
283 assert_eq!(event.input_tok, Some(2));
284 assert_eq!(event.output_tok, Some(295));
285 assert_eq!(event.cache_read_tok, Some(18715));
286 assert_eq!(event.cache_write_tok, Some(18472));
287 assert_eq!(event.is_sidechain, Some(false));
288 assert_eq!(
289 event.tool_calls,
290 vec![ToolCall::new("Read", Some("src/lib.rs".into()))]
291 );
292 assert_eq!(
293 parsed.usage_key.as_deref(),
294 Some("claude-code\u{1}s1\u{1}req_1")
295 );
296 assert_eq!(parsed.prompt_text, None);
297 }
298
299 #[test]
300 fn duration_is_never_invented() {
301 assert_eq!(record(ASSISTANT).event.duration_ms, None);
302 assert!(!CAPABILITIES.supports(Kpi::DurationMs));
303 assert!(CAPABILITIES.unsupported().contains(&Kpi::DurationMs));
304 }
305
306 #[test]
307 fn sibling_records_of_one_request_share_a_usage_key() {
308 let sibling = ASSISTANT.replace("\"uuid\":\"u1\"", "\"uuid\":\"u2\"");
309 assert_ne!(record(&sibling).event.id, record(ASSISTANT).event.id);
310 assert_eq!(record(&sibling).usage_key, record(ASSISTANT).usage_key);
311 }
312
313 #[test]
314 fn user_records_yield_prompt_text_in_both_content_shapes() {
315 let bare = r#"{"type":"user","uuid":"u2","timestamp":"2026-08-01T10:00:01Z","sessionId":"s1",
316 "cwd":"/home/me/acme-api","message":{"role":"user","content":"run the tests"}}"#;
317 let parsed = record(bare);
318 assert_eq!(parsed.prompt_text.as_deref(), Some("run the tests"));
319 assert_eq!(parsed.event.role, "user");
320 assert_eq!(parsed.event.input_tok, None, "user records carry no usage");
321 assert_eq!(parsed.usage_key, None);
322
323 let blocks = r#"{"type":"user","uuid":"u3","timestamp":"2026-08-01T10:00:02Z",
324 "message":{"content":[{"type":"text","text":"fix it"}]}}"#;
325 assert_eq!(record(blocks).prompt_text.as_deref(), Some("fix it"));
326 }
327
328 #[test]
329 fn tool_results_are_not_prompts() {
330 let line = r#"{"type":"user","uuid":"u4","timestamp":"2026-08-01T10:00:03Z",
331 "message":{"content":[{"type":"tool_result","content":"ok"}]}}"#;
332 assert_eq!(record(line).prompt_text, None);
333 }
334
335 #[test]
336 fn sidechain_records_are_ingested_and_marked() {
337 let line = ASSISTANT.replace("\"isSidechain\":false", "\"isSidechain\":true");
338 assert_eq!(record(&line).event.is_sidechain, Some(true));
339 }
340
341 #[test]
342 fn worktree_suffix_is_preserved_as_is() {
343 let line = ASSISTANT.replace("/home/me/code/acme-api", "/home/me/code/acme-api--wt-x");
344 assert_eq!(
345 record(&line).event.project.as_deref(),
346 Some("acme-api--wt-x")
347 );
348 }
349
350 #[test]
351 fn unknown_record_types_are_skipped_not_counted_as_unparseable() {
352 for kind in ["attachment", "mode", "file-history-snapshot", "system"] {
353 let line = format!(r#"{{"type":"{kind}","uuid":"x"}}"#);
354 assert_eq!(parse_line(&line), Parsed::Skipped, "{kind}");
355 }
356 assert_eq!(parse_line(" "), Parsed::Skipped);
357 }
358
359 #[test]
360 fn malformed_lines_are_counted_not_fatal() {
361 assert_eq!(parse_line("{not json"), Parsed::Unparseable);
362 assert_eq!(parse_line("{\"no\":\"type\"}"), Parsed::Unparseable);
363 assert_eq!(parse_line(r#"{"type":"assistant"}"#), Parsed::Unparseable);
365 }
366
367 #[test]
368 fn tool_target_is_absent_when_the_input_is_not_path_like() {
369 let line = ASSISTANT.replace(
370 r#"{"file_path":"src/lib.rs","limit":10}"#,
371 r#"{"command":"cargo test"}"#,
372 );
373 assert_eq!(record(&line).event.tool_calls[0].tool_target, None);
374 }
375
376 #[test]
377 fn missing_optional_fields_stay_absent() {
378 let line = r#"{"type":"assistant","uuid":"u9","timestamp":"2026-08-01T10:00:00Z"}"#;
379 let event = record(line).event;
380 assert_eq!(event.model, None);
381 assert_eq!(event.project, None);
382 assert_eq!(event.session_id, None);
383 assert_eq!(event.input_tok, None);
384 assert!(event.tool_calls.is_empty());
385 }
386}