1use std::collections::HashMap;
2
3use anyhow::Result;
4use chrono::{DateTime, Datelike, Duration, Local, Utc};
5
6use crate::cli::ResolvedFilter;
7use crate::ui;
8use claudex::index::IndexStore;
9use claudex::parser::parse_session;
10use claudex::plan::Plan;
11use claudex::providers::enabled_default;
12use claudex::store::{SessionStore, decode_project_name, display_project_name};
13use claudex::time_utils::local_day_start_ms;
14use claudex::types::{ModelPricing, TokenUsage};
15
16pub fn run(json: bool, no_index: bool, plan: Plan, filter: &ResolvedFilter) -> Result<()> {
17 if !no_index && let Ok(()) = run_indexed(json, plan, filter) {
18 return Ok(());
19 }
20 run_from_files(json, plan, filter)
21}
22
23fn run_indexed(json: bool, plan: Plan, filter: &ResolvedFilter) -> Result<()> {
24 let providers = enabled_default()?;
25 let mut idx = IndexStore::open()?;
26 idx.ensure_fresh(&providers)?;
27 let data = idx.query_summary(filter)?;
28
29 if json {
30 let mut out = serde_json::json!({
31 "total_sessions": data.total_sessions,
32 "sessions_today": data.sessions_today,
33 "sessions_this_week": data.sessions_this_week,
34 "total_input_tokens": data.total_input_tokens,
35 "total_output_tokens": data.total_output_tokens,
36 "total_cache_creation_tokens": data.total_cache_creation,
37 "total_cache_read_tokens": data.total_cache_read,
38 "total_tokens": data.total_input_tokens + data.total_output_tokens
39 + data.total_cache_creation + data.total_cache_read,
40 "thinking_block_count": data.thinking_block_count,
41 "avg_turn_duration_ms": data.avg_turn_duration_ms,
42 "pr_count": data.pr_count,
43 "files_modified_count": data.files_modified_count,
44 "top_projects": data.top_projects.iter()
45 .map(|(p, c)| serde_json::json!({"project": p, "sessions": c}))
46 .collect::<Vec<_>>(),
47 "top_tools": data.top_tools.iter()
48 .map(|(t, c)| serde_json::json!({"tool": t, "calls": c}))
49 .collect::<Vec<_>>(),
50 "top_stop_reasons": data.top_stop_reasons.iter()
51 .map(|(reason, count)| serde_json::json!({"stop_reason": reason, "count": count}))
52 .collect::<Vec<_>>(),
53 "model_distribution": data.model_distribution.iter()
54 .map(|(m, s, c)| serde_json::json!({"model": m, "sessions": s, "cost_usd": c}))
55 .collect::<Vec<_>>(),
56 "most_recent": data.most_recent.as_ref().map(|r| {
57 let date = DateTime::from_timestamp_millis(r.first_timestamp_ms)
58 .map(|d| d.to_rfc3339());
59 serde_json::json!({
60 "project": r.project,
61 "session_id": r.session_id,
62 "date": date,
63 "model": r.model,
64 "message_count": r.message_count,
65 })
66 }),
67 });
68 out.as_object_mut()
69 .expect("json! macro produces a JSON object")
70 .extend(plan.cost_fields(data.total_cost, data.week_cost));
71 println!("{}", serde_json::to_string_pretty(&out)?);
72 return Ok(());
73 }
74
75 section("Sessions");
76 println!(
77 " Total: {}",
78 ui::emphasis(&ui::fmt_count(data.total_sessions as u64))
79 );
80 println!(
81 " Today: {}",
82 ui::fmt_count(data.sessions_today as u64)
83 );
84 println!(
85 " This week: {}",
86 ui::fmt_count(data.sessions_this_week as u64)
87 );
88
89 print_cost_section(plan, data.total_cost, data.week_cost);
90
91 section("Tokens");
92 println!(
93 " Input: {}",
94 ui::count(data.total_input_tokens as u64)
95 );
96 println!(
97 " Output: {}",
98 ui::count(data.total_output_tokens as u64)
99 );
100 println!(
101 " Cache write: {}",
102 ui::count(data.total_cache_creation as u64)
103 );
104 println!(" Cache read: {}", ui::count(data.total_cache_read as u64));
105 println!(
106 " Total: {}",
107 ui::emphasis(&ui::count(
108 (data.total_input_tokens
109 + data.total_output_tokens
110 + data.total_cache_creation
111 + data.total_cache_read) as u64,
112 ))
113 );
114
115 section("Top Projects");
116 if data.top_projects.is_empty() {
117 println!(" (none)");
118 } else {
119 for (i, (proj, count)) in data.top_projects.iter().enumerate() {
120 println!(
121 " {}. {} {} sessions",
122 i + 1,
123 ui::project(proj),
124 ui::fmt_count(*count as u64)
125 );
126 }
127 }
128
129 section("Top Tools");
130 if data.top_tools.is_empty() {
131 println!(" (none)");
132 } else {
133 for (i, (tool, count)) in data.top_tools.iter().enumerate() {
134 println!(
135 " {}. {} {} calls",
136 i + 1,
137 ui::tool_name(tool),
138 ui::fmt_count(*count as u64)
139 );
140 }
141 }
142
143 section("Top Stop Reasons");
144 if data.top_stop_reasons.is_empty() {
145 println!(" (none)");
146 } else {
147 for (i, (reason, count)) in data.top_stop_reasons.iter().enumerate() {
148 println!(
149 " {}. {} {}",
150 i + 1,
151 ui::role(reason),
152 ui::fmt_count(*count as u64)
153 );
154 }
155 }
156
157 section("Model Distribution");
158 if data.model_distribution.is_empty() {
159 println!(" (none)");
160 } else {
161 for (model, sessions, c) in &data.model_distribution {
162 println!(
163 " {} {} sessions {}",
164 ui::model_name(model),
165 ui::fmt_count(*sessions as u64),
166 ui::cost(*c)
167 );
168 }
169 }
170
171 section("Metrics");
172 if data.thinking_block_count > 0 {
173 println!(
174 " Thinking blocks: {}",
175 ui::fmt_count(data.thinking_block_count as u64)
176 );
177 }
178 if let Some(avg) = data.avg_turn_duration_ms {
179 let secs = avg / 1000.0;
180 if secs < 60.0 {
181 println!(" Avg turn duration: {secs:.1}s");
182 } else {
183 println!(" Avg turn duration: {:.1}m", secs / 60.0);
184 }
185 }
186 if data.pr_count > 0 {
187 println!(
188 " PRs linked: {}",
189 ui::fmt_count(data.pr_count as u64)
190 );
191 }
192 if data.files_modified_count > 0 {
193 println!(
194 " Files modified: {}",
195 ui::fmt_count(data.files_modified_count as u64)
196 );
197 }
198
199 if let Some(r) = &data.most_recent {
200 section("Most Recent Session");
201 println!(" Project: {}", ui::project(&r.project));
202 if let Some(dt) = DateTime::from_timestamp_millis(r.first_timestamp_ms) {
203 println!(" Date: {}", dt.format("%Y-%m-%d %H:%M UTC"));
204 }
205 let sid: String = r.session_id.chars().take(8).collect();
206 println!(" Session: {}", ui::session_id(&sid));
207 let model = r
208 .model
209 .as_deref()
210 .map(|m| m.trim_start_matches("claude-").to_string())
211 .unwrap_or_else(|| "-".to_string());
212 println!(" Model: {}", model);
213 println!(" Messages: {}", ui::fmt_count(r.message_count as u64));
214 }
215
216 println!();
217 Ok(())
218}
219
220fn run_from_files(json: bool, plan: Plan, filter: &ResolvedFilter) -> Result<()> {
221 filter.ensure_no_index_supported()?;
222
223 let store = SessionStore::new()?;
224 let files = store.all_session_files(None)?;
225
226 let today = Local::now().date_naive();
227 let days_since_monday = today.weekday().num_days_from_monday() as i64;
228 let week_start = today - Duration::days(days_since_monday);
229 let today_start_ms = local_day_start_ms(today);
230 let week_start_ms = local_day_start_ms(week_start);
231
232 let mut total_sessions = 0usize;
233 let mut sessions_today = 0usize;
234 let mut sessions_this_week = 0usize;
235 let mut total_cost = 0.0f64;
236 let mut week_cost = 0.0f64;
237 let mut total_usage = TokenUsage::default();
238 let mut project_counts: HashMap<String, usize> = HashMap::new();
239 let mut tool_counts: HashMap<String, u64> = HashMap::new();
240 let mut stop_reason_counts: HashMap<String, u64> = HashMap::new();
241 let mut thinking_block_count = 0u64;
242 let mut total_turn_duration_ms = 0u64;
243 let mut total_turn_count = 0u64;
244 let mut pr_urls = std::collections::BTreeSet::new();
245 let mut files_modified = std::collections::BTreeSet::new();
246 let mut model_distribution: HashMap<String, (u64, f64)> = HashMap::new();
247
248 struct RecentSession {
249 date: DateTime<Utc>,
250 project: String,
251 session_id: String,
252 model: Option<String>,
253 message_count: usize,
254 }
255 let mut most_recent: Option<RecentSession> = None;
256
257 for (project_raw, path) in &files {
258 let stats = match parse_session(path) {
259 Ok(s) => s,
260 Err(_) => continue,
261 };
262 if !filter.matches("claude", &stats, false) {
263 continue;
264 }
265
266 total_sessions += 1;
267 let session_cost = stats.cost_usd();
268 total_cost += session_cost;
269 total_usage.add(&stats.usage);
270 thinking_block_count += stats.thinking_block_count;
271 total_turn_duration_ms += stats
272 .turn_durations
273 .iter()
274 .map(|(dur, _)| *dur)
275 .sum::<u64>();
276 total_turn_count += stats.turn_durations.len() as u64;
277
278 if let Some(dt) = stats.first_timestamp {
279 let active_dt = stats.last_timestamp.unwrap_or(dt);
280 let active_ms = active_dt.timestamp_millis();
281 if active_ms >= today_start_ms {
282 sessions_today += 1;
283 }
284 if active_ms >= week_start_ms {
285 sessions_this_week += 1;
286 week_cost += session_cost;
287 }
288
289 let is_newer = most_recent
290 .as_ref()
291 .map(|r| active_dt > r.date)
292 .unwrap_or(true);
293 if is_newer {
294 most_recent = Some(RecentSession {
295 date: active_dt,
296 project: display_project_name(&decode_project_name(project_raw)),
297 session_id: stats.session_id.unwrap_or_default(),
298 model: stats.model.clone(),
299 message_count: stats.message_count,
300 });
301 }
302 }
303
304 let proj = display_project_name(&decode_project_name(project_raw));
305 *project_counts.entry(proj).or_insert(0) += 1;
306
307 for name in &stats.tool_names {
308 *tool_counts.entry(name.clone()).or_insert(0) += 1;
309 }
310 for (reason, count) in &stats.stop_reason_counts {
311 *stop_reason_counts.entry(reason.clone()).or_insert(0) += *count;
312 }
313 for (_, url, _, _) in &stats.pr_links {
314 if !url.is_empty() {
315 pr_urls.insert(url.clone());
316 }
317 }
318 for file in &stats.file_paths_modified {
319 files_modified.insert(file.clone());
320 }
321 let mut session_families = std::collections::BTreeSet::new();
322 for (model, usage) in &stats.model_usage {
323 let family = ModelPricing::name(Some(model)).to_string();
324 session_families.insert(family.clone());
325 let entry = model_distribution.entry(family).or_insert((0, 0.0));
326 entry.1 += usage.usage.cost_for_model(Some(model));
327 }
328 if session_families.is_empty()
329 && let Some(model) = &stats.model
330 {
331 let family = ModelPricing::name(Some(model)).to_string();
332 session_families.insert(family.clone());
333 let entry = model_distribution.entry(family).or_insert((0, 0.0));
334 entry.1 += session_cost;
335 }
336 for family in session_families {
337 let entry = model_distribution.entry(family).or_insert((0, 0.0));
338 entry.0 += 1;
339 }
340 }
341
342 let mut top_projects: Vec<(String, usize)> = project_counts.into_iter().collect();
343 top_projects.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
344 top_projects.truncate(5);
345
346 let mut top_tools: Vec<(String, u64)> = tool_counts.into_iter().collect();
347 top_tools.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
348 top_tools.truncate(5);
349
350 let mut top_stop_reasons: Vec<(String, u64)> = stop_reason_counts.into_iter().collect();
351 top_stop_reasons.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
352 top_stop_reasons.truncate(5);
353
354 let mut model_distribution: Vec<(String, u64, f64)> = model_distribution
355 .into_iter()
356 .map(|(model, (sessions, cost))| (model, sessions, cost))
357 .collect();
358 model_distribution.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
359 model_distribution.truncate(5);
360 let avg_turn_duration_ms = if total_turn_count == 0 {
361 None
362 } else {
363 Some(total_turn_duration_ms as f64 / total_turn_count as f64)
364 };
365
366 if json {
367 let mut out = serde_json::json!({
368 "total_sessions": total_sessions,
369 "sessions_today": sessions_today,
370 "sessions_this_week": sessions_this_week,
371 "total_input_tokens": total_usage.input_tokens,
372 "total_output_tokens": total_usage.output_tokens,
373 "total_cache_creation_tokens": total_usage.cache_creation_tokens,
374 "total_cache_read_tokens": total_usage.cache_read_tokens,
375 "total_tokens": total_usage.total_tokens(),
376 "thinking_block_count": thinking_block_count,
377 "avg_turn_duration_ms": avg_turn_duration_ms,
378 "pr_count": pr_urls.len(),
379 "files_modified_count": files_modified.len(),
380 "top_projects": top_projects.iter().map(|(p, c)| serde_json::json!({"project": p, "sessions": c})).collect::<Vec<_>>(),
381 "top_tools": top_tools.iter().map(|(t, c)| serde_json::json!({"tool": t, "calls": c})).collect::<Vec<_>>(),
382 "top_stop_reasons": top_stop_reasons.iter().map(|(reason, count)| serde_json::json!({"stop_reason": reason, "count": count})).collect::<Vec<_>>(),
383 "model_distribution": model_distribution.iter().map(|(m, s, c)| serde_json::json!({"model": m, "sessions": s, "cost_usd": c})).collect::<Vec<_>>(),
384 "most_recent": most_recent.as_ref().map(|r| serde_json::json!({
385 "project": r.project,
386 "session_id": r.session_id,
387 "date": r.date.to_rfc3339(),
388 "model": r.model,
389 "message_count": r.message_count,
390 })),
391 });
392 out.as_object_mut()
393 .expect("json! macro produces a JSON object")
394 .extend(plan.cost_fields(total_cost, week_cost));
395 println!("{}", serde_json::to_string_pretty(&out)?);
396 return Ok(());
397 }
398
399 section("Sessions");
400 println!(
401 " Total: {}",
402 ui::emphasis(&ui::fmt_count(total_sessions as u64))
403 );
404 println!(" Today: {}", ui::fmt_count(sessions_today as u64));
405 println!(" This week: {}", ui::fmt_count(sessions_this_week as u64));
406
407 print_cost_section(plan, total_cost, week_cost);
408
409 section("Tokens");
410 println!(" Input: {}", ui::count(total_usage.input_tokens));
411 println!(" Output: {}", ui::count(total_usage.output_tokens));
412 println!(
413 " Cache write: {}",
414 ui::count(total_usage.cache_creation_tokens)
415 );
416 println!(
417 " Cache read: {}",
418 ui::count(total_usage.cache_read_tokens)
419 );
420 println!(
421 " Total: {}",
422 ui::emphasis(&ui::count(total_usage.total_tokens()))
423 );
424
425 section("Top Projects");
426 if top_projects.is_empty() {
427 println!(" (none)");
428 } else {
429 for (i, (proj, count)) in top_projects.iter().enumerate() {
430 println!(
431 " {}. {} {} sessions",
432 i + 1,
433 ui::project(proj),
434 ui::fmt_count(*count as u64)
435 );
436 }
437 }
438
439 section("Top Tools");
440 if top_tools.is_empty() {
441 println!(" (none)");
442 } else {
443 for (i, (tool, count)) in top_tools.iter().enumerate() {
444 println!(
445 " {}. {} {} calls",
446 i + 1,
447 ui::tool_name(tool),
448 ui::fmt_count(*count)
449 );
450 }
451 }
452
453 section("Top Stop Reasons");
454 if top_stop_reasons.is_empty() {
455 println!(" (none)");
456 } else {
457 for (i, (reason, count)) in top_stop_reasons.iter().enumerate() {
458 println!(
459 " {}. {} {}",
460 i + 1,
461 ui::role(reason),
462 ui::fmt_count(*count)
463 );
464 }
465 }
466
467 section("Model Distribution");
468 if model_distribution.is_empty() {
469 println!(" (none)");
470 } else {
471 for (model, sessions, c) in &model_distribution {
472 println!(
473 " {} {} sessions {}",
474 ui::model_name(model),
475 ui::fmt_count(*sessions),
476 ui::cost(*c)
477 );
478 }
479 }
480
481 section("Metrics");
482 if thinking_block_count > 0 {
483 println!(
484 " Thinking blocks: {}",
485 ui::fmt_count(thinking_block_count)
486 );
487 }
488 if let Some(avg) = avg_turn_duration_ms {
489 let secs = avg / 1000.0;
490 if secs < 60.0 {
491 println!(" Avg turn duration: {secs:.1}s");
492 } else {
493 println!(" Avg turn duration: {:.1}m", secs / 60.0);
494 }
495 }
496 if !pr_urls.is_empty() {
497 println!(
498 " PRs linked: {}",
499 ui::fmt_count(pr_urls.len() as u64)
500 );
501 }
502 if !files_modified.is_empty() {
503 println!(
504 " Files modified: {}",
505 ui::fmt_count(files_modified.len() as u64)
506 );
507 }
508
509 if let Some(r) = &most_recent {
510 section("Most Recent Session");
511 println!(" Project: {}", ui::project(&r.project));
512 println!(" Date: {}", r.date.format("%Y-%m-%d %H:%M UTC"));
513 let sid: String = r.session_id.chars().take(8).collect();
514 println!(" Session: {}", ui::session_id(&sid));
515 let model = r
516 .model
517 .as_deref()
518 .map(|m| m.trim_start_matches("claude-").to_string())
519 .unwrap_or_else(|| "-".to_string());
520 println!(" Model: {}", model);
521 println!(" Messages: {}", ui::fmt_count(r.message_count as u64));
522 }
523
524 println!();
525 Ok(())
526}
527
528fn section(title: &str) {
529 println!("\n{}", ui::section_title(title));
530 println!("{}", "─".repeat(title.len()));
531}
532
533fn print_cost_section(plan: Plan, total_api: f64, week_api: f64) {
540 section("Cost (estimated)");
541 match plan {
542 Plan::Api => {
543 println!(" All time: {}", ui::cost(total_api));
544 println!(" This week: {}", ui::cost(week_api));
545 }
546 Plan::FlatMonthly { usd_per_month } => {
547 println!(
548 " Plan: flat-monthly {}/mo",
549 ui::cost(usd_per_month)
550 );
551 println!(" API equivalent (all): {}", ui::cost(total_api));
552 println!(" API equivalent (wk): {}", ui::cost(week_api));
553 match plan.leverage_this_week(week_api) {
554 Some(lev) => println!(" Leverage this week: {lev:.1}x"),
555 None => println!(" Leverage this week: — (no usage yet)"),
556 }
557 }
558 }
559}