use crate::usage_stats::tui::{ScanFn, StatsTuiScanRequest, run_stats_tui};
use crate::usage_stats::{
OutputFormat, RenderOptions, SessionId, SessionRecord, StatsCliArgs, StatsJsonOutput,
StatsQuery, StatsViewResult, ToolKind, ViewKind, build_session_detail_view,
build_sessions_view, build_summary_view, build_trend_view, filter_sessions_v1,
parse_time_range, render_stats_json, render_stats_table, validate_stats_cli_args,
};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use clap::Args;
use serde::Deserialize;
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[derive(Args, Debug, Clone)]
pub struct ClaudeCodeStatsArgs {
#[command(flatten)]
pub stats: StatsCliArgs,
#[arg(long)]
pub projects_dir: Option<PathBuf>,
#[arg(long)]
pub no_sidechain: bool,
}
pub fn run_stats(args: &ClaudeCodeStatsArgs) -> Result<()> {
validate_stats_cli_args(&args.stats)?;
let cwd = std::env::current_dir().context("resolve current directory")?;
let projects_dir = resolve_projects_dir(args.projects_dir.as_deref())?;
let include_sidechain = !args.no_sidechain;
if args.stats.tui {
let initial = StatsTuiScanRequest {
cwd: cwd.clone(),
group_by: args.stats.group_by,
range: args.stats.range.clone(),
limit: args.stats.limit,
verbose_paths: args.stats.verbose,
with_breakdown: false,
include_sidechain,
};
let scan_projects_dir = projects_dir.clone();
let scan_fn: ScanFn = Arc::new(move |request, _tx, cancel| {
let sessions = load_claude_sessions(
&scan_projects_dir,
&request.cwd,
request.include_sidechain,
Some(cancel.as_ref()),
)?;
let time_range = parse_time_range(&request.range, Utc::now())?;
let query = StatsQuery {
view: ViewKind::Summary,
group_by: request.group_by,
time_range,
cwd: request.cwd.clone(),
limit: request.limit,
id: None,
};
Ok(filter_sessions_v1(&sessions, &query))
});
return run_stats_tui(ToolKind::ClaudeCode, initial, scan_fn);
}
let time_range = parse_time_range(&args.stats.range, Utc::now())?;
let id = args
.stats
.id
.as_deref()
.map(|raw| SessionId(raw.to_string()));
let query = StatsQuery {
view: args.stats.view,
group_by: args.stats.group_by,
time_range,
cwd,
limit: args.stats.limit,
id,
};
let sessions = load_claude_sessions(&projects_dir, &query.cwd, include_sidechain, None)?;
let sessions = filter_sessions_v1(&sessions, &query);
let view = match query.view {
ViewKind::Summary => StatsViewResult::Summary(build_summary_view(&sessions)),
ViewKind::Trend => StatsViewResult::Trend(build_trend_view(&sessions, query.group_by)),
ViewKind::Sessions => {
StatsViewResult::Sessions(build_sessions_view(&sessions, query.limit))
}
ViewKind::Session => {
let id = query.id.as_ref().expect("validated");
let Some(view) = build_session_detail_view(&sessions, id) else {
bail!("session not found for id: {id}");
};
StatsViewResult::Session(view)
}
};
let output = StatsJsonOutput {
tool: ToolKind::ClaudeCode,
query,
view,
};
match args.stats.format {
OutputFormat::Json => {
let json = render_stats_json(&output)?;
println!("{json}");
}
OutputFormat::Table => {
let table = render_stats_table(
&output,
&RenderOptions {
verbose_paths: args.stats.verbose,
color: args.stats.color,
},
);
print!("{table}");
}
}
Ok(())
}
fn resolve_projects_dir(override_path: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = override_path {
return Ok(path.to_path_buf());
}
let home = crate::config::home_dir().context("resolve home directory")?;
Ok(home.join(".claude").join("projects"))
}
#[derive(Debug, Clone, Deserialize)]
struct SessionsIndex {
#[serde(default)]
entries: Vec<SessionsIndexEntry>,
}
#[derive(Debug, Clone, Deserialize)]
struct SessionsIndexEntry {
#[serde(rename = "sessionId")]
session_id: String,
#[serde(rename = "fullPath")]
full_path: String,
#[serde(rename = "projectPath")]
project_path: String,
#[serde(rename = "isSidechain", default)]
is_sidechain: bool,
#[serde(default)]
created: Option<String>,
#[serde(default)]
modified: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct ClaudeJsonlLine {
#[serde(default)]
timestamp: Option<String>,
#[serde(default)]
message: Option<ClaudeMessage>,
}
#[derive(Debug, Clone, Deserialize)]
struct ClaudeMessage {
#[serde(default)]
usage: Option<ClaudeUsage>,
}
#[derive(Debug, Clone, Deserialize)]
struct ClaudeUsage {
#[serde(default)]
input_tokens: Option<u64>,
#[serde(default)]
output_tokens: Option<u64>,
#[serde(default)]
cache_read_input_tokens: Option<u64>,
#[serde(default)]
cache_creation_input_tokens: Option<u64>,
}
#[derive(Default)]
struct UsageAccum {
input_any: bool,
output_any: bool,
cache_any: bool,
input_sum: u64,
output_sum: u64,
cache_sum: u64,
}
impl UsageAccum {
fn add(&mut self, usage: &ClaudeUsage) {
if let Some(v) = usage.input_tokens {
self.input_any = true;
self.input_sum = self.input_sum.saturating_add(v);
}
if let Some(v) = usage.output_tokens {
self.output_any = true;
self.output_sum = self.output_sum.saturating_add(v);
}
if let Some(v) = usage.cache_read_input_tokens {
self.cache_any = true;
self.cache_sum = self.cache_sum.saturating_add(v);
}
if let Some(v) = usage.cache_creation_input_tokens {
self.cache_any = true;
self.cache_sum = self.cache_sum.saturating_add(v);
}
}
fn build(self) -> crate::usage_stats::TokenUsage {
let input = self.input_any.then_some(self.input_sum);
let output = self.output_any.then_some(self.output_sum);
let cache = self.cache_any.then_some(self.cache_sum);
let mut total_sum = 0u64;
let mut any = false;
for v in [input, output, cache].into_iter().flatten() {
any = true;
total_sum = total_sum.saturating_add(v);
}
crate::usage_stats::TokenUsage {
total: any.then_some(total_sum),
input,
output,
cache,
reasoning: None,
}
}
}
fn load_claude_sessions(
projects_dir: &Path,
cwd_filter: &Path,
include_sidechain: bool,
cancel: Option<&AtomicBool>,
) -> Result<Vec<SessionRecord>> {
let mut sessions = Vec::new();
let entries = fs::read_dir(projects_dir)
.with_context(|| format!("read Claude projects directory: {}", projects_dir.display()))?;
for entry in entries {
if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
return Ok(Vec::new());
}
let Ok(entry) = entry else {
continue;
};
let project_dir = entry.path();
if !project_dir.is_dir() {
continue;
}
let index_path = project_dir.join("sessions-index.json");
if !index_path.exists() {
continue;
}
let index_raw = match fs::read_to_string(&index_path) {
Ok(s) => s,
Err(_) => continue,
};
let index: SessionsIndex = match serde_json::from_str(&index_raw) {
Ok(v) => v,
Err(_) => continue,
};
for entry in index.entries {
if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
return Ok(Vec::new());
}
if !include_sidechain && entry.is_sidechain {
continue;
}
let project_path = PathBuf::from(&entry.project_path);
if project_path != cwd_filter {
continue;
}
let jsonl_path = PathBuf::from(&entry.full_path);
let (usage, start_ts, end_ts) = if jsonl_path.exists() {
parse_session_jsonl(&jsonl_path).unwrap_or_default()
} else {
Default::default()
};
let fallback_created = entry.created.as_deref().and_then(parse_rfc3339_utc);
let fallback_modified = entry
.modified
.as_deref()
.and_then(parse_rfc3339_utc)
.or(fallback_created);
let start_ts = start_ts.or(fallback_created);
let Some(end_ts) = end_ts.or(fallback_modified) else {
continue;
};
sessions.push(SessionRecord {
tool: ToolKind::ClaudeCode,
id: SessionId(entry.session_id),
cwd: project_path,
title: None,
start_ts,
end_ts,
token_usage: usage,
is_sidechain: Some(entry.is_sidechain),
});
}
}
Ok(sessions)
}
type ClaudeSessionParseResult = (
crate::usage_stats::TokenUsage,
Option<DateTime<Utc>>,
Option<DateTime<Utc>>,
);
fn parse_session_jsonl(path: &Path) -> Result<ClaudeSessionParseResult> {
let file = File::open(path)
.with_context(|| format!("open Claude session jsonl: {}", path.display()))?;
let reader = BufReader::new(file);
let mut usage = UsageAccum::default();
let mut min_ts: Option<DateTime<Utc>> = None;
let mut max_ts: Option<DateTime<Utc>> = None;
for line in reader.lines() {
let Ok(line) = line else {
continue;
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(event) = serde_json::from_str::<ClaudeJsonlLine>(trimmed) else {
continue;
};
if let Some(ts) = event.timestamp.as_deref().and_then(parse_rfc3339_utc) {
min_ts = Some(min_ts.map_or(ts, |current| current.min(ts)));
max_ts = Some(max_ts.map_or(ts, |current| current.max(ts)));
}
if let Some(msg) = event.message
&& let Some(usage_line) = msg.usage
{
usage.add(&usage_line);
}
}
Ok((usage.build(), min_ts, max_ts))
}
fn parse_rfc3339_utc(raw: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(raw)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_session_jsonl_sums_known_usage_and_timestamps() {
let temp = TempDir::new().expect("temp dir");
let path = temp.path().join("s.jsonl");
fs::write(
&path,
r#"
{"timestamp":"2026-02-01T00:00:00Z","message":{"usage":{"input_tokens":10,"output_tokens":5}}}
{"timestamp":"2026-02-02T00:00:00Z","message":{"usage":{"cache_read_input_tokens":2,"cache_creation_input_tokens":3}}}
"#,
)
.expect("write jsonl");
let (usage, start, end) = parse_session_jsonl(&path).expect("parse");
assert_eq!(usage.input, Some(10));
assert_eq!(usage.output, Some(5));
assert_eq!(usage.cache, Some(5));
assert_eq!(usage.total, Some(20));
assert!(start.is_some());
assert!(end.is_some());
assert!(end.unwrap() > start.unwrap());
}
}