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