Skip to main content

forge_agent/env/
token_usage.rs

1//! Token/cost accounting for the `usage` subject.
2//!
3//! [`pricing`] is the LiteLLM-sourced per-model USD table used to turn
4//! JSONL token counts into a notional cost. This root holds the
5//! project-folding that maps a `~/.claude/projects/<slug>` directory
6//! name back to the repo it belongs to.
7
8use std::collections::{BTreeMap, HashSet};
9use std::io::BufRead;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use forge_primitives::token_usage::{UsageReport, UsageRow, WindowUsage};
14use serde::{Deserialize, Serialize};
15use time::format_description::well_known::Rfc3339;
16use time::{Date, Duration, Month, OffsetDateTime};
17use time_tz::{OffsetDateTimeExt, Tz};
18
19use self::pricing::PricingTable;
20
21pub mod pricing;
22
23/// Encoded form of `/.claude/worktrees/` in a project slug: Claude Code
24/// maps both `/` and `.` to `-`, so a worktree path folds to
25/// `<parent>--claude-worktrees-<name>`.
26const WORKTREE_MARKER: &str = "--claude-worktrees-";
27
28/// Fold a `~/.claude/projects/<slug>` directory name to the display
29/// project it belongs to, plus whether that label is final (see
30/// [`FileUsageSummary::project_resolved`]).
31///
32/// The slug is the project's absolute path with `/` and `.` both
33/// replaced by `-`, so it is lossy: `web-api` and `web/api` encode
34/// identically. Resolution therefore consults the filesystem (the
35/// user's `~/Projects`) rather than splitting on `-`. Worktrees and
36/// sub-paths fold to their repo, `/tmp` paths to `scratch`.
37fn fold_project(slug: &str) -> (String, bool) {
38    let projects_root = home_dir().map(|home| home.join("Projects"));
39    let prefix = projects_root.as_deref().map(encoded_projects_prefix).unwrap_or_default();
40    let root = projects_root.as_deref().unwrap_or_else(|| Path::new(""));
41    fold_project_in(slug, &prefix, root)
42}
43
44/// Testable core of [`fold_project`]. `projects_prefix` is the encoded
45/// `<home>/Projects/` string to strip; an empty prefix disables the
46/// repo-resolution rule (no known home). `projects_root` is the real
47/// directory the candidate repo names are stat'd against.
48fn fold_project_in(slug: &str, projects_prefix: &str, projects_root: &Path) -> (String, bool) {
49    // (a) A worktree folds to its parent repo; the part before the
50    // marker is itself the parent's slug, so recurse on it.
51    if let Some(idx) = slug.find(WORKTREE_MARKER) {
52        return fold_project_in(&slug[..idx], projects_prefix, projects_root);
53    }
54    // (b) A path under <home>/Projects/: resolve the repo name against
55    // the filesystem so a dashed name (`web-api`) isn't mis-split.
56    if !projects_prefix.is_empty()
57        && let Some(remainder) = slug.strip_prefix(projects_prefix)
58        && !remainder.is_empty()
59    {
60        return resolve_project_name(remainder, projects_root);
61    }
62    // (c) /tmp and /private/tmp collapse into one scratch bucket.
63    if slug.starts_with("-private-tmp") || slug.starts_with("-tmp") {
64        return ("scratch".to_owned(), true);
65    }
66    // (d) Anything else: the trailing path component. Nothing on disk
67    // is consulted, so the label cannot go stale.
68    (basename_fallback(slug), true)
69}
70
71/// Resolve the repo name from a slug remainder (the encoded
72/// `<name>/<subpath...>` after the `Projects/` prefix). Picks the
73/// longest leading run of `-`-joined tokens that is an existing
74/// directory under `projects_root`; when nothing resolves (the repo is
75/// not checked out) the whole run is the best-effort label.
76fn resolve_project_name(remainder: &str, projects_root: &Path) -> (String, bool) {
77    // Drop empty tokens: a `.`/`/` in the original path encodes as a
78    // dash, so a dotted or double-slashed segment leaves an empty token
79    // that would otherwise resolve to `projects_root` itself (empty
80    // candidate) or become an empty fallback label.
81    let tokens: Vec<&str> = remainder.split('-').filter(|token| !token.is_empty()).collect();
82    for run_len in (1..=tokens.len()).rev() {
83        let candidate = tokens[..run_len].join("-");
84        if projects_root.join(&candidate).is_dir() {
85            return (candidate, true);
86        }
87    }
88    // Nothing confirms where the repo name ends, so keep the whole run:
89    // a first-token guess truncates `auto-portal` and can merge it into
90    // an unrelated `auto` repo's row.
91    let whole = tokens.join("-");
92    (if whole.is_empty() { remainder.to_owned() } else { whole }, false)
93}
94
95/// The trailing `-`-separated component of a slug, used when no richer
96/// rule applies.
97fn basename_fallback(slug: &str) -> String {
98    slug.rsplit('-').find(|token| !token.is_empty()).unwrap_or(slug).to_owned()
99}
100
101/// The slug prefix for paths under `<projects_root>`: the root path with
102/// `/` and `.` mapped to `-`, plus the trailing separator's `-`.
103fn encoded_projects_prefix(projects_root: &Path) -> String {
104    format!("{}-", projects_root.to_string_lossy().replace(['/', '.'], "-"))
105}
106
107fn home_dir() -> Option<PathBuf> {
108    std::env::var_os("HOME").filter(|s| !s.is_empty()).map(PathBuf::from)
109}
110
111/// The five-way token split accumulated for one `(model, day)` bucket.
112/// Cache-write is kept split by TTL tier so pricing can apply the 1h /
113/// 5m rates independently.
114#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
115pub struct TokenCounts {
116    pub input: u64,
117    pub cache_write_1h: u64,
118    pub cache_write_5m: u64,
119    pub cache_read: u64,
120    pub output: u64,
121}
122
123impl TokenCounts {
124    fn add(&mut self, other: &TokenCounts) {
125        self.input = self.input.saturating_add(other.input);
126        self.cache_write_1h = self.cache_write_1h.saturating_add(other.cache_write_1h);
127        self.cache_write_5m = self.cache_write_5m.saturating_add(other.cache_write_5m);
128        self.cache_read = self.cache_read.saturating_add(other.cache_read);
129        self.output = self.output.saturating_add(other.output);
130    }
131}
132
133/// One session file's deduped usage, keyed `model -> day -> counts`.
134/// `mtime` + `size` drive the incremental cache: an unchanged file is
135/// reused rather than re-parsed. `folded_project` is the repo the file
136/// belongs to, folded from its `~/.claude/projects/<slug>` dir name.
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
138pub struct FileUsageSummary {
139    pub mtime: SystemTime,
140    pub size: u64,
141    pub folded_project: String,
142    /// Whether `folded_project` is final. False only for the guess made
143    /// when the repo is not checked out: that guess is re-derived on
144    /// cache reuse, since a session file's mtime never changes to force
145    /// a re-parse once the checkout comes back. Rows written before this
146    /// field existed default to false and so heal on the next scan.
147    #[serde(default)]
148    pub project_resolved: bool,
149    pub by_model_day: BTreeMap<String, BTreeMap<String, TokenCounts>>,
150}
151
152impl FileUsageSummary {
153    /// Re-derive `folded_project` when it was only a guess, folding the
154    /// slug dir of `path` (the session file this summary was cached
155    /// for) afresh. Costs one fold and never a re-parse, so reusing a
156    /// cached summary still picks up a repo checked out since.
157    pub fn refresh_unresolved_project(&mut self, path: &Path) {
158        if self.project_resolved {
159            return;
160        }
161        let Some(slug) = path.parent().and_then(Path::file_name) else {
162            return;
163        };
164        let (name, resolved) = fold_project(&slug.to_string_lossy());
165        self.folded_project = name;
166        self.project_resolved = resolved;
167    }
168}
169
170/// Every real session file under `projects_dir/<slug>/`. Syncthing
171/// conflict copies (`*.sync-conflict-*.jsonl`) are skipped: they are
172/// stale duplicates of a real session and would double-count usage that
173/// per-file message-id dedup can't catch across files.
174pub fn usage_files(projects_dir: &Path) -> Vec<PathBuf> {
175    let mut files = Vec::new();
176    let project_dirs = match std::fs::read_dir(projects_dir) {
177        Ok(dirs) => dirs,
178        Err(error) => {
179            tracing::warn!(
180                target: "forge_agent::env::token_usage",
181                %error,
182                path = %projects_dir.display(),
183                "reading the projects dir failed; the usage scan answers empty",
184            );
185            return files;
186        }
187    };
188    for project in project_dirs.flatten() {
189        if !project.file_type().is_ok_and(|t| t.is_dir()) {
190            continue;
191        }
192        let Ok(session_files) = std::fs::read_dir(project.path()) else {
193            continue;
194        };
195        for session in session_files.flatten() {
196            let path = session.path();
197            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
198                continue;
199            }
200            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
201            if name.contains(".sync-conflict") {
202                continue;
203            }
204            files.push(path);
205        }
206    }
207    files
208}
209
210/// Parse one session file into its per-file usage summary, bucketing
211/// each record by its calendar day in `tz`. `None` when the file can't
212/// be stat'd or opened; a malformed line is skipped.
213pub fn parse_file(path: &Path, tz: &Tz) -> Option<FileUsageSummary> {
214    let metadata = std::fs::metadata(path).ok()?;
215    let mtime = metadata.modified().ok()?;
216    let size = metadata.len();
217    let slug = path.parent()?.file_name()?.to_string_lossy().into_owned();
218    let (folded_project, project_resolved) = fold_project(&slug);
219
220    let file = std::fs::File::open(path).ok()?;
221    let reader = std::io::BufReader::new(file);
222    let mut by_model_day: BTreeMap<String, BTreeMap<String, TokenCounts>> = BTreeMap::new();
223    let mut seen: HashSet<String> = HashSet::new();
224    let mut read_error_logged = false;
225    let mut timestamp_error_logged = false;
226    for line in reader.lines() {
227        let line = match line {
228            Ok(line) => line,
229            // A single bad line (e.g. invalid UTF-8) must not truncate the
230            // rest of the file - that would cache an undercount. Skip it
231            // and warn once per file so the loss is greppable.
232            Err(error) => {
233                if !read_error_logged {
234                    tracing::warn!(
235                        target: "forge_agent::env::token_usage",
236                        %error,
237                        path = %path.display(),
238                        "skipping an unreadable line; this file's usage may undercount",
239                    );
240                    read_error_logged = true;
241                }
242                continue;
243            }
244        };
245        let Ok(record) = serde_json::from_str::<Record>(&line) else {
246            continue;
247        };
248        if record.kind.as_deref() != Some("assistant") {
249            continue;
250        }
251        let (Some(message), Some(timestamp)) = (record.message, record.timestamp) else {
252            continue;
253        };
254        let (Some(id), Some(model), Some(usage)) = (message.id, message.model, message.usage)
255        else {
256            continue;
257        };
258        // Resumed sessions re-log prior turns into the same file; keep
259        // the first occurrence of each message id.
260        if !seen.insert(id) {
261            continue;
262        }
263        let Some(day) = calendar_day(&timestamp, tz) else {
264            // A strict rfc3339 parse drops off-spec timestamps; warn once
265            // per file so a systemic format drift is greppable rather than
266            // the usage scan answering all-zero in silence.
267            if !timestamp_error_logged {
268                tracing::warn!(
269                    target: "forge_agent::env::token_usage",
270                    path = %path.display(),
271                    "skipping a record with an unparseable timestamp; this file's usage may undercount",
272                );
273                timestamp_error_logged = true;
274            }
275            continue;
276        };
277        by_model_day.entry(model).or_default().entry(day).or_default().add(&usage.into_counts());
278    }
279    Some(FileUsageSummary { mtime, size, folded_project, project_resolved, by_model_day })
280}
281
282/// The `YYYY-MM-DD` calendar day in `tz` for an rfc3339 timestamp, or
283/// `None` when it doesn't parse. DST-correct: the offset is resolved at
284/// the instant, so a late-evening UTC time lands on the next local day.
285fn calendar_day(timestamp: &str, tz: &Tz) -> Option<String> {
286    let date = OffsetDateTime::parse(timestamp, &Rfc3339).ok()?.to_timezone(tz).date();
287    Some(format!("{:04}-{:02}-{:02}", date.year(), u8::from(date.month()), date.day()))
288}
289
290/// Minimal view of a transcript record: only the fields usage
291/// accounting reads. Unknown fields (content, tool blocks, …) are
292/// ignored by serde.
293#[derive(Deserialize)]
294struct Record {
295    #[serde(rename = "type")]
296    kind: Option<String>,
297    timestamp: Option<String>,
298    message: Option<RecordMessage>,
299}
300
301#[derive(Deserialize)]
302struct RecordMessage {
303    id: Option<String>,
304    model: Option<String>,
305    usage: Option<RecordUsage>,
306}
307
308#[derive(Deserialize)]
309struct RecordUsage {
310    #[serde(default)]
311    input_tokens: u64,
312    #[serde(default)]
313    output_tokens: u64,
314    #[serde(default)]
315    cache_read_input_tokens: u64,
316    #[serde(default)]
317    cache_creation_input_tokens: u64,
318    #[serde(default)]
319    cache_creation: Option<CacheCreation>,
320}
321
322impl RecordUsage {
323    fn into_counts(self) -> TokenCounts {
324        let mut counts = TokenCounts {
325            input: self.input_tokens,
326            cache_read: self.cache_read_input_tokens,
327            output: self.output_tokens,
328            ..TokenCounts::default()
329        };
330        // Prefer the TTL split; fall back to the flat cache-creation
331        // total as the 5m tier when the record predates the split.
332        match self.cache_creation {
333            Some(split) => {
334                counts.cache_write_1h = split.ephemeral_1h_input_tokens;
335                counts.cache_write_5m = split.ephemeral_5m_input_tokens;
336            }
337            None => counts.cache_write_5m = self.cache_creation_input_tokens,
338        }
339        counts
340    }
341}
342
343#[derive(Deserialize)]
344struct CacheCreation {
345    #[serde(default)]
346    ephemeral_1h_input_tokens: u64,
347    #[serde(default)]
348    ephemeral_5m_input_tokens: u64,
349}
350
351/// Roll per-file summaries up into the four windows the `/usage`
352/// overlay drew. Windows are calendar periods relative to `now`, in
353/// the same local timezone the day buckets already use: today, the
354/// current week (from Monday), the current month (from the 1st), and
355/// all-time. Each window carries both groupings and a total row, priced
356/// via `pricing` (an unpriced model contributes 0 cost).
357pub fn roll_up(
358    summaries: &[FileUsageSummary],
359    pricing: &PricingTable,
360    now: OffsetDateTime,
361) -> UsageReport {
362    let today = now.date();
363    let week_start = today - Duration::days(i64::from(today.weekday().number_days_from_monday()));
364    let month_start = Date::from_calendar_date(today.year(), today.month(), 1).unwrap_or(today);
365
366    let mut day = WindowAcc::default();
367    let mut week = WindowAcc::default();
368    let mut month = WindowAcc::default();
369    let mut lifetime = WindowAcc::default();
370
371    for summary in summaries {
372        for (model, days) in &summary.by_model_day {
373            for (day_str, counts) in days {
374                let Some(date) = parse_date(day_str) else {
375                    continue;
376                };
377                lifetime.add(&summary.folded_project, model, counts);
378                if date >= month_start {
379                    month.add(&summary.folded_project, model, counts);
380                }
381                if date >= week_start {
382                    week.add(&summary.folded_project, model, counts);
383                }
384                if date == today {
385                    day.add(&summary.folded_project, model, counts);
386                }
387            }
388        }
389    }
390
391    UsageReport {
392        today: day.finish(pricing),
393        week: week.finish(pricing),
394        month: month.finish(pricing),
395        lifetime: lifetime.finish(pricing),
396        pricing_available: !pricing.is_empty(),
397    }
398}
399
400/// Per-window accumulator keyed `project -> model -> counts`. Keeping
401/// the model split under each project lets the by-project rows sum cost
402/// across a project's differently-priced models.
403#[derive(Default)]
404struct WindowAcc {
405    by_project_model: BTreeMap<String, BTreeMap<String, TokenCounts>>,
406}
407
408impl WindowAcc {
409    fn add(&mut self, project: &str, model: &str, counts: &TokenCounts) {
410        self.by_project_model
411            .entry(project.to_owned())
412            .or_default()
413            .entry(model.to_owned())
414            .or_default()
415            .add(counts);
416    }
417
418    fn finish(&self, pricing: &PricingTable) -> WindowUsage {
419        let mut model_totals: BTreeMap<String, TokenCounts> = BTreeMap::new();
420        for models in self.by_project_model.values() {
421            for (model, counts) in models {
422                model_totals.entry(model.clone()).or_default().add(counts);
423            }
424        }
425
426        let mut by_model: Vec<UsageRow> = model_totals
427            .iter()
428            .map(|(model, counts)| make_row(model.clone(), counts, cost_of(model, counts, pricing)))
429            .collect();
430        sort_by_cost_desc(&mut by_model);
431
432        let mut by_project: Vec<UsageRow> = self
433            .by_project_model
434            .iter()
435            .map(|(project, models)| {
436                let mut counts = TokenCounts::default();
437                let mut cost = 0.0;
438                for (model, model_counts) in models {
439                    counts.add(model_counts);
440                    cost += cost_of(model, model_counts, pricing);
441                }
442                make_row(project.clone(), &counts, cost)
443            })
444            .collect();
445        sort_by_cost_desc(&mut by_project);
446
447        let mut total_counts = TokenCounts::default();
448        let mut total_cost = 0.0;
449        for (model, counts) in &model_totals {
450            total_counts.add(counts);
451            total_cost += cost_of(model, counts, pricing);
452        }
453
454        WindowUsage {
455            by_model,
456            by_project,
457            total: make_row("TOTAL".to_owned(), &total_counts, total_cost),
458        }
459    }
460}
461
462fn make_row(label: String, counts: &TokenCounts, cost_usd: f64) -> UsageRow {
463    UsageRow {
464        label,
465        input: counts.input,
466        cache_write_1h: counts.cache_write_1h,
467        cache_write_5m: counts.cache_write_5m,
468        cache_read: counts.cache_read,
469        output: counts.output,
470        cost_usd,
471    }
472}
473
474/// Notional cost of `counts` at `model`'s price; 0 for an unpriced or
475/// `<synthetic>` model.
476#[expect(
477    clippy::cast_precision_loss,
478    reason = "token counts stay well under 2^53, so the f64 conversion is exact"
479)]
480fn cost_of(model: &str, counts: &TokenCounts, pricing: &PricingTable) -> f64 {
481    let Some(price) = pricing.price(model) else {
482        return 0.0;
483    };
484    counts.input as f64 * price.input
485        + counts.cache_write_1h as f64 * price.cache_write_1h
486        + counts.cache_write_5m as f64 * price.cache_write_5m
487        + counts.cache_read as f64 * price.cache_read
488        + counts.output as f64 * price.output
489}
490
491fn sort_by_cost_desc(rows: &mut [UsageRow]) {
492    rows.sort_by(|a, b| b.cost_usd.total_cmp(&a.cost_usd).then_with(|| a.label.cmp(&b.label)));
493}
494
495/// Parse a `YYYY-MM-DD` day key into a `Date`.
496fn parse_date(day: &str) -> Option<Date> {
497    let mut parts = day.split('-');
498    let year: i32 = parts.next()?.parse().ok()?;
499    let month: u8 = parts.next()?.parse().ok()?;
500    let day_of_month: u8 = parts.next()?.parse().ok()?;
501    Date::from_calendar_date(year, Month::try_from(month).ok()?, day_of_month).ok()
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use tempfile::TempDir;
508    use time_tz::timezones;
509
510    const PREFIX: &str = "-Users-developer-Projects-";
511
512    fn utc() -> &'static Tz {
513        timezones::db::UTC
514    }
515
516    #[test]
517    fn calendar_day_uses_the_injected_timezone() {
518        let plus_530 = timezones::get_by_name("Asia/Colombo").expect("+5:30 zone");
519        // 2026-07-19T20:00Z is still 07-19 in UTC but 07-20 at +5:30 (01:30 local).
520        assert_eq!(calendar_day("2026-07-19T20:00:00Z", utc()).as_deref(), Some("2026-07-19"));
521        assert_eq!(calendar_day("2026-07-19T20:00:00Z", plus_530).as_deref(), Some("2026-07-20"));
522        assert_eq!(calendar_day("not-a-timestamp", utc()), None);
523    }
524
525    fn projects_root_with(dirs: &[&str]) -> TempDir {
526        let td = tempfile::tempdir().expect("tempdir");
527        for dir in dirs {
528            std::fs::create_dir_all(td.path().join(dir)).expect("mkdir");
529        }
530        td
531    }
532
533    /// The folded label alone, for the cases where resolution status is
534    /// not the property under test.
535    fn label(slug: &str, projects_root: &Path) -> String {
536        fold_project_in(slug, PREFIX, projects_root).0
537    }
538
539    #[test]
540    fn worktree_slug_folds_to_parent_repo() {
541        let root = projects_root_with(&["airmail"]);
542        let slug = "-Users-developer-Projects-airmail--claude-worktrees-abuse-hardening";
543        assert_eq!(label(slug, root.path()), "airmail");
544    }
545
546    #[test]
547    fn sub_crate_slug_folds_to_repo_not_dash_split() {
548        let root = projects_root_with(&["forge"]);
549        let slug = "-Users-developer-Projects-forge-crates-forge-test-harness";
550        assert_eq!(label(slug, root.path()), "forge");
551    }
552
553    #[test]
554    fn dashed_project_name_is_not_split_on_internal_dash() {
555        let root = projects_root_with(&["web-api"]);
556        assert_eq!(label("-Users-developer-Projects-web-api", root.path()), "web-api");
557    }
558
559    #[test]
560    fn tmp_slugs_fold_to_scratch() {
561        let root = projects_root_with(&[]);
562        assert_eq!(label("-private-tmp-forge-refresh-0ed1d9d0", root.path()), "scratch");
563        assert_eq!(label("-tmp-scratchpad", root.path()), "scratch");
564    }
565
566    #[test]
567    fn tmp_worktree_slug_folds_to_scratch_via_parent() {
568        let root = projects_root_with(&[]);
569        let slug = "-private-tmp-claude-501--tmpoBGeeH--claude-worktrees-harness-spawn";
570        assert_eq!(label(slug, root.path()), "scratch");
571    }
572
573    #[test]
574    fn absent_repo_keeps_its_whole_dashed_name_and_stays_unresolved() {
575        // `forge` is checked out, `auto-portal` is not.
576        let root = projects_root_with(&["forge"]);
577        assert_eq!(
578            fold_project_in("-Users-developer-Projects-auto-portal", PREFIX, root.path()),
579            ("auto-portal".to_owned(), false),
580            "an absent repo keeps its whole dashed name and is not marked resolved",
581        );
582        assert_eq!(
583            fold_project_in("-Users-developer-Projects-forge", PREFIX, root.path()),
584            ("forge".to_owned(), true),
585            "a present repo still resolves against the filesystem",
586        );
587    }
588
589    #[test]
590    fn dotted_path_does_not_yield_an_empty_label() {
591        let root = projects_root_with(&["forge"]);
592        // `~/Projects/.hidden` (dir gone) encodes the `.` as a second
593        // dash; the empty candidate must not match projects_root itself.
594        assert_eq!(label("-Users-developer-Projects--hidden", root.path()), "hidden");
595        // `~/Projects/forge/.config` still folds to the repo.
596        assert_eq!(label("-Users-developer-Projects-forge--config", root.path()), "forge");
597    }
598
599    fn write_session(td: &TempDir, slug: &str, file: &str, lines: &[&str]) -> PathBuf {
600        let dir = td.path().join(slug);
601        std::fs::create_dir_all(&dir).expect("mkdir slug");
602        let path = dir.join(file);
603        std::fs::write(&path, lines.join("\n")).expect("write jsonl");
604        path
605    }
606
607    fn day(summary: &FileUsageSummary, model: &str, day: &str) -> TokenCounts {
608        summary.by_model_day.get(model).and_then(|days| days.get(day)).cloned().unwrap_or_default()
609    }
610
611    #[test]
612    fn duplicate_message_id_counted_once() {
613        let td = tempfile::tempdir().expect("tempdir");
614        let rec = |ts: &str| {
615            format!(
616                r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"msg_A","model":"claude-opus-4-8","usage":{{"input_tokens":10,"output_tokens":5}}}}}}"#
617            )
618        };
619        let path = write_session(
620            &td,
621            "-slug",
622            "s.jsonl",
623            &[&rec("2026-07-08T09:30:34.184Z"), &rec("2026-07-08T10:00:00.000Z")],
624        );
625        let summary = parse_file(&path, utc()).expect("parse");
626        let counts = day(&summary, "claude-opus-4-8", "2026-07-08");
627        assert_eq!(counts.input, 10, "the re-logged duplicate id is not added twice");
628        assert_eq!(counts.output, 5);
629    }
630
631    #[test]
632    fn sidechain_record_is_included() {
633        let td = tempfile::tempdir().expect("tempdir");
634        let line = r#"{"type":"assistant","timestamp":"2026-07-08T09:30:34.184Z","isSidechain":true,"message":{"id":"msg_S","model":"claude-opus-4-8","usage":{"input_tokens":7,"output_tokens":2}}}"#;
635        let path = write_session(&td, "-slug", "s.jsonl", &[line]);
636        let summary = parse_file(&path, utc()).expect("parse");
637        assert_eq!(day(&summary, "claude-opus-4-8", "2026-07-08").input, 7);
638    }
639
640    #[test]
641    fn day_bucket_derives_from_timestamp() {
642        let td = tempfile::tempdir().expect("tempdir");
643        let a = r#"{"type":"assistant","timestamp":"2026-07-08T23:59:00.000Z","message":{"id":"a","model":"m","usage":{"output_tokens":1}}}"#;
644        let b = r#"{"type":"assistant","timestamp":"2026-07-09T00:01:00.000Z","message":{"id":"b","model":"m","usage":{"output_tokens":3}}}"#;
645        let path = write_session(&td, "-slug", "s.jsonl", &[a, b]);
646        let summary = parse_file(&path, utc()).expect("parse");
647        assert_eq!(day(&summary, "m", "2026-07-08").output, 1);
648        assert_eq!(day(&summary, "m", "2026-07-09").output, 3);
649    }
650
651    #[test]
652    fn ephemeral_split_maps_and_flat_falls_back_to_5m() {
653        let td = tempfile::tempdir().expect("tempdir");
654        let split = r#"{"type":"assistant","timestamp":"2026-07-08T00:00:00Z","message":{"id":"a","model":"m","usage":{"cache_read_input_tokens":100,"cache_creation_input_tokens":23,"cache_creation":{"ephemeral_1h_input_tokens":20,"ephemeral_5m_input_tokens":3}}}}"#;
655        let flat = r#"{"type":"assistant","timestamp":"2026-07-09T00:00:00Z","message":{"id":"b","model":"m","usage":{"cache_creation_input_tokens":50}}}"#;
656        let path = write_session(&td, "-slug", "s.jsonl", &[split, flat]);
657        let summary = parse_file(&path, utc()).expect("parse");
658
659        let with_split = day(&summary, "m", "2026-07-08");
660        assert_eq!(with_split.cache_write_1h, 20);
661        assert_eq!(with_split.cache_write_5m, 3, "the split is used verbatim when present");
662        assert_eq!(with_split.cache_read, 100);
663
664        let flat_fallback = day(&summary, "m", "2026-07-09");
665        assert_eq!(flat_fallback.cache_write_1h, 0);
666        assert_eq!(flat_fallback.cache_write_5m, 50, "flat total falls back to the 5m tier");
667    }
668
669    #[test]
670    fn parse_file_skips_an_unreadable_line_without_truncating() {
671        let td = tempfile::tempdir().expect("tempdir");
672        let dir = td.path().join("-slug");
673        std::fs::create_dir_all(&dir).expect("mkdir");
674        let record = |id: &str, out: u8| {
675            format!(
676                r#"{{"type":"assistant","timestamp":"2026-07-08T00:00:00Z","message":{{"id":"{id}","model":"m","usage":{{"output_tokens":{out}}}}}}}"#
677            )
678            .into_bytes()
679        };
680        let mut bytes = record("a", 10);
681        bytes.push(b'\n');
682        bytes.extend_from_slice(&[0xff, 0xfe, 0xff]); // invalid UTF-8 line
683        bytes.push(b'\n');
684        bytes.extend_from_slice(&record("b", 5));
685        let path = dir.join("s.jsonl");
686        std::fs::write(&path, bytes).expect("write");
687
688        let summary = parse_file(&path, utc()).expect("parse");
689        // The unreadable middle line is skipped, not treated as EOF, so
690        // the record after it still counts.
691        assert_eq!(day(&summary, "m", "2026-07-08").output, 15);
692    }
693
694    #[test]
695    fn parse_file_skips_a_record_with_an_unparseable_timestamp() {
696        let td = tempfile::tempdir().expect("tempdir");
697        let dir = td.path().join("-slug");
698        std::fs::create_dir_all(&dir).expect("mkdir");
699        let rec = |id: &str, ts: &str, out: u64| {
700            format!(
701                r#"{{"type":"assistant","timestamp":"{ts}","message":{{"id":"{id}","model":"m","usage":{{"output_tokens":{out}}}}}}}"#
702            )
703        };
704        let path = dir.join("s.jsonl");
705        std::fs::write(
706            &path,
707            [
708                rec("a", "2026-07-08T00:00:00Z", 10),
709                rec("b", "not-a-timestamp", 99),
710                rec("c", "2026-07-08T01:00:00Z", 5),
711            ]
712            .join("\n"),
713        )
714        .expect("write");
715
716        let summary = parse_file(&path, utc()).expect("parse");
717        // The off-spec-timestamp record is dropped; its siblings still count.
718        assert_eq!(day(&summary, "m", "2026-07-08").output, 15);
719    }
720
721    #[test]
722    fn usage_files_skips_sync_conflict_copies() {
723        let td = tempfile::tempdir().expect("tempdir");
724        write_session(&td, "-slug", "real.jsonl", &["{}"]);
725        write_session(&td, "-slug", "real.sync-conflict-20260710-110136-MOGYFY5.jsonl", &["{}"]);
726        let files = usage_files(td.path());
727        assert_eq!(files.len(), 1, "the sync-conflict copy is excluded");
728        assert!(files[0].file_name().and_then(|n| n.to_str()).is_some_and(|n| n == "real.jsonl"));
729    }
730
731    fn counts_out(output: u64) -> TokenCounts {
732        TokenCounts { output, ..TokenCounts::default() }
733    }
734
735    fn summary_of(project: &str, entries: &[(&str, &str, TokenCounts)]) -> FileUsageSummary {
736        let mut by_model_day: BTreeMap<String, BTreeMap<String, TokenCounts>> = BTreeMap::new();
737        for (model, day, counts) in entries {
738            by_model_day
739                .entry((*model).to_owned())
740                .or_default()
741                .insert((*day).to_owned(), counts.clone());
742        }
743        FileUsageSummary {
744            mtime: SystemTime::UNIX_EPOCH,
745            size: 0,
746            folded_project: project.to_owned(),
747            project_resolved: true,
748            by_model_day,
749        }
750    }
751
752    /// A fixed `now` mid-week (Wednesday) so `today - 1` / `today - 2`
753    /// stay inside the current week regardless of the host clock.
754    fn wednesday() -> OffsetDateTime {
755        Date::from_calendar_date(2026, Month::July, 15).expect("valid date").midnight().assume_utc()
756    }
757
758    fn approx(actual: f64, expected: f64) {
759        assert!((actual - expected).abs() < 1e-9, "expected {expected}, got {actual}");
760    }
761
762    #[test]
763    fn roll_up_buckets_days_into_windows() {
764        let s = summary_of(
765            "forge",
766            &[
767                ("m", "2026-07-15", counts_out(1)), // today
768                ("m", "2026-07-14", counts_out(2)), // this week, not today
769                ("m", "2026-07-05", counts_out(4)), // this month, before this week
770                ("m", "2026-06-20", counts_out(8)), // last month
771            ],
772        );
773        let report = roll_up(&[s], &PricingTable::from_litellm_json("{}"), wednesday());
774        assert_eq!(report.today.total.output, 1);
775        assert_eq!(report.week.total.output, 3);
776        assert_eq!(report.month.total.output, 7);
777        assert_eq!(report.lifetime.total.output, 15);
778    }
779
780    #[test]
781    fn roll_up_flags_pricing_availability() {
782        let summary = summary_of("forge", &[("m", "2026-07-15", counts_out(1))]);
783        let empty = roll_up(
784            std::slice::from_ref(&summary),
785            &PricingTable::from_litellm_json("{}"),
786            wednesday(),
787        );
788        assert!(!empty.pricing_available, "an empty table means pricing is unavailable");
789        let priced = PricingTable::from_litellm_json(
790            r#"{"m":{"input_cost_per_token":0.001,"output_cost_per_token":0.002}}"#,
791        );
792        assert!(roll_up(&[summary], &priced, wednesday()).pricing_available);
793    }
794
795    #[test]
796    fn roll_up_prices_models_and_zeros_unpriced() {
797        let s = summary_of(
798            "forge",
799            &[
800                ("m", "2026-07-15", counts_out(1000)),
801                ("<synthetic>", "2026-07-15", counts_out(500)),
802            ],
803        );
804        let pricing = PricingTable::from_litellm_json(
805            r#"{"m":{"input_cost_per_token":0.001,"output_cost_per_token":0.002}}"#,
806        );
807        let report = roll_up(&[s], &pricing, wednesday());
808        let m = report.today.by_model.iter().find(|r| r.label == "m").expect("m row");
809        let syn =
810            report.today.by_model.iter().find(|r| r.label == "<synthetic>").expect("synthetic row");
811        approx(m.cost_usd, 1000.0 * 0.002);
812        approx(syn.cost_usd, 0.0);
813        approx(report.today.total.cost_usd, 1000.0 * 0.002);
814    }
815
816    #[test]
817    fn roll_up_project_cost_sums_across_models() {
818        let s = summary_of(
819            "forge",
820            &[("cheap", "2026-07-15", counts_out(100)), ("pricey", "2026-07-15", counts_out(100))],
821        );
822        let pricing = PricingTable::from_litellm_json(
823            r#"{"cheap":{"input_cost_per_token":0,"output_cost_per_token":0.001},
824                "pricey":{"input_cost_per_token":0,"output_cost_per_token":0.01}}"#,
825        );
826        let report = roll_up(&[s], &pricing, wednesday());
827        let forge = report.today.by_project.iter().find(|r| r.label == "forge").expect("forge row");
828        assert_eq!(forge.output, 200, "project tokens sum across its models");
829        approx(forge.cost_usd, 100.0 * 0.001 + 100.0 * 0.01);
830    }
831
832    #[test]
833    fn roll_up_sorts_rows_by_cost_desc() {
834        let s = summary_of(
835            "forge",
836            &[("lo", "2026-07-15", counts_out(10)), ("hi", "2026-07-15", counts_out(10))],
837        );
838        let pricing = PricingTable::from_litellm_json(
839            r#"{"lo":{"input_cost_per_token":0,"output_cost_per_token":0.001},
840                "hi":{"input_cost_per_token":0,"output_cost_per_token":0.05}}"#,
841        );
842        let report = roll_up(&[s], &pricing, wednesday());
843        assert_eq!(report.today.by_model.first().expect("a row").label, "hi", "priciest first");
844    }
845
846    #[test]
847    fn roll_up_separates_projects_and_orders_by_cost_desc() {
848        let cheap = summary_of("cheap-proj", &[("m", "2026-07-15", counts_out(100))]);
849        let pricey = summary_of("pricey-proj", &[("m", "2026-07-15", counts_out(1000))]);
850        let pricing = PricingTable::from_litellm_json(
851            r#"{"m":{"input_cost_per_token":0,"output_cost_per_token":0.01}}"#,
852        );
853        let report = roll_up(&[cheap, pricey], &pricing, wednesday());
854        let projects = &report.lifetime.by_project;
855        assert_eq!(projects.len(), 2, "two projects stay separate");
856        assert_eq!(projects[0].label, "pricey-proj", "sorted by cost descending");
857        assert_eq!(projects[1].label, "cheap-proj");
858        assert_eq!(projects.iter().find(|r| r.label == "cheap-proj").expect("cheap").output, 100);
859        assert_eq!(
860            projects.iter().find(|r| r.label == "pricey-proj").expect("pricey").output,
861            1000
862        );
863    }
864
865    #[test]
866    fn roll_up_window_edges_are_inclusive_and_keep_unpriced_tokens() {
867        // now = Wed 2026-07-15 -> week_start = Mon 07-13, month_start = 07-01.
868        let summary = summary_of(
869            "forge",
870            &[("m", "2026-07-13", counts_out(1)), ("m", "2026-07-01", counts_out(2))],
871        );
872        let report = roll_up(&[summary], &PricingTable::from_litellm_json("{}"), wednesday());
873        assert_eq!(report.week.total.output, 1, "a record on Monday's week_start is in the week");
874        assert_eq!(report.month.total.output, 3, "records on/after the 1st are in the month");
875        // Empty pricing: the row keeps its full tokens, only the cost blanks.
876        let row = &report.month.by_model[0];
877        assert_eq!(row.output, 3, "an unpriced row keeps its full tokens");
878        approx(row.cost_usd, 0.0);
879    }
880}