1use std::collections::BTreeMap;
11
12use crate::adapters::Kpi;
13use crate::output::{Cell, Report, Table};
14use crate::store::Scanner;
15
16use super::{
17 count, desc, format_span, rollup, scan, short_id, ReportCtx, ReportError, Totals, NO_PROJECT,
18};
19
20const LIMIT: usize = 20;
22
23pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
24 let scanned = scan(scanner, ctx)?;
25 let by_session = rollup(&scanned.events, &ctx.pricing, |event| {
26 event.session_id.clone()
27 });
28 let total_sessions = by_session.len();
29
30 let mut projects: BTreeMap<String, String> = BTreeMap::new();
32 let mut durations: BTreeMap<String, Option<u64>> = BTreeMap::new();
33 for event in &scanned.events {
34 let Some(session) = event.session_id.clone() else {
35 continue;
36 };
37 if let Some(project) = &event.project {
38 projects.entry(session.clone()).or_insert(project.clone());
39 }
40 if let Some(duration) = event.duration_ms {
41 *durations.entry(session).or_default().get_or_insert(0) += duration;
42 }
43 }
44
45 let priced_anywhere = by_session.values().any(|totals| totals.cost.priced > 0);
48 let mut ranked: Vec<(String, Totals)> = by_session.into_iter().collect();
49 ranked.sort_by(|a, b| {
50 let key = |t: &Totals| {
51 if priced_anywhere {
52 t.cost.total
53 } else {
54 t.total_tokens() as f64
55 }
56 };
57 desc(key(&a.1), key(&b.1))
58 .then_with(|| b.1.total_tokens().cmp(&a.1.total_tokens()))
59 .then_with(|| a.0.cmp(&b.0))
60 });
61
62 let mut table = Table::new([
63 "session",
64 "project",
65 "span",
66 "duration",
67 "requests",
68 "in",
69 "out",
70 "cache r",
71 "est. cost",
72 ]);
73 let mut rows = Vec::new();
74
75 for (session, totals) in ranked.iter().take(LIMIT) {
76 let span = span_ms(totals);
77 let duration = durations.get(session).copied().flatten();
78 let project = projects.get(session).cloned();
79
80 let mut row = vec![
81 Cell::text(short_id(session)),
82 Cell::text(project.clone().unwrap_or_else(|| NO_PROJECT.to_string())),
83 Cell::text(format_span(span)),
84 match duration {
85 Some(ms) => Cell::text(format_span(ms as i64)),
86 None => Cell::Unsupported,
87 },
88 count(totals.requests),
89 ];
90 row.extend(totals.tail_cells());
91 table.push(row);
92
93 let mut json = serde_json::Map::new();
94 json.insert("session_id".into(), serde_json::json!(session));
95 json.insert("project".into(), serde_json::json!(project));
96 json.insert("first_ts".into(), serde_json::json!(totals.first_ts));
97 json.insert("last_ts".into(), serde_json::json!(totals.last_ts));
98 json.insert("span_ms".into(), serde_json::json!(span));
99 json.insert("duration_ms".into(), serde_json::json!(duration));
100 totals.write_json(&mut json);
101 rows.push(serde_json::Value::Object(json));
102 }
103
104 let mut notes = scanned.notes;
105 notes.push(
106 "span is the wall clock between a session's first and last event, including time you spent \
107 reading; it is not measured model time",
108 );
109 if rows.iter().all(|row| row["duration_ms"].is_null()) {
110 notes.push(format!(
111 "duration is blank because no source in this period logs per-turn duration ({}); blank \
112 means unrecorded, not zero",
113 adapters_without_duration()
114 ));
115 }
116 notes.push(if priced_anywhere {
117 "ordered by est. cost, highest first"
118 } else {
119 "ordered by total tokens, highest first, because no session in this period could be priced"
120 });
121 if total_sessions > LIMIT {
122 notes.push(format!(
123 "showing the top {LIMIT} of {total_sessions} sessions, in the table and in --json alike"
124 ));
125 }
126
127 Ok(Report::new("sessions", ctx.window, table)
128 .with_json_rows(rows)
129 .with_notes(notes.finish()))
130}
131
132fn span_ms(totals: &Totals) -> i64 {
133 match (totals.first_ts, totals.last_ts) {
134 (Some(first), Some(last)) => last - first,
135 _ => 0,
136 }
137}
138
139fn adapters_without_duration() -> String {
141 crate::adapters::registry()
142 .iter()
143 .filter(|adapter| {
144 adapter.is_implemented() && !adapter.capabilities().supports(Kpi::DurationMs)
145 })
146 .map(|adapter| adapter.name())
147 .collect::<Vec<_>>()
148 .join(", ")
149}
150
151#[cfg(test)]
152mod tests {
153 use super::super::testkit::*;
154 use super::*;
155 use crate::cli::TimeWindow;
156 use crate::output::{Style, UNSUPPORTED};
157 use crate::store::Event;
158
159 fn report(events: &[Event]) -> Report {
160 let (_dir, paths) = store(events);
161 build(
162 &Scanner::new(paths),
163 &ReportCtx::new(TimeWindow::all(), None, true),
164 )
165 .unwrap()
166 }
167
168 fn cheap_and_dear() -> Vec<Event> {
169 vec![
170 priced(used("a", ms(2026, 8, 4, 8), "acme", "m", 10, 1), 0.1),
171 priced(used("b", ms(2026, 8, 4, 10), "acme", "m", 10, 1), 0.1),
172 priced(used("c", ms(2026, 8, 4, 9), "dotfiles", "m", 5_000, 1), 9.0),
173 ]
174 }
175
176 #[test]
177 fn most_expensive_session_first() {
178 let report = report(&cheap_and_dear());
179 assert_eq!(report.json_rows[0]["session_id"], "session-dotfiles");
180 assert_eq!(report.json_rows[0]["cost_est"], 9.0);
181 assert_eq!(report.json_rows[0]["project"], "dotfiles");
182 assert!(report
183 .notes
184 .iter()
185 .any(|n| n.contains("ordered by est. cost")));
186 }
187
188 #[test]
189 fn falls_back_to_tokens_when_nothing_can_be_priced() {
190 let report = report(&[
191 used("a", ms(2026, 8, 4, 8), "acme", "m", 10, 1),
192 used("c", ms(2026, 8, 4, 9), "dotfiles", "m", 5_000, 1),
193 ]);
194 assert_eq!(report.json_rows[0]["session_id"], "session-dotfiles");
195 assert!(report
196 .notes
197 .iter()
198 .any(|n| n.contains("ordered by total tokens")));
199 }
200
201 #[test]
202 fn span_is_derived_and_unlogged_duration_stays_unsupported() {
203 let report = report(&cheap_and_dear());
204 let acme = report
205 .json_rows
206 .iter()
207 .find(|row| row["session_id"] == "session-acme")
208 .unwrap();
209 assert_eq!(acme["span_ms"], 2 * 3_600_000);
210 assert!(acme["duration_ms"].is_null());
211
212 let rendered = report.table.render(Style::plain());
213 assert!(rendered.contains("2h00m"), "{rendered}");
214 assert!(rendered.contains(UNSUPPORTED), "{rendered}");
215 assert!(report
216 .notes
217 .iter()
218 .any(|n| n.contains("blank means unrecorded, not zero")));
219 assert!(report.notes.iter().any(|n| n.contains("claude-code")));
220 }
221
222 #[test]
223 fn duration_is_shown_when_a_source_does_log_it() {
224 let mut timed = used("a", ms(2026, 8, 4, 8), "acme", "m", 10, 1);
225 timed.duration_ms = Some(8_140);
226 let report = report(&[timed]);
227 assert_eq!(report.json_rows[0]["duration_ms"], 8_140);
228 assert!(report.table.render(Style::plain()).contains("8s"));
229 }
230}