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