Skip to main content

claudex_cli/commands/
summary.rs

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
533/// Render the human-readable cost section, plan-aware. Under `Plan::Api`
534/// this is the historical "All time / This week" pair. Under
535/// `Plan::FlatMonthly` it adds the user's flat rate, the API-equivalent
536/// figures, and the calendar-week leverage multiple. Leverage math is
537/// delegated to `Plan::leverage_this_week` so the JSON output and this
538/// text output share a single source of truth.
539fn 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}