1use crate::{Event, EventType, Session, Stats};
2use chrono::Utc;
3
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct SessionAggregate {
10 pub session_count: u64,
11 pub message_count: u64,
12 pub event_count: u64,
13 pub tool_call_count: u64,
14 pub task_count: u64,
15 pub duration_seconds: u64,
16 pub total_input_tokens: u64,
17 pub total_output_tokens: u64,
18 pub user_message_count: u64,
19 pub files_changed: u64,
20 pub lines_added: u64,
21 pub lines_removed: u64,
22}
23
24impl SessionAggregate {
25 fn add_session_stats(&mut self, stats: &Stats) {
26 self.session_count += 1;
27 self.message_count += stats.message_count;
28 self.event_count += stats.event_count;
29 self.tool_call_count += stats.tool_call_count;
30 self.task_count += stats.task_count;
31 self.duration_seconds += stats.duration_seconds;
32 self.total_input_tokens += stats.total_input_tokens;
33 self.total_output_tokens += stats.total_output_tokens;
34 self.user_message_count += stats.user_message_count;
35 self.files_changed += stats.files_changed;
36 self.lines_added += stats.lines_added;
37 self.lines_removed += stats.lines_removed;
38 }
39}
40
41pub fn aggregate(sessions: &[Session]) -> SessionAggregate {
43 let mut agg = SessionAggregate::default();
44 for s in sessions {
45 agg.add_session_stats(&s.stats);
46 }
47 agg
48}
49
50pub fn aggregate_by_tool(sessions: &[Session]) -> Vec<(String, SessionAggregate)> {
52 aggregate_by(sessions, |s| s.agent.tool.clone())
53}
54
55pub fn aggregate_by_model(sessions: &[Session]) -> Vec<(String, SessionAggregate)> {
57 aggregate_by(sessions, |s| s.agent.model.clone())
58}
59
60fn aggregate_by(
62 sessions: &[Session],
63 key_fn: impl Fn(&Session) -> String,
64) -> Vec<(String, SessionAggregate)> {
65 let mut map = std::collections::HashMap::<String, SessionAggregate>::new();
66 for s in sessions {
67 map.entry(key_fn(s))
68 .or_default()
69 .add_session_stats(&s.stats);
70 }
71 let mut result: Vec<_> = map.into_iter().collect();
72 result.sort_by(|a, b| b.1.session_count.cmp(&a.1.session_count));
73 result
74}
75
76pub fn filter_by_time_range<'a>(sessions: &'a [Session], range: &str) -> Vec<&'a Session> {
80 let cutoff = match range {
81 "24h" => Some(Utc::now() - chrono::Duration::days(1)),
82 "7d" => Some(Utc::now() - chrono::Duration::days(7)),
83 "30d" => Some(Utc::now() - chrono::Duration::days(30)),
84 _ => None,
85 };
86 match cutoff {
87 Some(c) => sessions
88 .iter()
89 .filter(|s| s.context.created_at >= c)
90 .collect(),
91 None => sessions.iter().collect(),
92 }
93}
94
95fn extract_tool_name(event: &Event) -> Option<String> {
97 match &event.event_type {
98 EventType::ToolCall { name } => Some(name.clone()),
99 EventType::FileRead { .. } => Some("FileRead".to_string()),
100 EventType::CodeSearch { .. } => Some("CodeSearch".to_string()),
101 EventType::FileSearch { .. } => Some("FileSearch".to_string()),
102 EventType::FileEdit { .. } => Some("FileEdit".to_string()),
103 EventType::FileCreate { .. } => Some("FileCreate".to_string()),
104 EventType::FileDelete { .. } => Some("FileDelete".to_string()),
105 EventType::ShellCommand { .. } => Some("ShellCommand".to_string()),
106 EventType::WebSearch { .. } => Some("WebSearch".to_string()),
107 EventType::WebFetch { .. } => Some("WebFetch".to_string()),
108 _ => None,
109 }
110}
111
112pub fn count_tool_calls(sessions: &[Session]) -> Vec<(String, u64)> {
114 let mut result: Vec<_> = sessions
115 .iter()
116 .flat_map(|s| &s.events)
117 .filter_map(extract_tool_name)
118 .fold(
119 std::collections::HashMap::<String, u64>::new(),
120 |mut m, n| {
121 *m.entry(n).or_default() += 1;
122 m
123 },
124 )
125 .into_iter()
126 .collect();
127 result.sort_by(|a, b| b.1.cmp(&a.1));
128 result
129}
130
131pub mod sql {
136 pub fn time_range_filter(range: &str) -> &'static str {
140 match range {
141 "24h" => " AND s.created_at >= datetime('now', '-1 day')",
142 "7d" => " AND s.created_at >= datetime('now', '-7 days')",
143 "30d" => " AND s.created_at >= datetime('now', '-30 days')",
144 _ => "",
145 }
146 }
147
148 pub fn totals_query(time_filter: &str) -> String {
150 format!(
151 "SELECT \
152 COUNT(*) as session_count, \
153 COALESCE(SUM(s.message_count), 0) as message_count, \
154 COALESCE(SUM(s.event_count), 0) as event_count, \
155 COALESCE(SUM(s.duration_seconds), 0) as duration_seconds, \
156 COALESCE(SUM(s.total_input_tokens), 0) as total_input_tokens, \
157 COALESCE(SUM(s.total_output_tokens), 0) as total_output_tokens \
158 FROM sessions s \
159 WHERE s.team_id = ?1{time_filter}"
160 )
161 }
162
163 pub fn by_user_query(time_filter: &str) -> String {
165 format!(
166 "SELECT \
167 s.user_id as user_id, \
168 COALESCE(u.nickname, 'unknown') as nickname, \
169 COUNT(*) as session_count, \
170 COALESCE(SUM(s.message_count), 0) as message_count, \
171 COALESCE(SUM(s.event_count), 0) as event_count, \
172 COALESCE(SUM(s.duration_seconds), 0) as duration_seconds, \
173 COALESCE(SUM(s.total_input_tokens), 0) as total_input_tokens, \
174 COALESCE(SUM(s.total_output_tokens), 0) as total_output_tokens \
175 FROM sessions s \
176 LEFT JOIN users u ON u.id = s.user_id \
177 WHERE s.team_id = ?1{time_filter} \
178 GROUP BY s.user_id \
179 ORDER BY session_count DESC"
180 )
181 }
182
183 pub fn by_tool_query(time_filter: &str) -> String {
185 format!(
186 "SELECT \
187 s.tool as tool, \
188 COUNT(*) as session_count, \
189 COALESCE(SUM(s.message_count), 0) as message_count, \
190 COALESCE(SUM(s.event_count), 0) as event_count, \
191 COALESCE(SUM(s.duration_seconds), 0) as duration_seconds, \
192 COALESCE(SUM(s.total_input_tokens), 0) as total_input_tokens, \
193 COALESCE(SUM(s.total_output_tokens), 0) as total_output_tokens \
194 FROM sessions s \
195 WHERE s.team_id = ?1{time_filter} \
196 GROUP BY s.tool \
197 ORDER BY session_count DESC"
198 )
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::testing;
206 use crate::{Content, Event, Session, Stats};
207 use chrono::{Duration, Utc};
208 use std::collections::HashMap;
209
210 fn make_session_with_stats(tool: &str, model: &str, stats: Stats) -> Session {
211 let mut s = Session::new("s1".to_string(), testing::agent_with(tool, model));
212 s.stats = stats;
213 s
214 }
215
216 fn sample_stats(msg: u64, events: u64, tools: u64, dur: u64) -> Stats {
217 Stats {
218 event_count: events,
219 message_count: msg,
220 tool_call_count: tools,
221 task_count: 1,
222 duration_seconds: dur,
223 total_input_tokens: 100,
224 total_output_tokens: 200,
225 ..Default::default()
226 }
227 }
228
229 #[test]
230 fn test_aggregate_empty() {
231 let agg = aggregate(&[]);
232 assert_eq!(agg, SessionAggregate::default());
233 }
234
235 #[test]
236 fn test_aggregate_single() {
237 let sessions = vec![make_session_with_stats(
238 "claude-code",
239 "opus",
240 sample_stats(5, 10, 3, 60),
241 )];
242 let agg = aggregate(&sessions);
243 assert_eq!(agg.session_count, 1);
244 assert_eq!(agg.message_count, 5);
245 assert_eq!(agg.event_count, 10);
246 assert_eq!(agg.tool_call_count, 3);
247 assert_eq!(agg.duration_seconds, 60);
248 assert_eq!(agg.total_input_tokens, 100);
249 assert_eq!(agg.total_output_tokens, 200);
250 }
251
252 #[test]
253 fn test_aggregate_multiple() {
254 let sessions = vec![
255 make_session_with_stats("claude-code", "opus", sample_stats(5, 10, 3, 60)),
256 make_session_with_stats("cursor", "gpt-4o", sample_stats(3, 6, 2, 30)),
257 ];
258 let agg = aggregate(&sessions);
259 assert_eq!(agg.session_count, 2);
260 assert_eq!(agg.message_count, 8);
261 assert_eq!(agg.event_count, 16);
262 assert_eq!(agg.tool_call_count, 5);
263 assert_eq!(agg.duration_seconds, 90);
264 assert_eq!(agg.total_input_tokens, 200);
265 assert_eq!(agg.total_output_tokens, 400);
266 }
267
268 #[test]
269 fn test_aggregate_by_tool() {
270 let sessions = vec![
271 make_session_with_stats("claude-code", "opus", sample_stats(5, 10, 3, 60)),
272 make_session_with_stats("claude-code", "sonnet", sample_stats(3, 6, 2, 30)),
273 make_session_with_stats("cursor", "gpt-4o", sample_stats(1, 2, 1, 10)),
274 ];
275 let by_tool = aggregate_by_tool(&sessions);
276 assert_eq!(by_tool.len(), 2);
277 assert_eq!(by_tool[0].0, "claude-code");
279 assert_eq!(by_tool[0].1.session_count, 2);
280 assert_eq!(by_tool[1].0, "cursor");
281 assert_eq!(by_tool[1].1.session_count, 1);
282 }
283
284 #[test]
285 fn test_aggregate_by_model() {
286 let sessions = vec![
287 make_session_with_stats("claude-code", "opus", sample_stats(5, 10, 3, 60)),
288 make_session_with_stats("cursor", "opus", sample_stats(3, 6, 2, 30)),
289 make_session_with_stats("cursor", "gpt-4o", sample_stats(1, 2, 1, 10)),
290 ];
291 let by_model = aggregate_by_model(&sessions);
292 assert_eq!(by_model.len(), 2);
293 assert_eq!(by_model[0].0, "opus");
294 assert_eq!(by_model[0].1.session_count, 2);
295 }
296
297 #[test]
298 fn test_filter_by_time_range_all() {
299 let sessions = vec![make_session_with_stats(
300 "cc",
301 "opus",
302 sample_stats(1, 1, 0, 10),
303 )];
304 let filtered = filter_by_time_range(&sessions, "all");
305 assert_eq!(filtered.len(), 1);
306 }
307
308 #[test]
309 fn test_filter_by_time_range_24h() {
310 let mut recent = make_session_with_stats("cc", "opus", sample_stats(1, 1, 0, 10));
311 recent.context.created_at = Utc::now();
312
313 let mut old = make_session_with_stats("cc", "opus", sample_stats(1, 1, 0, 10));
314 old.context.created_at = Utc::now() - Duration::days(2);
315
316 let sessions = vec![recent, old];
317 let filtered = filter_by_time_range(&sessions, "24h");
318 assert_eq!(filtered.len(), 1);
319 }
320
321 #[test]
322 fn test_count_tool_calls() {
323 let mut session = Session::new("s1".to_string(), testing::agent_with("cc", "opus"));
324 session.events.push(Event {
325 event_id: "e1".to_string(),
326 timestamp: Utc::now(),
327 event_type: EventType::ToolCall {
328 name: "Read".to_string(),
329 },
330 task_id: None,
331 content: Content::empty(),
332 duration_ms: None,
333 attributes: HashMap::new(),
334 });
335 session.events.push(Event {
336 event_id: "e2".to_string(),
337 timestamp: Utc::now(),
338 event_type: EventType::FileRead {
339 path: "/tmp/a.rs".to_string(),
340 },
341 task_id: None,
342 content: Content::empty(),
343 duration_ms: None,
344 attributes: HashMap::new(),
345 });
346 session.events.push(Event {
347 event_id: "e3".to_string(),
348 timestamp: Utc::now(),
349 event_type: EventType::UserMessage,
350 task_id: None,
351 content: Content::text("hello"),
352 duration_ms: None,
353 attributes: HashMap::new(),
354 });
355
356 let counts = count_tool_calls(&[session]);
357 assert_eq!(counts.len(), 2);
358 let names: Vec<&str> = counts.iter().map(|(n, _)| n.as_str()).collect();
360 assert!(names.contains(&"Read"));
361 assert!(names.contains(&"FileRead"));
362 }
363
364 #[test]
367 fn test_sql_time_range_filter() {
368 assert_eq!(
369 sql::time_range_filter("24h"),
370 " AND s.created_at >= datetime('now', '-1 day')"
371 );
372 assert_eq!(
373 sql::time_range_filter("7d"),
374 " AND s.created_at >= datetime('now', '-7 days')"
375 );
376 assert_eq!(
377 sql::time_range_filter("30d"),
378 " AND s.created_at >= datetime('now', '-30 days')"
379 );
380 assert_eq!(sql::time_range_filter("all"), "");
381 assert_eq!(sql::time_range_filter("unknown"), "");
382 }
383
384 #[test]
385 fn test_sql_totals_query_contains_expected_fragments() {
386 let q = sql::totals_query("");
387 assert!(q.contains("COUNT(*) as session_count"));
388 assert!(q.contains("SUM(s.message_count)"));
389 assert!(q.contains("SUM(s.total_input_tokens)"));
390 assert!(q.contains("WHERE s.team_id = ?1"));
391 }
392
393 #[test]
394 fn test_sql_totals_query_with_time_filter() {
395 let tf = sql::time_range_filter("24h");
396 let q = sql::totals_query(tf);
397 assert!(q.contains("datetime('now', '-1 day')"));
398 }
399
400 #[test]
401 fn test_sql_by_user_query() {
402 let q = sql::by_user_query("");
403 assert!(q.contains("LEFT JOIN users u"));
404 assert!(q.contains("GROUP BY s.user_id"));
405 assert!(q.contains("ORDER BY session_count DESC"));
406 }
407
408 #[test]
409 fn test_sql_by_tool_query() {
410 let q = sql::by_tool_query("");
411 assert!(q.contains("GROUP BY s.tool"));
412 assert!(q.contains("ORDER BY session_count DESC"));
413 }
414}