use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum SessionEvent {
#[serde(rename = "user")]
User {
#[serde(default)]
uuid: String,
#[serde(default)]
timestamp: Option<String>,
#[serde(default)]
message: Option<serde_json::Value>,
#[serde(default)]
session_id: Option<String>,
},
#[serde(rename = "assistant")]
Assistant {
#[serde(default)]
uuid: String,
#[serde(default)]
timestamp: Option<String>,
#[serde(default)]
message: Option<AssistantMessage>,
#[serde(default)]
session_id: Option<String>,
},
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AssistantMessage {
#[serde(default)]
pub model: String,
#[serde(default)]
pub usage: Option<TokenUsage>,
#[serde(default)]
pub content: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct TokenUsage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cache_read_input_tokens: u64,
#[serde(default)]
pub cache_creation_input_tokens: u64,
#[serde(default)]
pub server_tool_use: Option<ServerToolUse>,
}
impl TokenUsage {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ServerToolUse {
#[serde(default)]
pub web_search_requests: u64,
#[serde(default)]
pub web_fetch_requests: u64,
}
#[derive(Debug, Clone)]
pub struct ProjectDir {
pub display_name: String,
pub full_path: String,
pub session_files: Vec<PathBuf>,
pub path_resolved: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_assistant_with_tool_use() {
let json = r#"{"type":"assistant","uuid":"abc","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":100,"output_tokens":50},"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}},{"type":"text","text":"done"},{"type":"tool_use","name":"Read","input":{"file":"a.txt"}}]}}"#;
let event: SessionEvent = serde_json::from_str(json).unwrap();
match event {
SessionEvent::Assistant { message, .. } => {
let msg = message.unwrap();
let tool_calls = msg.content.as_ref()
.map(|blocks| blocks.iter().filter(|b| {
b.get("type").and_then(|t| t.as_str()) == Some("tool_use")
}).count())
.unwrap_or(0);
assert_eq!(tool_calls, 2, "Should find 2 tool_use blocks in content");
}
_ => panic!("Expected Assistant event"),
}
}
#[test]
fn test_deserialize_assistant_without_content() {
let json = r#"{"type":"assistant","uuid":"abc","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":100,"output_tokens":50}}}"#;
let event: SessionEvent = serde_json::from_str(json).unwrap();
match event {
SessionEvent::Assistant { message, .. } => {
let msg = message.unwrap();
let tool_calls = msg.content.as_ref()
.map(|blocks| blocks.iter().filter(|b| {
b.get("type").and_then(|t| t.as_str()) == Some("tool_use")
}).count())
.unwrap_or(0);
assert_eq!(tool_calls, 0, "No content means no tool calls");
}
_ => panic!("Expected Assistant event"),
}
}
#[test]
fn test_token_usage_total_tokens() {
let tu = TokenUsage {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 25,
cache_creation_input_tokens: 25,
server_tool_use: None,
};
assert_eq!(tu.total_tokens(), 200);
}
#[test]
fn test_deserialize_assistant_event() {
let json = r#"{"type":"assistant","uuid":"abc","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":100,"output_tokens":50}}}"#;
let event: SessionEvent = serde_json::from_str(json).unwrap();
match event {
SessionEvent::Assistant { timestamp, message, .. } => {
assert_eq!(timestamp.unwrap(), "2026-04-15T10:30:00Z");
let msg = message.unwrap();
assert_eq!(msg.model, "opus");
let usage = msg.usage.unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
}
_ => panic!("Expected Assistant event"),
}
}
#[test]
fn test_deserialize_user_event() {
let json = r#"{"type":"user","uuid":"xyz","timestamp":"2026-04-15T09:00:00Z","message":{"role":"user","content":"hello"}}"#;
let event: SessionEvent = serde_json::from_str(json).unwrap();
match event {
SessionEvent::User { timestamp, .. } => {
assert_eq!(timestamp.unwrap(), "2026-04-15T09:00:00Z");
}
_ => panic!("Expected User event"),
}
}
#[test]
fn test_deserialize_unknown_event_type() {
let json = r#"{"type":"some-new-type","data":"foo"}"#;
let event: SessionEvent = serde_json::from_str(json).unwrap();
assert!(matches!(event, SessionEvent::Unknown));
}
#[test]
fn test_deserialize_token_usage_with_server_tool_use() {
let json = r#"{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":25,"cache_creation_input_tokens":10,"server_tool_use":{"web_search_requests":3,"web_fetch_requests":2}}"#;
let usage: TokenUsage = serde_json::from_str(json).unwrap();
assert_eq!(usage.input_tokens, 100);
let stu = usage.server_tool_use.unwrap();
assert_eq!(stu.web_search_requests, 3);
assert_eq!(stu.web_fetch_requests, 2);
}
#[test]
fn test_deserialize_token_usage_without_server_tool_use() {
let json = r#"{"input_tokens":100,"output_tokens":50}"#;
let usage: TokenUsage = serde_json::from_str(json).unwrap();
assert_eq!(usage.input_tokens, 100);
assert!(usage.server_tool_use.is_none());
}
#[test]
fn test_tool_use_name_extraction() {
let json = r#"{"type":"assistant","uuid":"x","timestamp":"2026-04-15T10:30:00Z","message":{"model":"opus","usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}},{"type":"tool_use","name":"Read","input":{"file":"a.txt"}},{"type":"tool_use","name":"Bash","input":{"command":"pwd"}},{"type":"text","text":"output"}]}}"#;
let event: SessionEvent = serde_json::from_str(json).unwrap();
match event {
SessionEvent::Assistant { message, .. } => {
let msg = message.unwrap();
let blocks = msg.content.unwrap();
let mut tool_names: Vec<String> = blocks.iter()
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_use"))
.filter_map(|b| b.get("name").and_then(|n| n.as_str()))
.map(|s| s.to_string())
.collect();
tool_names.sort();
assert_eq!(tool_names, vec!["Bash", "Bash", "Read"]);
}
_ => panic!("Expected Assistant event"),
}
}
#[test]
fn test_parse_real_session_file_from_disk() {
let claude_dir = crate::utils::find_claude_dir(None);
if claude_dir.is_none() {
eprintln!("Skipping: no .claude directory found");
return;
}
let projects_dir = claude_dir.unwrap().join("projects");
if !projects_dir.exists() {
eprintln!("Skipping: projects dir not found");
return;
}
use std::fs;
use std::io::{BufRead, BufReader};
let mut total_events = 0u64;
let mut assistant_events = 0u64;
let mut total_tokens = 0u64;
let mut total_tool_calls = 0u64;
let mut model_counts: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
if let Ok(proj_entries) = fs::read_dir(&projects_dir) {
for proj_entry in proj_entries {
if let Ok(proj) = proj_entry {
let proj_path = proj.path();
if !proj_path.is_dir() { continue; }
if let Ok(session_entries) = fs::read_dir(&proj_path) {
for session_entry in session_entries {
if let Ok(sess) = session_entry {
let sess_path = sess.path();
if sess_path.extension().map(|e| e == "jsonl").unwrap_or(false) {
if let Ok(file) = fs::File::open(&sess_path) {
let reader = BufReader::new(file);
for line in reader.lines() {
if let Ok(line) = line {
if line.trim().is_empty() { continue; }
total_events += 1;
if let Ok(SessionEvent::Assistant { message, .. }) =
serde_json::from_str::<SessionEvent>(&line)
{
assistant_events += 1;
if let Some(msg) = message {
if let Some(usage) = msg.usage {
let t = usage.total_tokens();
total_tokens += t;
*model_counts.entry(msg.model).or_insert(0) += t;
}
let tc = msg.content.as_ref()
.map(|blocks| blocks.iter().filter(|b| {
b.get("type").and_then(|t| t.as_str()) == Some("tool_use")
}).count() as u64)
.unwrap_or(0);
total_tool_calls += tc;
}
}
}
}
}
}
}
}
}
}
}
}
eprintln!("Total lines: {}, Assistant: {}, Tokens: {}M, Tool calls: {}",
total_events, assistant_events, total_tokens as f64 / 1e6, total_tool_calls);
assert!(total_events > 0);
assert!(assistant_events > 0);
}
}