Skip to main content

context_bar_core/
aggregate.rs

1//! Deterministic transforms — slice 2 of folding `usage_signal.py` into Rust
2//! (ROADMAP E1). Ported 1:1 from `empty_metrics`/`_add_metrics`,
3//! `split_logical_sessions`, `_empty_bucket`/`_accumulate`, `bucket_aggregates`,
4//! and `project_name_from_cwd`. Pure given (events, NOW, UTC offset) — pinned by
5//! a golden fixture generated from the Python (`tests/aggregate_golden.rs`).
6//!
7//! Day/week/month bucketing uses a fixed UTC offset (seconds east of UTC). The
8//! Python uses the system local tz via `astimezone()`; for fixed-offset zones
9//! (e.g. Türkiye, permanent UTC+3) that is identical, and the golden pins it
10//! with `TZ=UTC` / offset 0. DST-aware per-timestamp offsets are a later
11//! refinement (would need a tz database); documented in COST_MODEL/ROADMAP.
12
13use time::{Date, OffsetDateTime, UtcOffset};
14
15use crate::usage_signal::{DailyInstance, NamedBucket, SessionRecord, TimeBucket};
16
17/// Idle gap (seconds) that splits one transcript file into logical sessions —
18/// matches Claude's 5h window, which resets 5h after the *first* turn.
19pub const SESSION_IDLE_GAP: f64 = 5.0 * 3600.0;
20/// 30-day rolling window in seconds.
21pub const WIN_30D: f64 = 30.0 * 86400.0;
22
23/// Per-turn token buckets + estimated cost. `total` is the stats token total
24/// (fresh_in + output) the parser computes; the four buckets feed the cost view.
25#[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/// One transcript file's per-turn events plus its resolved model/cwd.
47#[derive(Clone, Debug, Default)]
48pub struct FileEvents {
49    pub model: Option<String>,
50    pub cwd: Option<String>,
51    /// (epoch seconds, metrics) — sorted by ts inside [`split_logical_sessions`].
52    pub events: Vec<(f64, TurnMetrics)>,
53}
54
55/// A logical session chunk that feeds [`bucket_aggregates`].
56#[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
70/// `project_name_from_cwd`: the git repository name for the cwd when it lives in
71/// a repo (origin remote name, else the toplevel directory), otherwise the
72/// directory's own basename, or `—` when absent. Keeping it git-aware means the
73/// menubar shows the actual repo (e.g. `context-bar`) rather than whichever
74/// sub-directory the agent happened to start in (e.g. `backend`).
75///
76/// MUST stay in lock-step with the same function in `usage_signal.py`.
77pub 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
95/// For a cwd that is NOT inside a git repo, show `parent/leaf` (e.g.
96/// `hususi/backend`) instead of the bare leaf, so a plain directory reads as a
97/// location rather than masquerading as a project name. Falls back to the leaf
98/// when there is no meaningful parent.
99fn 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
114/// Walk up from `cwd` to the nearest `.git` and return the repository name:
115/// origin's URL basename when present, else the toplevel directory's basename.
116/// `None` when `cwd` is not absolute or not inside a git repo (callers fall back
117/// to the plain basename). Pure filesystem — no `git` subprocess required.
118fn 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
141/// The `config` path for a `.git` entry — a directory for normal repos, or a
142/// `gitdir:`-pointer file for linked worktrees / submodules (resolved via the
143/// `commondir` so the shared config is read).
144fn 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
172/// Repo name from the `[remote "origin"]` url in a git config, if any.
173fn 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
192/// Last path/scp segment of a remote URL with a trailing `.git` removed.
193/// Handles `git@host:owner/repo.git` and `https://host/owner/repo.git`.
194fn 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
205/// `basename(path)` with the final extension removed (mirrors
206/// `os.path.basename(path).rsplit(".", 1)[0]`).
207fn 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
215// Python's round() is banker's rounding (ties to even); mirror it so the
216// rounded cost/duration values are byte-identical.
217fn 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
225/// Format an epoch-seconds value as a UTC ISO8601 string ending in `Z`.
226/// Subsecond is emitted as 6 digits only when nonzero (mirrors Python's
227/// `datetime.fromtimestamp(ts, tz=utc).isoformat().replace("+00:00","Z")`).
228pub 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
255/// Parse an ISO-8601 / RFC-3339 timestamp to epoch seconds (mirrors Python
256/// `datetime.fromisoformat(...).timestamp()` for the `Z`/offset forms used in
257/// transcripts). Returns `None` on empty/unparseable input.
258pub 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
268/// Local civil date/labels for an epoch second at a fixed UTC offset.
269fn 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/// Insertion-ordered string-keyed accumulator, so a later stable sort
289/// reproduces Python's dict-insertion-order tie-breaking exactly.
290#[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/// Result of [`bucket_aggregates`] — mirrors the snapshot's aggregate fields.
334#[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
351/// Split each file's events into logical sessions on the 5h idle gap, returning
352/// `(sessions, recent)` — sessions feed [`bucket_aggregates`], recent feeds
353/// `recent_sessions`. `files` is iterated in its existing order (use a
354/// `BTreeMap` for determinism). Mirrors `split_logical_sessions`.
355pub 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        // Split into chunks: a new chunk starts when an event is more than
368        // SESSION_IDLE_GAP after the current chunk's FIRST turn.
369        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
454/// Stable top-N by tokens descending (ties keep insertion order).
455fn 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
467/// Stable top-N by key string descending (for by_week / by_month).
468fn 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
480/// Roll sessions into day/week/month/model/project buckets + the day×project
481/// cross-tab + 30d totals. `now` is epoch seconds; `offset` is the fixed local
482/// UTC offset. Mirrors `bucket_aggregates` (days=365, weeks=52, months=24,
483/// instance_days=30, instance_rows=200).
484pub 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    // (day, project) -> (bucket, ordered models). Keep insertion order.
491    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        // Per (day × project) cross-tab, scoped to the recent 30-day window.
523        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    // by_day: pad every calendar day in the 365-day window, newest first.
554    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    // by_day_project: newest day first, within a day by cost desc; cap 200.
584    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    // Python: sort(key=(date, cost), reverse=True) — a single stable sort with a
606    // composite key, descending on both.
607    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    // Longest single session across all history (minutes).
615    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        // Not a git repo → parent/leaf (so a plain dir reads as a location).
655        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                // t=0, t=1h (same session, <5h from start), t=6h (new session).
677                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); // first two turns
685        assert_eq!(sessions[1].tokens, 10);
686    }
687}