1use time::{Date, OffsetDateTime, UtcOffset};
14
15use crate::usage_signal::{DailyInstance, NamedBucket, SessionRecord, TimeBucket};
16
17pub const SESSION_IDLE_GAP: f64 = 5.0 * 3600.0;
20pub const WIN_30D: f64 = 30.0 * 86400.0;
22
23#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub struct TurnMetrics {
27 pub total: u64,
28 pub cache_read: u64,
29 pub input: u64,
30 pub output: u64,
31 pub cache_creation: u64,
32 pub cost: f64,
33}
34
35impl TurnMetrics {
36 fn add(&mut self, m: &TurnMetrics) {
37 self.total += m.total;
38 self.cache_read += m.cache_read;
39 self.input += m.input;
40 self.output += m.output;
41 self.cache_creation += m.cache_creation;
42 self.cost += m.cost;
43 }
44}
45
46#[derive(Clone, Debug, Default)]
48pub struct FileEvents {
49 pub model: Option<String>,
50 pub cwd: Option<String>,
51 pub events: Vec<(f64, TurnMetrics)>,
53}
54
55#[derive(Clone, Debug)]
57pub struct Session {
58 pub tokens: u64,
59 pub cache_read: u64,
60 pub input: u64,
61 pub output: u64,
62 pub cache_creation: u64,
63 pub cost: f64,
64 pub last_ts: f64,
65 pub first_ts: f64,
66 pub model: Option<String>,
67 pub cwd: Option<String>,
68}
69
70pub fn project_name_from_cwd(cwd: Option<&str>) -> String {
78 match cwd {
79 None => "—".to_string(),
80 Some(c) if c.is_empty() => "—".to_string(),
81 Some(c) => repo_name_from_cwd(c).unwrap_or_else(|| basename_of(c)),
82 }
83}
84
85fn basename_of(c: &str) -> String {
86 let trimmed = c.trim_end_matches('/');
87 let base = trimmed.rsplit('/').next().unwrap_or("");
88 if base.is_empty() {
89 c.to_string()
90 } else {
91 base.to_string()
92 }
93}
94
95fn repo_name_from_cwd(cwd: &str) -> Option<String> {
100 let start = std::path::Path::new(cwd);
101 if !start.is_absolute() {
102 return None;
103 }
104 let mut dir = Some(start);
105 while let Some(d) = dir {
106 let dotgit = d.join(".git");
107 if dotgit.exists() {
108 if let Some(cfg) = git_config_path(&dotgit) {
109 if let Ok(text) = std::fs::read_to_string(&cfg) {
110 if let Some(name) = origin_repo_name(&text) {
111 return Some(name);
112 }
113 }
114 }
115 return d.file_name().and_then(|n| n.to_str()).map(str::to_string);
116 }
117 dir = d.parent();
118 }
119 None
120}
121
122fn git_config_path(dotgit: &std::path::Path) -> Option<std::path::PathBuf> {
126 if dotgit.is_dir() {
127 return Some(dotgit.join("config"));
128 }
129 let text = std::fs::read_to_string(dotgit).ok()?;
130 let gitdir = text
131 .lines()
132 .find_map(|l| l.strip_prefix("gitdir:").map(str::trim))?;
133 let gitdir_path = {
134 let p = std::path::Path::new(gitdir);
135 if p.is_absolute() {
136 p.to_path_buf()
137 } else {
138 dotgit.parent()?.join(p)
139 }
140 };
141 if let Ok(common) = std::fs::read_to_string(gitdir_path.join("commondir")) {
142 let common = common.trim();
143 let base = if std::path::Path::new(common).is_absolute() {
144 std::path::PathBuf::from(common)
145 } else {
146 gitdir_path.join(common)
147 };
148 return Some(base.join("config"));
149 }
150 Some(gitdir_path.join("config"))
151}
152
153fn origin_repo_name(config: &str) -> Option<String> {
155 let mut in_origin = false;
156 for line in config.lines() {
157 let t = line.trim();
158 if t.starts_with('[') {
159 in_origin = t == "[remote \"origin\"]";
160 continue;
161 }
162 if in_origin {
163 if let Some(rest) = t.strip_prefix("url") {
164 if let Some(eq) = rest.trim_start().strip_prefix('=') {
165 return repo_name_from_url(eq.trim());
166 }
167 }
168 }
169 }
170 None
171}
172
173fn repo_name_from_url(url: &str) -> Option<String> {
176 let url = url.trim().trim_end_matches('/');
177 let seg = url.rsplit(|c| c == '/' || c == ':').next()?;
178 let seg = seg.strip_suffix(".git").unwrap_or(seg);
179 if seg.is_empty() {
180 None
181 } else {
182 Some(seg.to_string())
183 }
184}
185
186fn session_base_id(path: &str) -> String {
189 let base = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path);
190 match base.rsplit_once('.') {
191 Some((stem, _ext)) if !stem.is_empty() => stem.to_string(),
192 _ => base.to_string(),
193 }
194}
195
196fn round6(x: f64) -> f64 {
199 (x * 1e6).round_ties_even() / 1e6
200}
201
202fn round1(x: f64) -> f64 {
203 (x * 10.0).round_ties_even() / 10.0
204}
205
206pub fn iso_utc(ts: f64) -> String {
210 let whole = ts.floor() as i64;
211 let micros = ((ts - ts.floor()) * 1_000_000.0).round() as i64;
212 let (whole, micros) = if micros >= 1_000_000 {
213 (whole + 1, 0)
214 } else {
215 (whole, micros)
216 };
217 let dt = OffsetDateTime::from_unix_timestamp(whole)
218 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
219 .to_offset(UtcOffset::UTC);
220 let base = format!(
221 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
222 dt.year(),
223 u8::from(dt.month()),
224 dt.day(),
225 dt.hour(),
226 dt.minute(),
227 dt.second(),
228 );
229 if micros == 0 {
230 format!("{base}Z")
231 } else {
232 format!("{base}.{micros:06}Z")
233 }
234}
235
236pub fn parse_iso(value: Option<&str>) -> Option<f64> {
240 let s = value?;
241 if s.is_empty() {
242 return None;
243 }
244 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
245 .ok()
246 .map(|dt| dt.unix_timestamp_nanos() as f64 / 1e9)
247}
248
249fn local_dt(ts: f64, offset: UtcOffset) -> OffsetDateTime {
251 OffsetDateTime::from_unix_timestamp(ts.floor() as i64)
252 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
253 .to_offset(offset)
254}
255
256fn day_key(dt: OffsetDateTime) -> String {
257 format!("{:04}-{:02}-{:02}", dt.year(), u8::from(dt.month()), dt.day())
258}
259
260fn week_key(dt: OffsetDateTime) -> String {
261 let (iso_year, week, _) = dt.to_iso_week_date();
262 format!("{iso_year}-W{week:02}")
263}
264
265fn month_key(dt: OffsetDateTime) -> String {
266 format!("{:04}-{:02}", dt.year(), u8::from(dt.month()))
267}
268
269#[derive(Default)]
272struct OrderedBuckets {
273 order: Vec<String>,
274 idx: std::collections::HashMap<String, usize>,
275 buckets: Vec<Bucket>,
276}
277
278#[derive(Clone, Default)]
279struct Bucket {
280 tokens: u64,
281 sessions: u64,
282 cache_read: u64,
283 input: u64,
284 output: u64,
285 cache_creation: u64,
286 cost: f64,
287}
288
289impl Bucket {
290 fn accumulate(&mut self, s: &Session) {
291 self.tokens += s.tokens;
292 self.sessions += 1;
293 self.cache_read += s.cache_read;
294 self.input += s.input;
295 self.output += s.output;
296 self.cache_creation += s.cache_creation;
297 self.cost += s.cost;
298 }
299}
300
301impl OrderedBuckets {
302 fn entry(&mut self, key: &str) -> &mut Bucket {
303 if let Some(&i) = self.idx.get(key) {
304 return &mut self.buckets[i];
305 }
306 let i = self.buckets.len();
307 self.idx.insert(key.to_string(), i);
308 self.order.push(key.to_string());
309 self.buckets.push(Bucket::default());
310 &mut self.buckets[i]
311 }
312}
313
314#[derive(Clone, Debug, Default)]
316pub struct Buckets {
317 pub total_tokens_30d: u64,
318 pub total_sessions_30d: u64,
319 pub total_cost_30d: f64,
320 pub total_input_30d: u64,
321 pub total_output_30d: u64,
322 pub cost_today: f64,
323 pub max_session_minutes: f64,
324 pub by_day: Vec<TimeBucket>,
325 pub by_week: Vec<TimeBucket>,
326 pub by_month: Vec<TimeBucket>,
327 pub by_model: Vec<NamedBucket>,
328 pub by_project: Vec<NamedBucket>,
329 pub by_day_project: Vec<DailyInstance>,
330}
331
332pub fn split_logical_sessions(
337 files: &std::collections::BTreeMap<String, FileEvents>,
338) -> (Vec<Session>, Vec<SessionRecord>) {
339 let mut sessions = Vec::new();
340 let mut recent = Vec::new();
341
342 for (path, fe) in files {
343 let mut events = fe.events.clone();
344 events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
345 if events.is_empty() {
346 continue;
347 }
348 let mut chunks: Vec<Vec<(f64, TurnMetrics)>> = Vec::new();
351 let mut cur: Vec<(f64, TurnMetrics)> = vec![events[0]];
352 let mut session_start = events[0].0;
353 for &nxt in &events[1..] {
354 if nxt.0 - session_start > SESSION_IDLE_GAP {
355 chunks.push(std::mem::take(&mut cur));
356 cur = vec![nxt];
357 session_start = nxt.0;
358 } else {
359 cur.push(nxt);
360 }
361 }
362 chunks.push(cur);
363
364 let base_id = session_base_id(path);
365 let multi = chunks.len() > 1;
366 for (i, chunk) in chunks.iter().enumerate() {
367 let first_ts = chunk[0].0;
368 let last_ts = chunk[chunk.len() - 1].0;
369 let mut agg = TurnMetrics::default();
370 for (_, m) in chunk {
371 agg.add(m);
372 }
373 sessions.push(Session {
374 tokens: agg.total,
375 cache_read: agg.cache_read,
376 input: agg.input,
377 output: agg.output,
378 cache_creation: agg.cache_creation,
379 cost: agg.cost,
380 last_ts,
381 first_ts,
382 model: fe.model.clone(),
383 cwd: fe.cwd.clone(),
384 });
385 recent.push(SessionRecord {
386 id: if multi {
387 format!("{base_id}#{}", i + 1)
388 } else {
389 base_id.clone()
390 },
391 started_at: iso_utc(first_ts),
392 ended_at: iso_utc(last_ts),
393 duration_minutes: round1((last_ts - first_ts) / 60.0),
394 tokens: agg.total,
395 cache_read: agg.cache_read,
396 input: agg.input,
397 output: agg.output,
398 cache_creation: agg.cache_creation,
399 cost: round6(agg.cost),
400 model: fe.model.clone().unwrap_or_else(|| "—".to_string()),
401 project: project_name_from_cwd(fe.cwd.as_deref()),
402 });
403 }
404 }
405 (sessions, recent)
406}
407
408#[allow(clippy::too_many_arguments)]
409fn time_bucket(date: String, b: &Bucket) -> TimeBucket {
410 TimeBucket {
411 date,
412 tokens: b.tokens,
413 sessions: b.sessions,
414 input: b.input,
415 output: b.output,
416 cache_creation: b.cache_creation,
417 cache_read: b.cache_read,
418 cost: round6(b.cost),
419 }
420}
421
422fn named_bucket(model: String, b: &Bucket) -> NamedBucket {
423 NamedBucket {
424 model,
425 tokens: b.tokens,
426 sessions: b.sessions,
427 input: b.input,
428 output: b.output,
429 cache_creation: b.cache_creation,
430 cache_read: b.cache_read,
431 cost: round6(b.cost),
432 }
433}
434
435fn take_by_tokens(buckets: &OrderedBuckets, n: usize) -> Vec<(String, Bucket)> {
437 let mut items: Vec<(String, Bucket)> = buckets
438 .order
439 .iter()
440 .enumerate()
441 .map(|(i, k)| (k.clone(), buckets.buckets[i].clone()))
442 .collect();
443 items.sort_by(|a, b| b.1.tokens.cmp(&a.1.tokens));
444 items.truncate(n);
445 items
446}
447
448fn take_by_key(buckets: &OrderedBuckets, n: usize) -> Vec<(String, Bucket)> {
450 let mut items: Vec<(String, Bucket)> = buckets
451 .order
452 .iter()
453 .enumerate()
454 .map(|(i, k)| (k.clone(), buckets.buckets[i].clone()))
455 .collect();
456 items.sort_by(|a, b| b.0.cmp(&a.0));
457 items.truncate(n);
458 items
459}
460
461pub fn bucket_aggregates(sessions: &[Session], now: f64, offset: UtcOffset) -> Buckets {
466 let mut by_day = OrderedBuckets::default();
467 let mut by_week = OrderedBuckets::default();
468 let mut by_month = OrderedBuckets::default();
469 let mut by_model = OrderedBuckets::default();
470 let mut by_project = OrderedBuckets::default();
471 let mut dp_order: Vec<(String, String)> = Vec::new();
473 let mut dp_idx: std::collections::HashMap<(String, String), usize> = Default::default();
474 let mut dp_buckets: Vec<Bucket> = Vec::new();
475 let mut dp_models: Vec<Vec<String>> = Vec::new();
476
477 let mut total30: u64 = 0;
478 let mut sessions30: u64 = 0;
479 let mut cost30: f64 = 0.0;
480 let mut input30: u64 = 0;
481 let mut output30: u64 = 0;
482 let cutoff30 = now - WIN_30D;
483 let today_key = day_key(local_dt(now, offset));
484
485 for s in sessions {
486 let ts = s.last_ts;
487 let dt = local_dt(ts, offset);
488 let day = day_key(dt);
489 let week = week_key(dt);
490 let month = month_key(dt);
491 let proj = project_name_from_cwd(s.cwd.as_deref());
492
493 by_day.entry(&day).accumulate(s);
494 by_week.entry(&week).accumulate(s);
495 by_month.entry(&month).accumulate(s);
496 if let Some(model) = &s.model {
497 if !model.is_empty() {
498 by_model.entry(model).accumulate(s);
499 }
500 }
501 by_project.entry(&proj).accumulate(s);
502
503 if now - ts <= 30.0 * 86400.0 {
505 let key = (day.clone(), proj.clone());
506 let i = match dp_idx.get(&key) {
507 Some(&i) => i,
508 None => {
509 let i = dp_buckets.len();
510 dp_idx.insert(key.clone(), i);
511 dp_order.push(key.clone());
512 dp_buckets.push(Bucket::default());
513 dp_models.push(Vec::new());
514 i
515 }
516 };
517 dp_buckets[i].accumulate(s);
518 if let Some(model) = &s.model {
519 if !model.is_empty() && !dp_models[i].contains(model) {
520 dp_models[i].push(model.clone());
521 }
522 }
523 }
524
525 if ts >= cutoff30 {
526 total30 += s.tokens;
527 sessions30 += 1;
528 cost30 += s.cost;
529 input30 += s.input;
530 output30 += s.output;
531 }
532 }
533
534 let today_local = local_dt(now, offset);
536 let today_date = today_local.date();
537 let mut padded_day = Vec::with_capacity(365);
538 for i in 0..365i64 {
539 let d: Date = today_date.saturating_sub(time::Duration::days(i));
540 let key = format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day());
541 match by_day.idx.get(&key) {
542 Some(&j) => padded_day.push(time_bucket(key.clone(), &by_day.buckets[j])),
543 None => padded_day.push(time_bucket(key.clone(), &Bucket::default())),
544 }
545 }
546
547 let by_week_out = take_by_key(&by_week, 52)
548 .into_iter()
549 .map(|(k, b)| time_bucket(k, &b))
550 .collect();
551 let by_month_out = take_by_key(&by_month, 24)
552 .into_iter()
553 .map(|(k, b)| time_bucket(k, &b))
554 .collect();
555 let by_model_out = take_by_tokens(&by_model, 20)
556 .into_iter()
557 .map(|(k, b)| named_bucket(k, &b))
558 .collect();
559 let by_project_out = take_by_tokens(&by_project, 20)
560 .into_iter()
561 .map(|(k, b)| named_bucket(k, &b))
562 .collect();
563
564 let mut instances: Vec<DailyInstance> = dp_order
566 .iter()
567 .enumerate()
568 .map(|(i, (day, proj))| {
569 let b = &dp_buckets[i];
570 let mut models = dp_models[i].clone();
571 models.sort();
572 DailyInstance {
573 date: day.clone(),
574 project: proj.clone(),
575 models,
576 tokens: b.tokens,
577 sessions: b.sessions,
578 input: b.input,
579 output: b.output,
580 cache_creation: b.cache_creation,
581 cache_read: b.cache_read,
582 cost: round6(b.cost),
583 }
584 })
585 .collect();
586 instances.sort_by(|a, b| {
589 b.date
590 .cmp(&a.date)
591 .then(b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal))
592 });
593 instances.truncate(200);
594
595 let mut max_session_minutes = 0.0f64;
597 for s in sessions {
598 let dur = (s.last_ts - s.first_ts) / 60.0;
599 if dur > max_session_minutes {
600 max_session_minutes = dur;
601 }
602 }
603
604 let cost_today = by_day
605 .idx
606 .get(&today_key)
607 .map(|&j| by_day.buckets[j].cost)
608 .unwrap_or(0.0);
609
610 Buckets {
611 total_tokens_30d: total30,
612 total_sessions_30d: sessions30,
613 total_cost_30d: round6(cost30),
614 total_input_30d: input30,
615 total_output_30d: output30,
616 cost_today: round6(cost_today),
617 max_session_minutes: round1(max_session_minutes),
618 by_day: padded_day,
619 by_week: by_week_out,
620 by_month: by_month_out,
621 by_model: by_model_out,
622 by_project: by_project_out,
623 by_day_project: instances,
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 #[test]
632 fn project_name_basename_rules() {
633 assert_eq!(project_name_from_cwd(None), "—");
634 assert_eq!(project_name_from_cwd(Some("")), "—");
635 assert_eq!(project_name_from_cwd(Some("/a/b/c")), "c");
636 assert_eq!(project_name_from_cwd(Some("/a/b/c/")), "c");
637 assert_eq!(project_name_from_cwd(Some("/")), "/");
638 }
639
640 #[test]
641 fn session_id_strips_extension() {
642 assert_eq!(session_base_id("/x/y/abc.jsonl"), "abc");
643 assert_eq!(session_base_id("/x/y/a.b.jsonl"), "a.b");
644 assert_eq!(session_base_id("noext"), "noext");
645 }
646
647 #[test]
648 fn idle_gap_splits_from_first_turn() {
649 let mut files = std::collections::BTreeMap::new();
650 let m = TurnMetrics { total: 10, input: 5, output: 5, ..Default::default() };
651 files.insert(
652 "/p/s.jsonl".to_string(),
653 FileEvents {
654 model: Some("claude-opus-4-8".into()),
655 cwd: Some("/home/proj".into()),
656 events: vec![(0.0, m), (3600.0, m), (6.0 * 3600.0, m)],
658 },
659 );
660 let (sessions, recent) = split_logical_sessions(&files);
661 assert_eq!(sessions.len(), 2);
662 assert_eq!(recent.len(), 2);
663 assert_eq!(recent[0].id, "s#1");
664 assert_eq!(sessions[0].tokens, 20); assert_eq!(sessions[1].tokens, 10);
666 }
667}