use std::collections::BTreeSet;
use std::io;
use std::path::{Path, PathBuf};
use chrono::DateTime;
use serde_json::Value;
use super::{usage_key, Adapter, Capabilities, Kpi, Parsed, ParsedRecord};
use crate::config::Config;
use crate::store::{event_id, Event, EventIdentity, ToolCall};
pub const AGENT: &str = "claude-code";
pub const PROVIDER: &str = "anthropic";
pub const DEFAULT_ROOT: &str = "~/.claude/projects";
const CAPABILITIES: Capabilities = Capabilities::new(&[
Kpi::Tokens,
Kpi::CacheTokens,
Kpi::Cost,
Kpi::Prompts,
Kpi::ToolCalls,
Kpi::StopReason,
Kpi::Sidechain,
]);
const INGESTED_TYPES: [&str; 2] = ["assistant", "user"];
const TARGET_KEYS: [&str; 6] = [
"file_path",
"notebook_path",
"path",
"pattern",
"url",
"file",
];
pub struct ClaudeCodeAdapter;
impl Adapter for ClaudeCodeAdapter {
fn name(&self) -> &'static str {
AGENT
}
fn is_implemented(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
CAPABILITIES
}
fn root(&self, config: &Config) -> Option<PathBuf> {
Some(
config
.source(AGENT)
.path
.unwrap_or_else(|| PathBuf::from(DEFAULT_ROOT)),
)
}
fn discover(&self, root: &Path) -> io::Result<Vec<PathBuf>> {
super::jsonl_files(root)
}
fn session_count(&self, root: &Path) -> io::Result<usize> {
let sessions: BTreeSet<String> = self
.discover(root)?
.iter()
.filter_map(|path| path.file_stem()?.to_str().map(str::to_string))
.collect();
Ok(sessions.len())
}
fn parse_line(&self, _source: &Path, line: &str) -> Parsed {
parse_line(line)
}
}
fn parse_line(line: &str) -> Parsed {
if line.trim().is_empty() {
return Parsed::Skipped;
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
return Parsed::Unparseable;
};
let Some(kind) = value.get("type").and_then(Value::as_str) else {
return Parsed::Unparseable;
};
if !INGESTED_TYPES.contains(&kind) {
return Parsed::Skipped;
}
let Some(ts) = value
.get("timestamp")
.and_then(Value::as_str)
.and_then(ts_ms)
else {
return Parsed::Unparseable;
};
let session_id = string(&value, "sessionId");
let source_id = value.get("uuid").and_then(Value::as_str);
let turn_id = string(&value, "requestId");
let id = event_id(EventIdentity {
agent: AGENT,
session_id: session_id.as_deref(),
source_id,
ts,
role: kind,
extra: None,
});
let mut event = Event::new(id, ts, AGENT, PROVIDER, kind);
event.session_id = session_id.clone();
event.turn_id = turn_id.clone();
event.project = value
.get("cwd")
.and_then(Value::as_str)
.and_then(project_from_cwd);
event.is_sidechain = value.get("isSidechain").and_then(Value::as_bool);
let message = value.get("message");
event.model = message.and_then(|m| string(m, "model"));
event.stop_reason = message.and_then(|m| string(m, "stop_reason"));
event.tool_calls = tool_calls(message);
let mut usage_dedup_key = None;
if kind == "assistant" {
if let Some(usage) = message.and_then(|m| m.get("usage")) {
event.input_tok = count(usage, "input_tokens");
event.output_tok = count(usage, "output_tokens");
event.cache_read_tok = count(usage, "cache_read_input_tokens");
event.cache_write_tok = count(usage, "cache_creation_input_tokens");
usage_dedup_key = turn_id
.as_deref()
.map(|turn| usage_key(AGENT, session_id.as_deref(), turn));
}
}
let prompt_text = if kind == "user" {
message.and_then(|m| m.get("content")).and_then(prompt_text)
} else {
None
};
Parsed::Record(Box::new(ParsedRecord {
event,
prompt_text,
usage_key: usage_dedup_key,
}))
}
fn ts_ms(text: &str) -> Option<i64> {
DateTime::parse_from_rfc3339(text)
.ok()
.map(|dt| dt.timestamp_millis())
}
fn project_from_cwd(cwd: &str) -> Option<String> {
Path::new(cwd)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.map(str::to_string)
}
fn string(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(Value::as_str).map(str::to_string)
}
fn count(usage: &Value, key: &str) -> Option<u64> {
usage.get(key).and_then(Value::as_u64)
}
fn tool_calls(message: Option<&Value>) -> Vec<ToolCall> {
let Some(blocks) = message
.and_then(|m| m.get("content"))
.and_then(Value::as_array)
else {
return Vec::new();
};
blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.filter_map(|block| {
let name = block.get("name").and_then(Value::as_str)?;
Some(ToolCall::new(name, tool_target(block.get("input"))))
})
.collect()
}
fn tool_target(input: Option<&Value>) -> Option<String> {
let input = input?;
TARGET_KEYS
.iter()
.find_map(|key| input.get(key).and_then(Value::as_str))
.filter(|target| !target.is_empty())
.map(str::to_string)
}
fn prompt_text(content: &Value) -> Option<String> {
let text = match content {
Value::String(text) => text.clone(),
Value::Array(blocks) => blocks
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => return None,
};
(!text.trim().is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
fn record(line: &str) -> ParsedRecord {
match parse_line(line) {
Parsed::Record(record) => *record,
other => panic!("expected a record, got {other:?}"),
}
}
const ASSISTANT: &str = r#"{"type":"assistant","uuid":"u1","timestamp":"2026-08-01T10:00:00.000Z",
"sessionId":"s1","requestId":"req_1","cwd":"/home/me/code/acme-api","isSidechain":false,
"message":{"model":"claude-sonnet-4-6","stop_reason":"tool_use",
"usage":{"input_tokens":2,"output_tokens":295,"cache_read_input_tokens":18715,
"cache_creation_input_tokens":18472,"service_tier":"standard"},
"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","limit":10}}]}}"#;
#[test]
fn maps_an_assistant_record_field_for_field() {
let parsed = record(ASSISTANT);
let event = &parsed.event;
assert_eq!(event.agent, "claude-code");
assert_eq!(event.provider, "anthropic");
assert_eq!(event.role, "assistant");
assert_eq!(event.ts, 1_785_578_400_000);
assert_eq!(event.model.as_deref(), Some("claude-sonnet-4-6"));
assert_eq!(event.session_id.as_deref(), Some("s1"));
assert_eq!(event.turn_id.as_deref(), Some("req_1"));
assert_eq!(event.project.as_deref(), Some("acme-api"));
assert_eq!(event.stop_reason.as_deref(), Some("tool_use"));
assert_eq!(event.input_tok, Some(2));
assert_eq!(event.output_tok, Some(295));
assert_eq!(event.cache_read_tok, Some(18715));
assert_eq!(event.cache_write_tok, Some(18472));
assert_eq!(event.is_sidechain, Some(false));
assert_eq!(
event.tool_calls,
vec![ToolCall::new("Read", Some("src/lib.rs".into()))]
);
assert_eq!(
parsed.usage_key.as_deref(),
Some("claude-code\u{1}s1\u{1}req_1")
);
assert_eq!(parsed.prompt_text, None);
}
#[test]
fn duration_is_never_invented() {
assert_eq!(record(ASSISTANT).event.duration_ms, None);
assert!(!CAPABILITIES.supports(Kpi::DurationMs));
assert!(CAPABILITIES.unsupported().contains(&Kpi::DurationMs));
}
#[test]
fn sibling_records_of_one_request_share_a_usage_key() {
let sibling = ASSISTANT.replace("\"uuid\":\"u1\"", "\"uuid\":\"u2\"");
assert_ne!(record(&sibling).event.id, record(ASSISTANT).event.id);
assert_eq!(record(&sibling).usage_key, record(ASSISTANT).usage_key);
}
#[test]
fn user_records_yield_prompt_text_in_both_content_shapes() {
let bare = r#"{"type":"user","uuid":"u2","timestamp":"2026-08-01T10:00:01Z","sessionId":"s1",
"cwd":"/home/me/acme-api","message":{"role":"user","content":"run the tests"}}"#;
let parsed = record(bare);
assert_eq!(parsed.prompt_text.as_deref(), Some("run the tests"));
assert_eq!(parsed.event.role, "user");
assert_eq!(parsed.event.input_tok, None, "user records carry no usage");
assert_eq!(parsed.usage_key, None);
let blocks = r#"{"type":"user","uuid":"u3","timestamp":"2026-08-01T10:00:02Z",
"message":{"content":[{"type":"text","text":"fix it"}]}}"#;
assert_eq!(record(blocks).prompt_text.as_deref(), Some("fix it"));
}
#[test]
fn tool_results_are_not_prompts() {
let line = r#"{"type":"user","uuid":"u4","timestamp":"2026-08-01T10:00:03Z",
"message":{"content":[{"type":"tool_result","content":"ok"}]}}"#;
assert_eq!(record(line).prompt_text, None);
}
#[test]
fn sidechain_records_are_ingested_and_marked() {
let line = ASSISTANT.replace("\"isSidechain\":false", "\"isSidechain\":true");
assert_eq!(record(&line).event.is_sidechain, Some(true));
}
#[test]
fn worktree_suffix_is_preserved_as_is() {
let line = ASSISTANT.replace("/home/me/code/acme-api", "/home/me/code/acme-api--wt-x");
assert_eq!(
record(&line).event.project.as_deref(),
Some("acme-api--wt-x")
);
}
#[test]
fn unknown_record_types_are_skipped_not_counted_as_unparseable() {
for kind in ["attachment", "mode", "file-history-snapshot", "system"] {
let line = format!(r#"{{"type":"{kind}","uuid":"x"}}"#);
assert_eq!(parse_line(&line), Parsed::Skipped, "{kind}");
}
assert_eq!(parse_line(" "), Parsed::Skipped);
}
#[test]
fn malformed_lines_are_counted_not_fatal() {
assert_eq!(parse_line("{not json"), Parsed::Unparseable);
assert_eq!(parse_line("{\"no\":\"type\"}"), Parsed::Unparseable);
assert_eq!(parse_line(r#"{"type":"assistant"}"#), Parsed::Unparseable);
}
#[test]
fn tool_target_is_absent_when_the_input_is_not_path_like() {
let line = ASSISTANT.replace(
r#"{"file_path":"src/lib.rs","limit":10}"#,
r#"{"command":"cargo test"}"#,
);
assert_eq!(record(&line).event.tool_calls[0].tool_target, None);
}
#[test]
fn missing_optional_fields_stay_absent() {
let line = r#"{"type":"assistant","uuid":"u9","timestamp":"2026-08-01T10:00:00Z"}"#;
let event = record(line).event;
assert_eq!(event.model, None);
assert_eq!(event.project, None);
assert_eq!(event.session_id, None);
assert_eq!(event.input_tok, None);
assert!(event.tool_calls.is_empty());
}
}