Skip to main content

warden/reports/
mod.rs

1//! The named reports and the rollup behind `warden query`.
2//!
3//! Every report in here is a pure consumer of [`crate::store::Scanner`] — none
4//! of them opens an event file itself. A report's whole job is
5//! to turn a window of events into a [`Report`]; whether that reaches a terminal
6//! or a harness is [`crate::output`]'s problem.
7//!
8//! Three honesty rules are enforced here rather than in each report:
9//!
10//! - A figure warden cannot derive is [`Cell::Unsupported`], never `0`.
11//! - An absent token count means "not applicable on this record", because usage
12//!   is logged once per request and repeated on no sibling. Summing treats it as
13//!   contributing nothing, and [`Notes`] says how many records carried usage.
14//! - Anything that would make a number differ from what a user sees elsewhere —
15//!   sidechain events, unpriced models, skipped lines — becomes a note.
16
17pub mod compare;
18pub mod files;
19pub mod models;
20pub mod projects;
21pub mod query;
22pub mod sessions;
23pub mod summary;
24pub mod tools;
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt;
28use std::io;
29
30use chrono::{TimeZone, Utc};
31use serde_json::{Map, Value};
32
33use crate::cli::TimeWindow;
34use crate::config::{Pricing, TokenCounts};
35use crate::output::{Cell, Report};
36use crate::store::{Event, ScanQuery, Scanner};
37
38/// The model name Claude Code writes for records it synthesized itself. It is
39/// not a model anyone is billed for, so it is counted in volume and excluded
40/// from cost (see [`Cost::add`]).
41pub const SYNTHETIC_MODEL: &str = "<synthetic>";
42
43/// The named reports, in their documented order.
44pub const NAMES: [&str; 7] = [
45    "summary", "projects", "models", "sessions", "tools", "compare", "files",
46];
47
48/// Everything a report is given besides the scanner.
49#[derive(Debug, Clone)]
50pub struct ReportCtx {
51    pub window: TimeWindow,
52    pub project: Option<String>,
53    /// Sidechain (subagent) events are real spend and are included by default.
54    pub include_sidechain: bool,
55    /// The price table as it is *now*. Cost is derived at read time from this
56    /// and the event's stored token counts, so editing `config.toml` re-prices
57    /// the existing store without a re-ingest.
58    pub pricing: Pricing,
59}
60
61impl ReportCtx {
62    pub fn new(window: TimeWindow, project: Option<String>, include_sidechain: bool) -> Self {
63        Self {
64            window,
65            project,
66            include_sidechain,
67            pricing: Pricing::default(),
68        }
69    }
70
71    /// Price this run from the user's config. Without it the price table is
72    /// empty and every cost falls back to what was stored at ingest.
73    pub fn with_pricing(mut self, pricing: Pricing) -> Self {
74        self.pricing = pricing;
75        self
76    }
77
78    /// The same context over a different window, for `compare`.
79    pub fn with_window(&self, window: TimeWindow) -> Self {
80        Self {
81            window,
82            ..self.clone()
83        }
84    }
85
86    fn scan_query(&self) -> ScanQuery {
87        ScanQuery::new(self.window).with_project(self.project.clone())
88    }
89}
90
91/// A report by name.
92pub type Builder = fn(&Scanner, &ReportCtx) -> Result<Report, ReportError>;
93
94/// Resolve a report name, listing the valid ones when it is not one.
95pub fn resolve(name: &str) -> Result<Builder, ReportError> {
96    match name {
97        "summary" => Ok(summary::build),
98        "projects" => Ok(projects::build),
99        "models" => Ok(models::build),
100        "sessions" => Ok(sessions::build),
101        "tools" => Ok(tools::build),
102        "compare" => Ok(compare::build),
103        "files" => Ok(files::build),
104        other => Err(ReportError::Unknown(other.to_string())),
105    }
106}
107
108/// Build a named report end to end.
109pub fn run(scanner: &Scanner, name: &str, ctx: &ReportCtx) -> Result<Report, ReportError> {
110    resolve(name)?(scanner, ctx)
111}
112
113#[derive(Debug)]
114pub enum ReportError {
115    /// No such report. Carries the name so the message can list the real ones.
116    Unknown(String),
117    /// The report is meaningless without a bounded period (`compare`).
118    NeedsWindow(&'static str),
119    /// `--group-by` named a dimension that does not exist.
120    UnknownDimension(String),
121    Io(io::Error),
122}
123
124impl fmt::Display for ReportError {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self {
127            ReportError::Unknown(name) => write!(
128                f,
129                "unknown report {:?}: expected one of {}",
130                name,
131                NAMES.join(", ")
132            ),
133            ReportError::NeedsWindow(name) => write!(
134                f,
135                "report {name} compares a period against the one before it, so it needs a bounded \
136                 period: pass --since (e.g. --since 7d)"
137            ),
138            ReportError::UnknownDimension(dim) => write!(
139                f,
140                "unknown --group-by dimension {:?}: expected one of {}",
141                dim,
142                query::DIMENSIONS.join(", ")
143            ),
144            ReportError::Io(err) => write!(f, "{err}"),
145        }
146    }
147}
148
149impl std::error::Error for ReportError {}
150
151impl From<io::Error> for ReportError {
152    fn from(err: io::Error) -> Self {
153        ReportError::Io(err)
154    }
155}
156
157/// A completed scan, with the observations every report turns into notes.
158pub struct Scanned {
159    pub events: Vec<Event>,
160    pub notes: Notes,
161}
162
163/// Read the window through the one shared scanner.
164pub fn scan(scanner: &Scanner, ctx: &ReportCtx) -> Result<Scanned, ReportError> {
165    let mut events = Vec::new();
166    let mut notes = Notes::new(ctx.include_sidechain, ctx.pricing.clone());
167    let stats = scanner.scan_with(&ctx.scan_query(), |event| {
168        if event.is_sidechain == Some(true) {
169            notes.sidechain_events += 1;
170            if !ctx.include_sidechain {
171                return;
172            }
173        }
174        notes.observe(&event);
175        events.push(event);
176    })?;
177    notes.lines_skipped = stats.lines_skipped;
178    Ok(Scanned { events, notes })
179}
180
181/// Running cost for one bucket.
182///
183/// `priced` and `unpriced` are counted separately so a bucket whose model has no
184/// configured rate renders as `–` instead of `$0.00`.
185#[derive(Debug, Clone, Copy, Default, PartialEq)]
186pub struct Cost {
187    pub total: f64,
188    /// Events that carried usage and a configured price.
189    pub priced: u64,
190    /// Events that carried usage but whose model has no configured price.
191    pub unpriced: u64,
192}
193
194impl Cost {
195    fn add(&mut self, event: &Event, pricing: &Pricing) {
196        self.add_share(event, pricing, 1.0);
197    }
198
199    /// Add `share` of this event's cost — the whole of it for a bucket that owns
200    /// the event, a fraction for `report files`, which splits a turn across the
201    /// files it touched. The priced/unpriced counters are per event either way,
202    /// so a partially-priced bucket is flagged the same however it was built.
203    fn add_share(&mut self, event: &Event, pricing: &Pricing, share: f64) {
204        if !event.has_usage() || event.model.as_deref() == Some(SYNTHETIC_MODEL) {
205            return;
206        }
207        match event_cost(event, pricing) {
208            Some(cost) => {
209                self.total += cost * share;
210                self.priced += 1;
211            }
212            None => self.unpriced += 1,
213        }
214    }
215
216    fn merge(&mut self, other: Cost) {
217        self.total += other.total;
218        self.priced += other.priced;
219        self.unpriced += other.unpriced;
220    }
221
222    /// True when part of this bucket's spend could not be priced. The total is
223    /// then a floor, not a total, and must not be printed as if it were one.
224    pub fn is_partial(&self) -> bool {
225        self.priced > 0 && self.unpriced > 0
226    }
227
228    /// `–` when nothing in this bucket could be priced, `~+` when only some of
229    /// it could, otherwise a plain estimate.
230    pub fn cell(&self) -> Cell {
231        if self.priced == 0 {
232            Cell::Unsupported
233        } else if self.is_partial() {
234            Cell::money_partial(self.total)
235        } else {
236            Cell::money_est(self.total)
237        }
238    }
239
240    /// `null` rather than `0` when nothing could be priced.
241    pub fn json(&self) -> Value {
242        if self.priced == 0 {
243            Value::Null
244        } else {
245            serde_json::json!(round_money(self.total))
246        }
247    }
248}
249
250/// What one event cost, priced from the current config.
251///
252/// The config wins; `cost_est` on the record is a cache, used only when the
253/// config has no rate for that model. That is what makes editing `config.toml`
254/// take effect without a re-ingest, while keeping the JSONL self-describing for
255/// `jq` users. `None` — never `0.0` — when neither can price it.
256pub fn event_cost(event: &Event, pricing: &Pricing) -> Option<f64> {
257    let model = event.model.as_deref()?;
258    pricing
259        .estimate_cost(
260            &event.provider,
261            model,
262            TokenCounts {
263                input: event.input_tok,
264                output: event.output_tok,
265                cache_read: event.cache_read_tok,
266                cache_write: event.cache_write_tok,
267            },
268        )
269        .or(event.cost_est)
270}
271
272/// Everything summed for one bucket of events.
273#[derive(Debug, Clone, Default)]
274pub struct Totals {
275    /// Events in the bucket, whether or not they carried usage.
276    pub events: u64,
277    /// Events that carried usage — i.e. billable requests.
278    pub requests: u64,
279    pub input: u64,
280    pub output: u64,
281    pub cache_read: u64,
282    pub cache_write: u64,
283    pub cost: Cost,
284    pub sessions: BTreeSet<String>,
285    pub first_ts: Option<i64>,
286    pub last_ts: Option<i64>,
287}
288
289impl Totals {
290    pub fn add(&mut self, event: &Event, pricing: &Pricing) {
291        self.events += 1;
292        if event.has_usage() {
293            self.requests += 1;
294        }
295        self.input += event.input_tok.unwrap_or(0);
296        self.output += event.output_tok.unwrap_or(0);
297        self.cache_read += event.cache_read_tok.unwrap_or(0);
298        self.cache_write += event.cache_write_tok.unwrap_or(0);
299        self.cost.add(event, pricing);
300        if let Some(session) = &event.session_id {
301            self.sessions.insert(session.clone());
302        }
303        self.first_ts = Some(self.first_ts.map_or(event.ts, |ts| ts.min(event.ts)));
304        self.last_ts = Some(self.last_ts.map_or(event.ts, |ts| ts.max(event.ts)));
305    }
306
307    pub fn merge(&mut self, other: &Totals) {
308        self.events += other.events;
309        self.requests += other.requests;
310        self.input += other.input;
311        self.output += other.output;
312        self.cache_read += other.cache_read;
313        self.cache_write += other.cache_write;
314        self.cost.merge(other.cost);
315        self.sessions.extend(other.sessions.iter().cloned());
316        self.first_ts = min_opt(self.first_ts, other.first_ts);
317        self.last_ts = max_opt(self.last_ts, other.last_ts);
318    }
319
320    pub fn total_tokens(&self) -> u64 {
321        self.input + self.output + self.cache_read + self.cache_write
322    }
323
324    /// The four columns every report ends with: in, out, cache read, cost.
325    pub fn tail_cells(&self) -> Vec<Cell> {
326        vec![
327            count(self.input),
328            count(self.output),
329            count(self.cache_read),
330            self.cost.cell(),
331        ]
332    }
333
334    /// The token fields, written into a JSON row.
335    pub fn write_json(&self, row: &mut Map<String, Value>) {
336        row.insert("requests".into(), serde_json::json!(self.requests));
337        row.insert("input_tok".into(), serde_json::json!(self.input));
338        row.insert("output_tok".into(), serde_json::json!(self.output));
339        row.insert("cache_read_tok".into(), serde_json::json!(self.cache_read));
340        row.insert(
341            "cache_write_tok".into(),
342            serde_json::json!(self.cache_write),
343        );
344        row.insert("cost_est".into(), self.cost.json());
345        // A harness must be able to tell a total from a floor without parsing
346        // the notes: `cost_est` covers `cost_priced_requests` of the
347        // `cost_priced_requests + cost_unpriced_requests` billable requests here.
348        row.insert(
349            "cost_partial".into(),
350            serde_json::json!(self.cost.is_partial()),
351        );
352        row.insert(
353            "cost_priced_requests".into(),
354            serde_json::json!(self.cost.priced),
355        );
356        row.insert(
357            "cost_unpriced_requests".into(),
358            serde_json::json!(self.cost.unpriced),
359        );
360    }
361}
362
363fn min_opt(a: Option<i64>, b: Option<i64>) -> Option<i64> {
364    match (a, b) {
365        (Some(a), Some(b)) => Some(a.min(b)),
366        (a, b) => a.or(b),
367    }
368}
369
370fn max_opt(a: Option<i64>, b: Option<i64>) -> Option<i64> {
371    match (a, b) {
372        (Some(a), Some(b)) => Some(a.max(b)),
373        (a, b) => a.or(b),
374    }
375}
376
377/// Bucket events by a key. `None` from `key` drops the event from the rollup.
378pub fn rollup<K, F>(events: &[Event], pricing: &Pricing, key: F) -> BTreeMap<K, Totals>
379where
380    K: Ord,
381    F: Fn(&Event) -> Option<K>,
382{
383    let mut buckets: BTreeMap<K, Totals> = BTreeMap::new();
384    for event in events {
385        if let Some(k) = key(event) {
386            buckets.entry(k).or_default().add(event, pricing);
387        }
388    }
389    buckets
390}
391
392/// The label for a bucket whose events recorded no working directory. Those
393/// events are still real spend, so they get a row rather than being dropped —
394/// under one spelling, because `projects` explains it in a note and `sessions`
395/// prints it.
396pub const NO_PROJECT: &str = "(no project)";
397
398/// Descending float compare. `Ordering::Equal` for a NaN — a weight that cannot
399/// be compared must not reorder the rows around it.
400pub fn desc(a: f64, b: f64) -> std::cmp::Ordering {
401    b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal)
402}
403
404/// Heaviest bucket first, ties broken by key so output is deterministic.
405pub fn by_weight_desc<K: Ord + Clone>(buckets: BTreeMap<K, Totals>) -> Vec<(K, Totals)> {
406    let mut rows: Vec<(K, Totals)> = buckets.into_iter().collect();
407    rows.sort_by(|a, b| {
408        b.1.total_tokens()
409            .cmp(&a.1.total_tokens())
410            .then_with(|| a.0.cmp(&b.0))
411    });
412    rows
413}
414
415/// A count cell, saturating rather than wrapping on an implausible total.
416pub fn count(n: u64) -> Cell {
417    Cell::Int(i64::try_from(n).unwrap_or(i64::MAX))
418}
419
420/// `2026-08-04` in UTC. The store is UTC throughout, so days are too.
421pub fn day_of(ts_ms: i64) -> Option<String> {
422    Utc.timestamp_millis_opt(ts_ms)
423        .single()
424        .map(|dt| dt.format("%Y-%m-%d").to_string())
425}
426
427/// `1h12m`, `4m`, `12s` — a wall-clock span, for `sessions`.
428pub fn format_span(ms: i64) -> String {
429    let secs = ms.max(0) / 1000;
430    let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
431    if h > 0 {
432        format!("{h}h{m:02}m")
433    } else if m > 0 {
434        format!("{m}m{s:02}s")
435    } else {
436        format!("{s}s")
437    }
438}
439
440/// Money is compared and diffed as a float but published at cent precision.
441pub fn round_money(amount: f64) -> f64 {
442    (amount * 100.0).round() / 100.0
443}
444
445/// Long ids are unreadable in a table; JSON keeps them whole.
446pub fn short_id(id: &str) -> String {
447    match id.char_indices().nth(8) {
448        Some((idx, _)) => format!("{}…", &id[..idx]),
449        None => id.to_string(),
450    }
451}
452
453/// The observations that become a report's `notes`.
454///
455/// Notes exist so a number a user cannot reconcile against what their agent
456/// showed them is explained rather than merely printed.
457#[derive(Debug, Clone)]
458pub struct Notes {
459    include_sidechain: bool,
460    pub sidechain_events: u64,
461    pub lines_skipped: u64,
462    pricing: Pricing,
463    unpriced_models: BTreeSet<String>,
464    synthetic_events: u64,
465    events: u64,
466    requests: u64,
467    extra: Vec<String>,
468}
469
470impl Notes {
471    fn new(include_sidechain: bool, pricing: Pricing) -> Self {
472        Self {
473            include_sidechain,
474            sidechain_events: 0,
475            lines_skipped: 0,
476            pricing,
477            unpriced_models: BTreeSet::new(),
478            synthetic_events: 0,
479            events: 0,
480            requests: 0,
481            extra: Vec::new(),
482        }
483    }
484
485    fn observe(&mut self, event: &Event) {
486        self.events += 1;
487        if !event.has_usage() {
488            return;
489        }
490        self.requests += 1;
491        if event.model.as_deref() == Some(SYNTHETIC_MODEL) {
492            self.synthetic_events += 1;
493        } else if event_cost(event, &self.pricing).is_none() {
494            self.unpriced_models.insert(
495                event
496                    .model
497                    .clone()
498                    .unwrap_or_else(|| "(unknown model)".into()),
499            );
500        }
501    }
502
503    /// Add a report-specific note, kept ahead of the shared ones.
504    pub fn push(&mut self, note: impl Into<String>) {
505        self.extra.push(note.into());
506    }
507
508    /// Merge another window's observations in (`compare` scans twice).
509    ///
510    /// Destructured rather than field-by-field on purpose: a new observation
511    /// added to `Notes` fails to compile here until it says how it merges,
512    /// instead of being silently dropped from every `compare`.
513    pub fn merge(&mut self, other: &Notes) {
514        let Notes {
515            // Both windows are scanned through one context, so these are the
516            // same on either side and the left-hand copy stands.
517            include_sidechain: _,
518            pricing: _,
519            // Report-specific notes belong to the report, which pushes them
520            // once after merging; a merged pair would print them twice.
521            extra: _,
522            sidechain_events,
523            lines_skipped,
524            unpriced_models,
525            synthetic_events,
526            events,
527            requests,
528        } = other;
529        self.sidechain_events += sidechain_events;
530        self.lines_skipped += lines_skipped;
531        self.unpriced_models.extend(unpriced_models.iter().cloned());
532        self.synthetic_events += synthetic_events;
533        self.events += events;
534        self.requests += requests;
535    }
536
537    /// The full note list, report-specific notes first.
538    pub fn finish(&self) -> Vec<String> {
539        let mut notes = self.extra.clone();
540
541        if self.events > 0 {
542            notes.push(format!(
543                "usage is recorded once per request: {} of {} events carry token counts, and the \
544                 rest contribute nothing rather than zero",
545                self.requests, self.events
546            ));
547        }
548
549        if self.sidechain_events > 0 {
550            notes.push(if self.include_sidechain {
551                format!(
552                    "includes {} sidechain (subagent) events — real spend, but counted in no \
553                     per-session figure your agent shows you, so these totals will read higher; \
554                     pass --no-sidechain to exclude them",
555                    self.sidechain_events
556                )
557            } else {
558                format!(
559                    "excludes {} sidechain (subagent) events (--no-sidechain); they are real \
560                     spend, so these totals understate it",
561                    self.sidechain_events
562                )
563            });
564        }
565
566        if !self.unpriced_models.is_empty() {
567            notes.push(format!(
568                "no configured price for {} — their spend is in no est. cost figure here: a row \
569                 with nothing else in it is blank rather than 0, and a row that also has priced \
570                 models is marked ~+ and understates its cost; add rates under \
571                 [pricing.<provider>] in config.toml and rerun (no re-ingest needed)",
572                self.unpriced_models
573                    .iter()
574                    .cloned()
575                    .collect::<Vec<_>>()
576                    .join(", ")
577            ));
578        }
579
580        if self.synthetic_events > 0 {
581            notes.push(format!(
582                "{} events report the model as {SYNTHETIC_MODEL}, a placeholder the agent writes \
583                 for records it generated itself; their tokens are counted and their cost is not, \
584                 because nobody is billed for them",
585                self.synthetic_events
586            ));
587        }
588
589        if self.lines_skipped > 0 {
590            notes.push(format!(
591                "skipped {} unreadable line(s) in the store (a torn final line, or schema drift)",
592                self.lines_skipped
593            ));
594        }
595
596        notes.push("cost figures are estimates".into());
597        notes
598    }
599}
600
601#[cfg(test)]
602pub(crate) mod testkit {
603    use crate::store::{Event, StorePaths, StoreWriter, ToolCall};
604    use chrono::{TimeZone, Utc};
605
606    pub fn ms(y: i32, mo: u32, d: u32, h: u32) -> i64 {
607        Utc.with_ymd_and_hms(y, mo, d, h, 0, 0)
608            .unwrap()
609            .timestamp_millis()
610    }
611
612    /// An assistant event carrying usage.
613    pub fn used(id: &str, ts: i64, project: &str, model: &str, input: u64, output: u64) -> Event {
614        let mut event = Event::new(id, ts, "claude-code", "anthropic", "assistant");
615        event.project = Some(project.into());
616        event.model = Some(model.into());
617        event.session_id = Some(format!("session-{project}"));
618        event.input_tok = Some(input);
619        event.output_tok = Some(output);
620        event.cache_read_tok = Some(input * 10);
621        event.cache_write_tok = Some(0);
622        event
623    }
624
625    pub fn priced(mut event: Event, cost: f64) -> Event {
626        event.cost_est = Some(cost);
627        event
628    }
629
630    pub fn with_tools(mut event: Event, targets: &[(&str, &str)]) -> Event {
631        event.tool_calls = targets
632            .iter()
633            .map(|(name, target)| ToolCall::new(*name, Some((*target).to_string())))
634            .collect();
635        event
636    }
637
638    pub fn store(events: &[Event]) -> (tempfile::TempDir, StorePaths) {
639        let dir = tempfile::tempdir().unwrap();
640        let paths = StorePaths::new(dir.path());
641        let mut writer = StoreWriter::open(paths.clone()).unwrap();
642        for event in events {
643            writer.append_event(event).unwrap();
644        }
645        (dir, paths)
646    }
647}
648
649#[cfg(test)]
650mod tests {
651    use super::testkit::*;
652    use super::*;
653
654    fn ctx() -> ReportCtx {
655        ReportCtx::new(TimeWindow::all(), None, true)
656    }
657
658    #[test]
659    fn unknown_report_lists_the_valid_names() {
660        let err = resolve("costs").unwrap_err();
661        let msg = err.to_string();
662        assert!(msg.contains("unknown report \"costs\""), "{msg}");
663        for name in NAMES {
664            assert!(msg.contains(name), "{msg} is missing {name}");
665        }
666    }
667
668    #[test]
669    fn every_documented_name_resolves() {
670        for name in NAMES {
671            assert!(resolve(name).is_ok(), "{name}");
672        }
673    }
674
675    #[test]
676    fn an_unpriced_bucket_is_unsupported_not_zero() {
677        let mut totals = Totals::default();
678        totals.add(
679            &used("a", 0, "p", "claude-opus-5", 10, 10),
680            &Pricing::default(),
681        );
682        assert_eq!(totals.cost.cell(), Cell::Unsupported);
683        assert_eq!(totals.cost.json(), Value::Null);
684
685        // Mixing an unpriced event with a priced one gives a figure that is
686        // real but incomplete: marked `~+`, never passed off as a total.
687        totals.add(
688            &priced(used("b", 0, "p", "claude-opus-5", 10, 10), 0.5),
689            &Pricing::default(),
690        );
691        assert!(totals.cost.is_partial());
692        assert_eq!(totals.cost.cell(), Cell::money_partial(0.5));
693        assert_eq!(totals.cost.json(), serde_json::json!(0.5));
694
695        let mut row = Map::new();
696        totals.write_json(&mut row);
697        assert_eq!(row["cost_partial"], serde_json::json!(true));
698        assert_eq!(row["cost_priced_requests"], serde_json::json!(1));
699        assert_eq!(row["cost_unpriced_requests"], serde_json::json!(1));
700
701        // Everything priced: a plain estimate again.
702        let mut whole = Totals::default();
703        whole.add(
704            &priced(used("c", 0, "p", "claude-opus-5", 10, 10), 0.5),
705            &Pricing::default(),
706        );
707        assert!(!whole.cost.is_partial());
708        assert_eq!(whole.cost.cell(), Cell::money_est(0.5));
709    }
710
711    /// Finding 2: cost comes from the config at *read* time, so a rate added
712    /// after ingest applies without re-ingesting, and the stored `cost_est` is
713    /// only a fallback.
714    #[test]
715    fn config_pricing_wins_over_the_cached_cost_est() {
716        let config: crate::config::Config = toml::from_str(
717            r#"
718[pricing.anthropic]
719"claude-opus-5" = { input = 15.0, output = 75.0, cache_read = 1.5 }
720"#,
721        )
722        .unwrap();
723        let pricing = config.pricing();
724
725        // 1M in, 1M out, 10M cache read (testkit sets cache_read = input * 10).
726        let event = used("a", 0, "p", "claude-opus-5", 1_000_000, 1_000_000);
727        let cost = event_cost(&event, &pricing).unwrap();
728        assert!((cost - (15.0 + 75.0 + 15.0)).abs() < 1e-9, "got {cost}");
729
730        // A stale ingest-time figure is ignored while the config can price it.
731        let stale = priced(event.clone(), 999.0);
732        assert_eq!(event_cost(&stale, &pricing), Some(cost));
733
734        // No configured rate: the cached figure, then nothing — never 0.0.
735        let empty = Pricing::default();
736        assert_eq!(event_cost(&stale, &empty), Some(999.0));
737        assert_eq!(event_cost(&event, &empty), None);
738    }
739
740    #[test]
741    fn a_model_the_config_cannot_price_stays_none_not_zero() {
742        let config: crate::config::Config = toml::from_str(
743            r#"
744[pricing.anthropic]
745"claude-sonnet-5" = { input = 3.0, output = 15.0, cache_read = 0.3 }
746"#,
747        )
748        .unwrap();
749        let mut totals = Totals::default();
750        totals.add(
751            &used("a", 0, "p", "claude-opus-5", 10, 10),
752            &config.pricing(),
753        );
754        assert_eq!(totals.cost.priced, 0);
755        assert_eq!(totals.cost.unpriced, 1);
756        assert_eq!(totals.cost.cell(), Cell::Unsupported);
757        assert_eq!(totals.cost.json(), Value::Null);
758    }
759
760    #[test]
761    fn records_without_usage_contribute_nothing_but_are_still_counted() {
762        let mut totals = Totals::default();
763        totals.add(&used("a", 0, "p", "m", 100, 20), &Pricing::default());
764        let mut sibling = Event::new("b", 0, "claude-code", "anthropic", "assistant");
765        sibling.session_id = Some("session-p".into());
766        totals.add(&sibling, &Pricing::default());
767
768        assert_eq!(totals.events, 2);
769        assert_eq!(totals.requests, 1, "usage is counted once per request");
770        assert_eq!(totals.input, 100);
771        assert_eq!(totals.sessions.len(), 1);
772    }
773
774    #[test]
775    fn synthetic_is_counted_in_tokens_and_excluded_from_cost() {
776        let mut totals = Totals::default();
777        totals.add(
778            &priced(used("a", 0, "p", SYNTHETIC_MODEL, 10, 5), 9.99),
779            &Pricing::default(),
780        );
781        assert_eq!(totals.input, 10);
782        assert_eq!(
783            totals.cost,
784            Cost::default(),
785            "no cost accrues to {SYNTHETIC_MODEL}"
786        );
787        assert_eq!(totals.cost.cell(), Cell::Unsupported);
788    }
789
790    #[test]
791    fn sidechain_events_are_included_by_default_and_always_noted() {
792        let mut sidechain = used("s", ms(2026, 8, 4, 9), "p", "m", 5, 5);
793        sidechain.is_sidechain = Some(true);
794        let (_dir, paths) = store(&[used("a", ms(2026, 8, 4, 8), "p", "m", 10, 10), sidechain]);
795        let scanner = Scanner::new(paths);
796
797        let scanned = scan(&scanner, &ctx()).unwrap();
798        assert_eq!(scanned.events.len(), 2);
799        assert!(scanned
800            .notes
801            .finish()
802            .iter()
803            .any(|n| n.contains("includes 1 sidechain")));
804
805        let excluded = scan(&scanner, &ReportCtx::new(TimeWindow::all(), None, false)).unwrap();
806        assert_eq!(excluded.events.len(), 1);
807        assert!(excluded
808            .notes
809            .finish()
810            .iter()
811            .any(|n| n.contains("excludes 1 sidechain")));
812    }
813
814    #[test]
815    fn unpriced_models_are_named_in_the_notes() {
816        let (_dir, paths) = store(&[used("a", ms(2026, 8, 4, 8), "p", "claude-opus-5", 10, 10)]);
817        let notes = scan(&Scanner::new(paths), &ctx()).unwrap().notes.finish();
818        assert!(
819            notes
820                .iter()
821                .any(|n| n.contains("no configured price for claude-opus-5")),
822            "{notes:?}"
823        );
824        assert!(notes.iter().any(|n| n == "cost figures are estimates"));
825    }
826
827    #[test]
828    fn spans_and_ids_are_readable() {
829        assert_eq!(format_span(0), "0s");
830        assert_eq!(format_span(45_000), "45s");
831        assert_eq!(format_span(4 * 60_000 + 5_000), "4m05s");
832        assert_eq!(format_span(72 * 60_000), "1h12m");
833        assert_eq!(short_id("0123456789abcdef"), "01234567…");
834        assert_eq!(short_id("short"), "short");
835    }
836
837    #[test]
838    fn days_are_utc() {
839        assert_eq!(day_of(ms(2026, 8, 4, 23)).unwrap(), "2026-08-04");
840    }
841}