use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StatsCache {
pub version: u32,
#[serde(default)]
pub last_computed_date: Option<String>,
#[serde(default)]
pub daily_activity: Vec<DailyActivity>,
#[serde(default)]
pub daily_model_tokens: Vec<DailyModelTokens>,
#[serde(default)]
pub model_usage: HashMap<String, ModelUsage>,
#[serde(default)]
pub total_sessions: u64,
#[serde(default)]
pub total_messages: u64,
#[serde(default)]
pub longest_session: Option<LongestSession>,
#[serde(default)]
pub first_session_date: Option<String>,
#[serde(default)]
pub hour_counts: HashMap<String, u64>,
#[serde(default)]
pub total_speculation_time_saved_ms: u64,
}#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyActivity {
pub date: String,
#[serde(default)]
pub message_count: u64,
#[serde(default)]
pub session_count: u64,
#[serde(default)]
pub tool_call_count: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyModelTokens {
pub date: String,
#[serde(default)]
pub tokens_by_model: HashMap<String, u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelUsage {
#[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 web_search_requests: u64,
#[serde(default)]
pub cost_usd: f64,
#[serde(default)]
pub context_window: u64,
#[serde(default)]
pub max_output_tokens: u64,
}
impl ModelUsage {
pub fn total_tokens(&self) -> u64 {
self.input_tokens + self.output_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LongestSession {
#[serde(default)]
pub session_id: String,
#[serde(default)]
pub duration: u64,
#[serde(default)]
pub message_count: u64,
#[serde(default)]
pub timestamp: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryEntry {
pub display: String,
pub timestamp: i64,
pub project: String,
#[serde(default)]
pub session_id: String,
}
#[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,
}
#[derive(Debug, Clone, Default)]
pub struct ModelTokenDetail {
pub total_tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_creation_input_tokens: u64,
pub web_search_requests: u64,
pub web_fetch_requests: u64,
pub tool_call_count: u64,
pub tool_calls: HashMap<String, u64>,
}
impl ModelTokenDetail {
pub fn cache_hit_rate(&self) -> f64 {
let denom = self.input_tokens + self.cache_read_input_tokens;
if denom > 0 {
self.cache_read_input_tokens as f64 / denom as f64
} else {
0.0
}
}
#[allow(dead_code)]
pub fn total_tool_requests(&self) -> u64 {
self.tool_call_count + self.web_search_requests + self.web_fetch_requests
}
}
#[derive(Debug, Clone)]
pub struct SessionSummary {
pub project_name: String,
pub model: String,
pub total_tokens: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_creation_input_tokens: u64,
pub web_search_requests: u64,
pub web_fetch_requests: u64,
pub tool_call_count: u64,
pub tool_calls: HashMap<String, u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ViewData {
pub total_sessions: u64,
pub total_messages: u64,
pub total_tokens: u64,
pub model_tokens: HashMap<String, u64>,
pub model_token_detail: HashMap<String, ModelTokenDetail>,
pub project_tokens: HashMap<String, u64>,
pub daily_activity: Vec<DailyActivity>,
pub total_web_search_requests: u64,
pub total_web_fetch_requests: u64,
pub total_tool_calls: u64,
pub tool_calls: HashMap<String, u64>,
pub longest_session_secs: u64,
pub avg_session_secs: u64,
pub peak_period: String,
}
#[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_model_usage_total_tokens() {
let mu = ModelUsage {
input_tokens: 1000,
output_tokens: 500,
cache_read_input_tokens: 300,
cache_creation_input_tokens: 200,
web_search_requests: 0,
cost_usd: 0.0,
context_window: 0,
max_output_tokens: 0,
};
assert_eq!(mu.total_tokens(), 2000);
}
#[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_history_entry() {
let json = r#"{"display":"test msg","timestamp":1712345678000,"project":"D:\\dev\\example","sessionId":"abc123"}"#;
let entry: HistoryEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.display, "test msg");
assert_eq!(entry.project, "D:\\dev\\example");
assert_eq!(entry.session_id, "abc123");
}
#[test]
fn test_deserialize_stats_cache_empty() {
let json = r#"{"version":3}"#;
let stats: StatsCache = serde_json::from_str(json).unwrap();
assert_eq!(stats.version, 3);
assert!(stats.daily_activity.is_empty());
assert!(stats.model_usage.is_empty());
}
#[test]
fn test_deserialize_daily_model_tokens() {
let json = r#"{"date":"2026-04-15","tokensByModel":{"opus":1000,"sonnet":500}}"#;
let dmt: DailyModelTokens = serde_json::from_str(json).unwrap();
assert_eq!(dmt.date, "2026-04-15");
assert_eq!(dmt.tokens_by_model.get("opus").unwrap(), &1000);
assert_eq!(dmt.tokens_by_model.get("sonnet").unwrap(), &500);
}
#[test]
fn test_deserialize_daily_model_tokens_empty() {
let json = r#"{"date":"2026-04-15","tokensByModel":{}}"#;
let dmt: DailyModelTokens = serde_json::from_str(json).unwrap();
assert_eq!(dmt.date, "2026-04-15");
assert!(dmt.tokens_by_model.is_empty());
}
#[test]
fn test_deserialize_stats_cache_with_daily_model_tokens() {
let json = r#"{"version":3,"dailyModelTokens":[{"date":"2026-04-15","tokensByModel":{"opus":100}},{"date":"2026-04-16","tokensByModel":{"sonnet":200}}]}"#;
let stats: StatsCache = serde_json::from_str(json).unwrap();
assert_eq!(stats.daily_model_tokens.len(), 2);
assert_eq!(stats.daily_model_tokens[0].date, "2026-04-15");
assert_eq!(stats.daily_model_tokens[0].tokens_by_model.get("opus").unwrap(), &100);
}
#[test]
fn test_model_token_detail_cache_hit_rate() {
let mut detail = ModelTokenDetail::default();
detail.input_tokens = 800;
detail.cache_read_input_tokens = 200;
let rate = detail.cache_hit_rate();
assert!((rate - 0.2).abs() < 1e-9);
}
#[test]
fn test_model_token_detail_cache_hit_rate_zero() {
let detail = ModelTokenDetail::default();
assert_eq!(detail.cache_hit_rate(), 0.0);
}
#[test]
fn test_model_token_detail_cache_hit_rate_full() {
let mut detail = ModelTokenDetail::default();
detail.input_tokens = 0;
detail.cache_read_input_tokens = 1000;
assert_eq!(detail.cache_hit_rate(), 1.0);
}
#[test]
fn test_model_token_detail_total_tool_requests() {
let mut detail = ModelTokenDetail::default();
detail.tool_call_count = 50;
detail.web_search_requests = 10;
detail.web_fetch_requests = 5;
assert_eq!(detail.total_tool_requests(), 65);
}
#[test]
fn test_model_token_detail_total_tool_requests_zero() {
let detail = ModelTokenDetail::default();
assert_eq!(detail.total_tool_requests(), 0);
}
#[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_deserialize_stats_cache_full() {
let json = r#"{
"version": 3,
"lastComputedDate": "2026-05-30",
"totalSessions": 42,
"totalMessages": 500,
"firstSessionDate": "2025-11-04T08:34:16.259Z",
"totalSpeculationTimeSavedMs": 12345,
"modelUsage": {
"opus": {
"inputTokens": 10000,
"outputTokens": 5000,
"cacheReadInputTokens": 3000,
"cacheCreationInputTokens": 1000,
"webSearchRequests": 10,
"costUsd": 1.5,
"contextWindow": 200000,
"maxOutputTokens": 8192
}
},
"hourCounts": {"10": 50, "14": 80, "22": 30},
"longestSession": {
"sessionId": "abc123",
"duration": 7200,
"messageCount": 100,
"timestamp": "2026-04-15T10:00:00Z"
}
}"#;
let stats: StatsCache = serde_json::from_str(json).unwrap();
assert_eq!(stats.version, 3);
assert_eq!(stats.last_computed_date.as_deref(), Some("2026-05-30"));
assert_eq!(stats.total_sessions, 42);
assert_eq!(stats.total_messages, 500);
assert_eq!(stats.first_session_date.as_deref(), Some("2025-11-04T08:34:16.259Z"));
assert_eq!(stats.total_speculation_time_saved_ms, 12345);
let opus = stats.model_usage.get("opus").unwrap();
assert_eq!(opus.input_tokens, 10000);
assert_eq!(opus.cost_usd, 1.5);
assert_eq!(opus.context_window, 200000);
assert_eq!(stats.hour_counts.get("14").unwrap(), &80);
let longest = stats.longest_session.unwrap();
assert_eq!(longest.session_id, "abc123");
assert_eq!(longest.duration, 7200);
assert_eq!(longest.message_count, 100);
}
#[test]
fn test_deserialize_history_entry_missing_session_id() {
let json = r#"{"display":"test","timestamp":1234567890,"project":"/test"}"#;
let entry: HistoryEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.session_id, "");
}
#[test]
fn test_deserialize_daily_activity() {
let json = r#"{"date":"2026-04-15","messageCount":25,"sessionCount":3,"toolCallCount":10}"#;
let da: DailyActivity = serde_json::from_str(json).unwrap();
assert_eq!(da.date, "2026-04-15");
assert_eq!(da.message_count, 25);
assert_eq!(da.session_count, 3);
assert_eq!(da.tool_call_count, 10);
}
#[test]
fn test_deserialize_daily_activity_defaults() {
let json = r#"{"date":"2026-04-15"}"#;
let da: DailyActivity = serde_json::from_str(json).unwrap();
assert_eq!(da.date, "2026-04-15");
assert_eq!(da.message_count, 0);
assert_eq!(da.session_count, 0);
assert_eq!(da.tool_call_count, 0);
}
#[test]
fn test_model_usage_total_tokens_zero() {
let mu = ModelUsage {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
web_search_requests: 0,
cost_usd: 0.0,
context_window: 0,
max_output_tokens: 0,
};
assert_eq!(mu.total_tokens(), 0);
}
#[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_view_data_default() {
let vd = ViewData::default();
assert_eq!(vd.total_sessions, 0);
assert_eq!(vd.total_messages, 0);
assert_eq!(vd.total_tokens, 0);
assert!(vd.model_tokens.is_empty());
assert!(vd.model_token_detail.is_empty());
assert!(vd.project_tokens.is_empty());
assert!(vd.daily_activity.is_empty());
assert_eq!(vd.peak_period, "");
}
#[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);
}
}