Skip to main content

costroid_core/
lib.rs

1//! Costroid data pipeline and aggregation interfaces.
2
3use std::collections::BTreeMap;
4
5use chrono::{DateTime, Datelike, Duration, Local, LocalResult, NaiveDate, TimeZone, Utc};
6use costroid_focus::{
7    to_csv_string, to_json_string, FocusAccessPath, FocusError, FocusRecord, TokenType,
8    UnpricedUsage, DEFAULT_BILLING_CURRENCY, PRICING_CATEGORY_STANDARD,
9    PRICING_STATUS_MISSING_PRICE, PRICING_UNIT_TOKENS,
10};
11use costroid_providers::{
12    default_providers, read_cursor_config, AccessPath, CursorConfig, HostEnv, LimitKind,
13    LimitWindow, Provider, ProviderId, UsageEvent,
14};
15use rust_decimal::prelude::ToPrimitive;
16use rust_decimal::Decimal;
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20mod bench;
21pub use bench::{
22    bench_view, BenchDisclaimer, BenchFrontier, BenchView, FrontierPoint, FrontierStanding,
23    OverlayAppearance, OverlayModel, RepricingDelta, RepricingStatus,
24};
25
26const PRICING_STATUS_PRICED: &str = "priced";
27const PRICING_STATUS_UNKNOWN_MODEL: &str = "unknown_model";
28const PRICING_SCHEMA_VERSION: &str = "1";
29const PRICING_UNIT_1M_TOKENS: &str = "1M_tokens";
30const UNKNOWN_GROUP_VALUE: &str = "unknown";
31const TOTAL_GROUP_VALUE: &str = "total";
32
33pub fn bundled_pricing_json() -> &'static str {
34    // Bundled inside this crate (not the workspace root) so `cargo package`
35    // includes it and the crate publishes standalone to crates.io.
36    include_str!("../pricing/pricing.v1.json")
37}
38
39pub fn bundled_pricing_value() -> Result<serde_json::Value, CoreError> {
40    serde_json::from_str(bundled_pricing_json()).map_err(CoreError::from)
41}
42
43pub fn collect_local_snapshot(env: &HostEnv) -> Result<EngineSnapshot, CoreError> {
44    collect_snapshot_from_providers(env, default_providers(), Utc::now())
45}
46
47/// Compatibility wrapper for the Milestone 2 API.
48pub fn local_snapshot(env: &HostEnv) -> Snapshot {
49    match collect_local_snapshot(env) {
50        Ok(snapshot) => snapshot,
51        Err(_) => EngineSnapshot::empty(Utc::now()),
52    }
53}
54
55pub fn focus_records_from_usage(events: &[UsageEvent]) -> Result<Vec<FocusRecord>, CoreError> {
56    let pricing = PricingCatalog::bundled()?;
57    let mut records = Vec::new();
58    for event in events {
59        push_meter_records(event, &pricing, &mut records)?;
60    }
61    Ok(records)
62}
63
64pub fn focus_records_from_local_logs(env: &HostEnv) -> Result<Vec<FocusRecord>, CoreError> {
65    Ok(collect_local_snapshot(env)?.focus_rows)
66}
67
68pub fn export_focus_json(rows: Vec<FocusRecord>) -> Result<String, CoreError> {
69    to_json_string(rows).map_err(CoreError::from)
70}
71
72pub fn export_focus_csv(rows: &[FocusRecord]) -> Result<String, CoreError> {
73    to_csv_string(rows).map_err(CoreError::from)
74}
75
76pub fn now_summary(snapshot: &EngineSnapshot, options: NowOptions) -> NowSummary {
77    let cost_period = period_range_for(options.cost_period, snapshot.generated_at);
78    let current_costs = summarize_rows(
79        snapshot
80            .focus_rows
81            .iter()
82            .filter(|row| cost_period.contains(row.charge_period_start)),
83        options.group_by,
84    );
85    let limits = snapshot
86        .limit_windows
87        .iter()
88        .map(|limit| limit_summary(limit, snapshot.generated_at))
89        .collect();
90
91    NowSummary {
92        generated_at: snapshot.generated_at,
93        cost_period,
94        group_by: options.group_by,
95        limits,
96        current_costs,
97        providers: snapshot.providers.clone(),
98    }
99}
100
101pub fn trends_summary(snapshot: &EngineSnapshot, options: TrendsOptions) -> TrendsSummary {
102    let mut buckets = BTreeMap::<(PeriodRange, CostLane, GroupKey), AggregateTotals>::new();
103
104    for row in &snapshot.focus_rows {
105        let range = period_range_for(options.period, row.charge_period_start);
106        let lane = CostLane::from_access_path(&row.x_access_path);
107        let group = group_key(row, options.group_by);
108        buckets
109            .entry((range, lane, group))
110            .or_default()
111            .add_row(row);
112    }
113
114    let buckets = buckets
115        .into_iter()
116        .map(|((period, lane, group), totals)| TrendBucket {
117            period,
118            group,
119            lane,
120            totals,
121        })
122        .collect();
123
124    TrendsSummary {
125        generated_at: snapshot.generated_at,
126        period: options.period,
127        group_by: options.group_by,
128        buckets,
129        totals: summarize_rows(snapshot.focus_rows.iter(), options.group_by),
130        providers: snapshot.providers.clone(),
131    }
132}
133
134pub fn period_range_for(period: Period, anchor: DateTime<Utc>) -> PeriodRange {
135    let local_anchor = anchor.with_timezone(&Local);
136    let local_start = start_of_period_local(period, local_anchor);
137    let local_end = add_period_local(period, local_start);
138
139    PeriodRange {
140        start: local_start.with_timezone(&Utc),
141        end: local_end.with_timezone(&Utc),
142    }
143}
144
145fn collect_snapshot_from_providers(
146    env: &HostEnv,
147    providers: Vec<Box<dyn Provider>>,
148    generated_at: DateTime<Utc>,
149) -> Result<EngineSnapshot, CoreError> {
150    let mut snapshot = EngineSnapshot::empty(generated_at);
151
152    for provider in providers {
153        let provider_id = provider.id();
154        let location = match provider.discover(env) {
155            Ok(Some(location)) => location,
156            Ok(None) => {
157                snapshot.providers.push(ProviderStatus {
158                    provider: provider_id,
159                    status: ProviderStatusKind::Missing,
160                    files: 0,
161                    usage_events: 0,
162                    focus_rows: 0,
163                    limit_windows: 0,
164                    message: Some("no local data found".to_string()),
165                });
166                continue;
167            }
168            Err(err) => {
169                snapshot.providers.push(ProviderStatus {
170                    provider: provider_id,
171                    status: ProviderStatusKind::Error,
172                    files: 0,
173                    usage_events: 0,
174                    focus_rows: 0,
175                    limit_windows: 0,
176                    message: Some(err.to_string()),
177                });
178                continue;
179            }
180        };
181
182        let files = location.files.len();
183        let mut messages = Vec::new();
184        let mut usage_events = Vec::new();
185        let mut limit_windows = Vec::new();
186        let mut usage_ok = true;
187        let mut limits_ok = true;
188
189        match provider.parse_usage(&location) {
190            Ok(events) => usage_events = events,
191            Err(err) => {
192                usage_ok = false;
193                messages.push(err.to_string());
194            }
195        }
196
197        match provider.parse_limits(&location) {
198            Ok(limits) => limit_windows = limits,
199            Err(err) => {
200                limits_ok = false;
201                messages.push(err.to_string());
202            }
203        }
204
205        let focus_rows = focus_records_from_usage(&usage_events)?;
206        // Cursor is detect-and-defer: it keeps no token usage or quota on disk
207        // (both are live server RPCs; ARCHITECTURE.md §4), so it produces zero
208        // events/limits and is reported as `Detected` with the selected model,
209        // logged-in flag, and the Phase-2 deferral carried in `message`. Every other
210        // provider keeps the generic usage/limits-derived status.
211        let (status, message) = if provider_id == ProviderId::Cursor {
212            (
213                ProviderStatusKind::Detected,
214                Some(cursor_detected_message(&read_cursor_config(&location))),
215            )
216        } else {
217            let status = provider_status_kind(usage_ok, limits_ok);
218            let message = if messages.is_empty() {
219                None
220            } else {
221                Some(messages.join("; "))
222            };
223            (status, message)
224        };
225
226        snapshot.providers.push(ProviderStatus {
227            provider: provider_id,
228            status,
229            files,
230            usage_events: usage_events.len(),
231            focus_rows: focus_rows.len(),
232            limit_windows: limit_windows.len(),
233            message,
234        });
235        snapshot.usage_events.append(&mut usage_events);
236        snapshot.limit_windows.append(&mut limit_windows);
237        snapshot.focus_rows.extend(focus_rows);
238    }
239
240    Ok(snapshot)
241}
242
243fn provider_status_kind(usage_ok: bool, limits_ok: bool) -> ProviderStatusKind {
244    match (usage_ok, limits_ok) {
245        (true, true) => ProviderStatusKind::Available,
246        (false, false) => ProviderStatusKind::Error,
247        (true, false) | (false, true) => ProviderStatusKind::Partial,
248    }
249}
250
251/// The detected-status line for Cursor: the selected model, the logged-in flag, and
252/// the explicit "usage/quota live (Phase 2)" deferral. Honest about what is locally
253/// knowable (presence + model) versus what is not (cost + quota). Never includes the
254/// account email/userId — only whether a session exists.
255fn cursor_detected_message(config: &CursorConfig) -> String {
256    let model = match (&config.display_name, &config.model_id) {
257        (Some(display), Some(id)) => format!("model {display} ({id})"),
258        (Some(display), None) => format!("model {display}"),
259        (None, Some(id)) => format!("model {id}"),
260        (None, None) => "model unknown".to_string(),
261    };
262    let login = if config.logged_in {
263        "logged in"
264    } else {
265        "login unknown"
266    };
267    format!(
268        "BETA — {model}, {login}; usage unavailable — live (Phase 2); \
269         quota unavailable — live (Phase 2)"
270    )
271}
272
273fn push_meter_records(
274    event: &UsageEvent,
275    pricing: &PricingCatalog,
276    records: &mut Vec<FocusRecord>,
277) -> Result<(), CoreError> {
278    let meters = [
279        (TokenType::Input, event.input_tokens),
280        (TokenType::Output, event.output_tokens),
281        (TokenType::CacheRead, event.cache_read_tokens),
282        (TokenType::CacheWrite, event.cache_write_tokens),
283    ];
284    // Resolve the raw log model id to a catalog key ONCE: exact match wins
285    // (preserving exactness; an explicit dated entry would override the fallback),
286    // else a base id with a strict date-snapshot suffix stripped, iff that base is
287    // in the table. Both the model-info and rate lookups use the same resolved key,
288    // so model-presence and rate-presence can never disagree.
289    let resolved = pricing.resolve_key(&event.model);
290    let model = resolved.and_then(|key| pricing.model(key));
291
292    for (token_type, token_count) in meters {
293        if token_count == 0 {
294            continue;
295        }
296        let mut row = FocusRecord::unpriced_usage(UnpricedUsage {
297            timestamp: event.timestamp,
298            tool: event.tool.to_string(),
299            model: event.model.clone(),
300            token_type,
301            token_count,
302            project: event.project.clone(),
303            access_path: focus_access_path(event.access_path),
304            service_name: model
305                .map(|model| model.service_name.clone())
306                .unwrap_or_else(|| service_name(event.tool).to_string()),
307            service_provider_name: vendor_name(event.tool).to_string(),
308            host_provider_name: vendor_name(event.tool).to_string(),
309            invoice_issuer_name: vendor_name(event.tool).to_string(),
310            billing_currency: model
311                .map(|_| pricing.currency.clone())
312                .unwrap_or_else(|| DEFAULT_BILLING_CURRENCY.to_string()),
313        })?;
314
315        match resolved.and_then(|key| pricing.rate(key, token_type)) {
316            Some(rate) => apply_pricing(&mut row, rate, pricing),
317            None if model.is_none() => {
318                row.x_pricing_status = PRICING_STATUS_UNKNOWN_MODEL.to_string();
319            }
320            None => {}
321        }
322
323        records.push(row);
324    }
325
326    Ok(())
327}
328
329fn apply_pricing(row: &mut FocusRecord, rate: &CatalogRate, pricing: &PricingCatalog) {
330    // Per-token representation (FOCUS UnitFormat): PricingQuantity is the token
331    // count, the unit-price columns are per-token (the per-1M catalog rate ÷
332    // 1_000_000). Cost is invariant: per_token × tokens == tokens × rate ÷ 1e6,
333    // identical to the previous (tokens / 1e6) × rate and exact in Decimal for
334    // every catalog rate (each price has ≤2 dp, so ÷1e6 terminates at ≤8 dp).
335    let per_token = rate.price / Decimal::from(1_000_000_u64);
336    let quantity = row.x_consumed_tokens;
337    let cost = per_token * quantity;
338    row.billed_cost = cost;
339    row.effective_cost = cost;
340    row.list_cost = cost;
341    row.contracted_cost = cost;
342    // A priced SKU exists: populate the columns nulled on unpriced rows.
343    row.consumed_quantity = Some(quantity);
344    row.pricing_quantity = Some(quantity);
345    row.pricing_category = Some(PRICING_CATEGORY_STANDARD.to_string());
346    row.pricing_unit = Some(PRICING_UNIT_TOKENS.to_string());
347    row.sku_price_id = Some(pricing.sku_price_id(rate));
348    row.list_unit_price = Some(per_token);
349    row.contracted_unit_price = Some(per_token);
350    // PricingCurrency == BillingCurrency for Costroid, so the pricing-currency
351    // columns mirror their billing-currency counterparts.
352    row.pricing_currency_effective_cost = cost;
353    row.pricing_currency_list_unit_price = Some(per_token);
354    row.pricing_currency_contracted_unit_price = Some(per_token);
355    row.x_pricing_status = PRICING_STATUS_PRICED.to_string();
356}
357
358/// If `model` ends in a strict dated-snapshot suffix, return the base id with the
359/// suffix removed; otherwise `None`.
360///
361/// Recognizes ONLY the two shapes providers actually mint: `-YYYYMMDD` (Anthropic
362/// snapshots, e.g. `claude-haiku-4-5-20251001`) and `-YYYY-MM-DD` (OpenAI
363/// snapshots, e.g. `gpt-5.5-2025-10-01`). A version component like `-8` (one digit)
364/// matches neither, so a genuinely new version (`claude-opus-4-8`) is never
365/// mistaken for a dated snapshot. A suffix that would leave an empty base is
366/// rejected. Pure, ASCII, never panics.
367fn strip_date_suffix(model: &str) -> Option<&str> {
368    strip_dashed_date(model).or_else(|| strip_compact_date(model))
369}
370
371/// `<base>-YYYY-MM-DD` → `<base>` (OpenAI dated-snapshot form).
372fn strip_dashed_date(model: &str) -> Option<&str> {
373    let head = model.get(..model.len().checked_sub(11)?)?;
374    let tail = model.get(head.len()..)?.as_bytes();
375    let ok = tail[0] == b'-'
376        && tail[1..5].iter().all(u8::is_ascii_digit)
377        && tail[5] == b'-'
378        && tail[6..8].iter().all(u8::is_ascii_digit)
379        && tail[8] == b'-'
380        && tail[9..11].iter().all(u8::is_ascii_digit);
381    (ok && !head.is_empty()).then_some(head)
382}
383
384/// `<base>-YYYYMMDD` → `<base>` (Anthropic dated-snapshot form, exactly 8 digits).
385fn strip_compact_date(model: &str) -> Option<&str> {
386    let head = model.get(..model.len().checked_sub(9)?)?;
387    let tail = model.get(head.len()..)?.as_bytes();
388    let ok = tail[0] == b'-' && tail[1..].iter().all(u8::is_ascii_digit);
389    (ok && !head.is_empty()).then_some(head)
390}
391
392#[derive(Debug, Deserialize)]
393struct PricingTable {
394    schema_version: String,
395    as_of: String,
396    currency: String,
397    #[serde(default)]
398    models: Vec<PricingModel>,
399}
400
401#[derive(Debug, Deserialize)]
402struct PricingModel {
403    provider: String,
404    model: String,
405    service_name: String,
406    #[serde(default)]
407    rates: Vec<PricingRate>,
408}
409
410#[derive(Debug, Deserialize)]
411struct PricingRate {
412    meter: String,
413    unit: String,
414    price: Decimal,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
418struct PricingModelInfo {
419    service_name: String,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
423struct CatalogRate {
424    provider: String,
425    model: String,
426    meter: String,
427    unit: String,
428    price: Decimal,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
432struct PricingCatalog {
433    as_of: String,
434    currency: String,
435    models: BTreeMap<String, PricingModelInfo>,
436    rates: BTreeMap<(String, String), CatalogRate>,
437}
438
439impl PricingCatalog {
440    fn bundled() -> Result<Self, CoreError> {
441        Self::from_json(bundled_pricing_json())
442    }
443
444    fn from_json(value: &str) -> Result<Self, CoreError> {
445        let table = serde_json::from_str::<PricingTable>(value)?;
446        Self::from_table(table)
447    }
448
449    fn model(&self, model: &str) -> Option<&PricingModelInfo> {
450        self.models.get(model)
451    }
452
453    fn rate(&self, model: &str, token_type: TokenType) -> Option<&CatalogRate> {
454        self.rates
455            .get(&(model.to_string(), token_type.as_str().to_string()))
456    }
457
458    /// Per-1M-token list price for a `(model, meter)` pair, by the meter's string id
459    /// (`"input"`/`"output"`/`"cache_read"`/`"cache_write"`). Lets `bench` re-price by
460    /// the `x_token_type` string without round-tripping through `TokenType`.
461    fn meter_price(&self, model: &str, meter: &str) -> Option<Decimal> {
462        self.rates
463            .get(&(model.to_string(), meter.to_string()))
464            .map(|rate| rate.price)
465    }
466
467    /// Resolve a raw log model id to the catalog key whose info/rates apply.
468    ///
469    /// 1. Exact match wins — preserves prior behavior and lets a curated explicit
470    ///    dated entry override the base-alias fallback (the escape hatch when a
471    ///    snapshot is ever repriced away from its base).
472    /// 2. Else strip a strict date-snapshot suffix and use the bare base **iff that
473    ///    base already exists in the catalog** — so we never invent a mapping, and a
474    ///    version bump (`claude-opus-4-8`) is never folded onto a different version.
475    /// 3. Else `None` (genuinely unknown model → `unknown_model`).
476    fn resolve_key<'a>(&'a self, model: &'a str) -> Option<&'a str> {
477        if self.models.contains_key(model) {
478            return Some(model);
479        }
480        let base = strip_date_suffix(model)?;
481        if self.models.contains_key(base) {
482            return Some(base);
483        }
484        None
485    }
486
487    fn sku_price_id(&self, rate: &CatalogRate) -> String {
488        // Opaque, stable per-rate identifier. The unit component reflects the
489        // FOCUS-facing per-token basis (consistent with PricingUnit / ListUnitPrice),
490        // not the catalog's per-1M rate basis — no stale "1M_tokens" in the id.
491        format!(
492            "{}:{}:{}:{}:{}",
493            rate.provider, rate.model, rate.meter, PRICING_UNIT_TOKENS, self.as_of
494        )
495    }
496
497    fn from_table(table: PricingTable) -> Result<Self, CoreError> {
498        if table.schema_version != PRICING_SCHEMA_VERSION {
499            return Err(CoreError::PricingValidation(format!(
500                "unsupported schema_version {}; expected {}",
501                table.schema_version, PRICING_SCHEMA_VERSION
502            )));
503        }
504        if table.currency != DEFAULT_BILLING_CURRENCY {
505            return Err(CoreError::PricingValidation(format!(
506                "unsupported currency {}; expected {}",
507                table.currency, DEFAULT_BILLING_CURRENCY
508            )));
509        }
510
511        let mut catalog = Self {
512            as_of: table.as_of,
513            currency: table.currency,
514            models: BTreeMap::new(),
515            rates: BTreeMap::new(),
516        };
517
518        for model in table.models {
519            if catalog
520                .models
521                .insert(
522                    model.model.clone(),
523                    PricingModelInfo {
524                        service_name: model.service_name.clone(),
525                    },
526                )
527                .is_some()
528            {
529                return Err(CoreError::PricingValidation(format!(
530                    "duplicate pricing model {}",
531                    model.model
532                )));
533            }
534
535            for rate in model.rates {
536                if rate.unit != PRICING_UNIT_1M_TOKENS {
537                    return Err(CoreError::PricingValidation(format!(
538                        "unsupported pricing unit {} for {}:{}",
539                        rate.unit, model.model, rate.meter
540                    )));
541                }
542                if !is_supported_meter(&rate.meter) {
543                    return Err(CoreError::PricingValidation(format!(
544                        "unsupported pricing meter {} for {}",
545                        rate.meter, model.model
546                    )));
547                }
548
549                let key = (model.model.clone(), rate.meter.clone());
550                if catalog.rates.contains_key(&key) {
551                    return Err(CoreError::PricingValidation(format!(
552                        "duplicate pricing rate {}:{}",
553                        model.model, rate.meter
554                    )));
555                }
556
557                catalog.rates.insert(
558                    key,
559                    CatalogRate {
560                        provider: model.provider.clone(),
561                        model: model.model.clone(),
562                        meter: rate.meter,
563                        unit: rate.unit,
564                        price: rate.price,
565                    },
566                );
567            }
568        }
569
570        Ok(catalog)
571    }
572}
573
574fn is_supported_meter(value: &str) -> bool {
575    matches!(value, "input" | "output" | "cache_read" | "cache_write")
576}
577
578fn focus_access_path(access_path: AccessPath) -> FocusAccessPath {
579    match access_path {
580        AccessPath::Api => FocusAccessPath::Api,
581        AccessPath::Subscription => FocusAccessPath::Subscription,
582        AccessPath::Unknown => FocusAccessPath::Unknown,
583    }
584}
585
586fn service_name(provider: ProviderId) -> &'static str {
587    match provider {
588        ProviderId::ClaudeCode => "Claude Code",
589        ProviderId::Codex => "Codex",
590        ProviderId::Cursor => "Cursor",
591    }
592}
593
594fn vendor_name(provider: ProviderId) -> &'static str {
595    match provider {
596        ProviderId::ClaudeCode => "Anthropic",
597        ProviderId::Codex => "OpenAI",
598        ProviderId::Cursor => "Anysphere",
599    }
600}
601
602fn summarize_rows<'a, I>(rows: I, group_by: GroupBy) -> Vec<CostLaneSummary>
603where
604    I: IntoIterator<Item = &'a FocusRecord>,
605{
606    let mut summaries = BTreeMap::<(CostLane, GroupKey), AggregateTotals>::new();
607    for row in rows {
608        let lane = CostLane::from_access_path(&row.x_access_path);
609        let group = group_key(row, group_by);
610        summaries.entry((lane, group)).or_default().add_row(row);
611    }
612
613    summaries
614        .into_iter()
615        .map(|((lane, group), totals)| CostLaneSummary {
616            group,
617            lane,
618            totals,
619        })
620        .collect()
621}
622
623fn group_key(row: &FocusRecord, group_by: GroupBy) -> GroupKey {
624    let value = match group_by {
625        GroupBy::Model => non_empty_value(&row.x_model),
626        GroupBy::App => row
627            .x_project
628            .as_deref()
629            .map(non_empty_value)
630            .unwrap_or_else(|| UNKNOWN_GROUP_VALUE.to_string()),
631        GroupBy::Total => TOTAL_GROUP_VALUE.to_string(),
632    };
633
634    GroupKey {
635        kind: group_by,
636        value,
637    }
638}
639
640fn non_empty_value(value: &str) -> String {
641    let trimmed = value.trim();
642    if trimmed.is_empty() {
643        UNKNOWN_GROUP_VALUE.to_string()
644    } else {
645        trimmed.to_string()
646    }
647}
648
649fn limit_summary(limit: &LimitWindow, generated_at: DateTime<Utc>) -> LimitSummary {
650    LimitSummary {
651        tool: limit.tool,
652        plan: limit.plan.clone(),
653        kind: limit.kind,
654        label: limit.label.clone(),
655        availability: limit_availability(limit, generated_at),
656    }
657}
658
659fn limit_availability(limit: &LimitWindow, generated_at: DateTime<Utc>) -> LimitAvailability {
660    let reset_in_seconds = limit
661        .resets_at
662        .map(|resets_at| clamp_reset_seconds(resets_at, generated_at));
663    let is_stale = limit
664        .resets_at
665        .map(|resets_at| resets_at < generated_at)
666        .unwrap_or(false);
667
668    match (limit.used_fraction, limit.resets_at, is_stale) {
669        (Some(used_fraction), Some(resets_at), false) => LimitAvailability::Available {
670            used_fraction,
671            resets_at,
672            reset_in_seconds: reset_in_seconds.unwrap_or(0),
673        },
674        (None, None, _) => LimitAvailability::Unavailable {
675            reason: limit
676                .label
677                .clone()
678                .unwrap_or_else(|| "limit data unavailable from local logs".to_string()),
679        },
680        _ => LimitAvailability::Partial {
681            used_fraction: limit.used_fraction,
682            resets_at: limit.resets_at,
683            reset_in_seconds,
684            reason: if is_stale {
685                "data may be stale".to_string()
686            } else {
687                "limit data incomplete".to_string()
688            },
689        },
690    }
691}
692
693fn clamp_reset_seconds(resets_at: DateTime<Utc>, generated_at: DateTime<Utc>) -> i64 {
694    resets_at
695        .signed_duration_since(generated_at)
696        .num_seconds()
697        .max(0)
698}
699
700fn start_of_period_local(period: Period, anchor: DateTime<Local>) -> DateTime<Local> {
701    match period {
702        Period::Day => local_start_of_day(anchor.date_naive(), anchor),
703        Period::Week => {
704            let days_from_monday = i64::from(anchor.weekday().num_days_from_monday());
705            let date = match anchor
706                .date_naive()
707                .checked_sub_signed(Duration::days(days_from_monday))
708            {
709                Some(value) => value,
710                None => anchor.date_naive(),
711            };
712            local_start_of_day(date, anchor)
713        }
714        Period::Month => local_start_for_ymd(anchor.year(), anchor.month(), 1, anchor),
715        Period::Year => local_start_for_ymd(anchor.year(), 1, 1, anchor),
716    }
717}
718
719fn add_period_local(period: Period, start: DateTime<Local>) -> DateTime<Local> {
720    match period {
721        Period::Day => add_days_local(start, 1),
722        Period::Week => add_days_local(start, 7),
723        Period::Month => {
724            let (year, month) = if start.month() == 12 {
725                match start.year().checked_add(1) {
726                    Some(year) => (year, 1),
727                    None => return add_days_local(start, 31),
728                }
729            } else {
730                (start.year(), start.month() + 1)
731            };
732            local_start_for_ymd(year, month, 1, add_days_local(start, 31))
733        }
734        Period::Year => match start.year().checked_add(1) {
735            Some(year) => local_start_for_ymd(year, 1, 1, add_days_local(start, 366)),
736            None => add_days_local(start, 366),
737        },
738    }
739}
740
741fn add_days_local(start: DateTime<Local>, days: i64) -> DateTime<Local> {
742    let fallback = start + Duration::days(days);
743    match start.date_naive().checked_add_signed(Duration::days(days)) {
744        Some(date) => local_start_of_day(date, fallback),
745        None => fallback,
746    }
747}
748
749fn local_start_for_ymd(
750    year: i32,
751    month: u32,
752    day: u32,
753    fallback: DateTime<Local>,
754) -> DateTime<Local> {
755    match NaiveDate::from_ymd_opt(year, month, day) {
756        Some(date) => local_start_of_day(date, fallback),
757        None => fallback,
758    }
759}
760
761fn local_start_of_day(date: NaiveDate, fallback: DateTime<Local>) -> DateTime<Local> {
762    let midnight = match date.and_hms_opt(0, 0, 0) {
763        Some(value) => value,
764        None => return fallback,
765    };
766
767    match Local.from_local_datetime(&midnight) {
768        LocalResult::Single(value) => value,
769        LocalResult::Ambiguous(first, _) => first,
770        LocalResult::None => first_valid_local_after(midnight, fallback),
771    }
772}
773
774fn first_valid_local_after(
775    start: chrono::NaiveDateTime,
776    fallback: DateTime<Local>,
777) -> DateTime<Local> {
778    for minutes_after in 1_i64..=180 {
779        let candidate = match start.checked_add_signed(Duration::minutes(minutes_after)) {
780            Some(value) => value,
781            None => return fallback,
782        };
783        match Local.from_local_datetime(&candidate) {
784            LocalResult::Single(value) => return value,
785            LocalResult::Ambiguous(first, _) => return first,
786            LocalResult::None => {}
787        }
788    }
789    fallback
790}
791
792pub type Snapshot = EngineSnapshot;
793
794#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
795pub struct EngineSnapshot {
796    pub generated_at: DateTime<Utc>,
797    pub usage_events: Vec<UsageEvent>,
798    pub focus_rows: Vec<FocusRecord>,
799    pub limit_windows: Vec<LimitWindow>,
800    pub providers: Vec<ProviderStatus>,
801}
802
803impl EngineSnapshot {
804    fn empty(generated_at: DateTime<Utc>) -> Self {
805        Self {
806            generated_at,
807            usage_events: Vec::new(),
808            focus_rows: Vec::new(),
809            limit_windows: Vec::new(),
810            providers: Vec::new(),
811        }
812    }
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816pub struct ProviderStatus {
817    pub provider: ProviderId,
818    pub status: ProviderStatusKind,
819    pub files: usize,
820    pub usage_events: usize,
821    pub focus_rows: usize,
822    pub limit_windows: usize,
823    pub message: Option<String>,
824}
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
827#[serde(rename_all = "snake_case")]
828pub enum ProviderStatusKind {
829    Available,
830    /// Installed and healthy, but with no local cost/quota data by design — the
831    /// detect-and-defer case (Cursor, whose usage and quota are live server RPCs,
832    /// not local files). Distinct from `Missing` ("not installed") and `Available`
833    /// ("local usage parsed"); the deferral detail rides `ProviderStatus.message`.
834    Detected,
835    Partial,
836    Missing,
837    Error,
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
841#[serde(rename_all = "kebab-case")]
842pub enum Period {
843    Day,
844    Week,
845    Month,
846    Year,
847}
848
849#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
850#[serde(rename_all = "kebab-case")]
851pub enum GroupBy {
852    Model,
853    App,
854    Total,
855}
856
857#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
858#[serde(rename_all = "snake_case")]
859pub enum CostLane {
860    Api,
861    SubscriptionEstimate,
862    UnknownAccess,
863}
864
865impl CostLane {
866    fn from_access_path(value: &str) -> Self {
867        match value {
868            "api" => Self::Api,
869            "subscription" => Self::SubscriptionEstimate,
870            _ => Self::UnknownAccess,
871        }
872    }
873}
874
875#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
876pub struct PeriodRange {
877    pub start: DateTime<Utc>,
878    pub end: DateTime<Utc>,
879}
880
881impl PeriodRange {
882    pub fn contains(&self, timestamp: DateTime<Utc>) -> bool {
883        timestamp >= self.start && timestamp < self.end
884    }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
888pub struct GroupKey {
889    pub kind: GroupBy,
890    pub value: String,
891}
892
893#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
894pub struct TokenTotals {
895    pub input: u64,
896    pub output: u64,
897    pub cache_read: u64,
898    pub cache_write: u64,
899}
900
901impl TokenTotals {
902    pub fn total(&self) -> u64 {
903        self.input + self.output + self.cache_read + self.cache_write
904    }
905
906    fn add(&mut self, token_type: &str, tokens: u64) {
907        match token_type {
908            "input" => self.input += tokens,
909            "output" => self.output += tokens,
910            "cache_read" => self.cache_read += tokens,
911            "cache_write" => self.cache_write += tokens,
912            _ => {}
913        }
914    }
915}
916
917#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
918pub struct PricingCoverage {
919    pub priced_rows: usize,
920    pub missing_price_rows: usize,
921    pub unknown_model_rows: usize,
922}
923
924impl PricingCoverage {
925    fn add(&mut self, status: &str) {
926        match status {
927            PRICING_STATUS_PRICED => self.priced_rows += 1,
928            PRICING_STATUS_UNKNOWN_MODEL => self.unknown_model_rows += 1,
929            PRICING_STATUS_MISSING_PRICE => self.missing_price_rows += 1,
930            _ => self.missing_price_rows += 1,
931        }
932    }
933}
934
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936pub struct AggregateTotals {
937    pub row_count: usize,
938    pub billed_cost: Decimal,
939    pub effective_cost: Decimal,
940    pub currency: Option<String>,
941    pub multiple_currencies: bool,
942    pub tokens: TokenTotals,
943    pub pricing_coverage: PricingCoverage,
944    pub estimated_rows: usize,
945}
946
947impl AggregateTotals {
948    fn add_row(&mut self, row: &FocusRecord) {
949        self.row_count += 1;
950        self.billed_cost += row.billed_cost;
951        self.effective_cost += row.effective_cost;
952        self.add_currency(&row.billing_currency);
953        // Token totals come from x_ConsumedTokens (always populated), not
954        // ConsumedQuantity, which is null on unpriced rows per FOCUS 1.3.
955        self.tokens
956            .add(&row.x_token_type, decimal_to_u64(row.x_consumed_tokens));
957        self.pricing_coverage.add(&row.x_pricing_status);
958        if row.x_estimated {
959            self.estimated_rows += 1;
960        }
961    }
962
963    fn add_currency(&mut self, currency: &str) {
964        match &self.currency {
965            None => self.currency = Some(currency.to_string()),
966            Some(current) if current == currency => {}
967            Some(_) => self.multiple_currencies = true,
968        }
969    }
970}
971
972impl Default for AggregateTotals {
973    fn default() -> Self {
974        Self {
975            row_count: 0,
976            billed_cost: Decimal::from(0),
977            effective_cost: Decimal::from(0),
978            currency: None,
979            multiple_currencies: false,
980            tokens: TokenTotals::default(),
981            pricing_coverage: PricingCoverage::default(),
982            estimated_rows: 0,
983        }
984    }
985}
986
987fn decimal_to_u64(value: Decimal) -> u64 {
988    value.to_u64().unwrap_or_default()
989}
990
991#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992pub struct CostLaneSummary {
993    pub group: GroupKey,
994    pub lane: CostLane,
995    pub totals: AggregateTotals,
996}
997
998#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
999pub struct LimitSummary {
1000    pub tool: ProviderId,
1001    pub plan: Option<String>,
1002    pub kind: LimitKind,
1003    pub label: Option<String>,
1004    pub availability: LimitAvailability,
1005}
1006
1007#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1008#[serde(rename_all = "snake_case")]
1009pub enum LimitAvailability {
1010    Available {
1011        used_fraction: f64,
1012        resets_at: DateTime<Utc>,
1013        reset_in_seconds: i64,
1014    },
1015    Partial {
1016        used_fraction: Option<f64>,
1017        resets_at: Option<DateTime<Utc>>,
1018        reset_in_seconds: Option<i64>,
1019        reason: String,
1020    },
1021    Unavailable {
1022        reason: String,
1023    },
1024}
1025
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027pub struct NowOptions {
1028    pub cost_period: Period,
1029    pub group_by: GroupBy,
1030}
1031
1032impl Default for NowOptions {
1033    fn default() -> Self {
1034        Self {
1035            cost_period: Period::Week,
1036            group_by: GroupBy::Model,
1037        }
1038    }
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct TrendsOptions {
1043    pub period: Period,
1044    pub group_by: GroupBy,
1045}
1046
1047impl Default for TrendsOptions {
1048    fn default() -> Self {
1049        Self {
1050            period: Period::Week,
1051            group_by: GroupBy::Model,
1052        }
1053    }
1054}
1055
1056#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1057pub struct EngineOptions {
1058    pub period: Period,
1059    pub group_by: GroupBy,
1060}
1061
1062impl Default for EngineOptions {
1063    fn default() -> Self {
1064        Self {
1065            period: Period::Week,
1066            group_by: GroupBy::Model,
1067        }
1068    }
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1072pub struct NowSummary {
1073    pub generated_at: DateTime<Utc>,
1074    pub cost_period: PeriodRange,
1075    pub group_by: GroupBy,
1076    pub limits: Vec<LimitSummary>,
1077    pub current_costs: Vec<CostLaneSummary>,
1078    pub providers: Vec<ProviderStatus>,
1079}
1080
1081#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1082pub struct TrendsSummary {
1083    pub generated_at: DateTime<Utc>,
1084    pub period: Period,
1085    pub group_by: GroupBy,
1086    pub buckets: Vec<TrendBucket>,
1087    pub totals: Vec<CostLaneSummary>,
1088    pub providers: Vec<ProviderStatus>,
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1092pub struct TrendBucket {
1093    pub period: PeriodRange,
1094    pub group: GroupKey,
1095    pub lane: CostLane,
1096    pub totals: AggregateTotals,
1097}
1098
1099#[derive(Debug, Error)]
1100pub enum CoreError {
1101    #[error("bundled pricing JSON is invalid: {0}")]
1102    PricingJson(#[from] serde_json::Error),
1103
1104    #[error("bundled pricing table is invalid: {0}")]
1105    PricingValidation(String),
1106
1107    #[error("bundled benchmark data is invalid: {0}")]
1108    BenchValidation(String),
1109
1110    #[error("FOCUS export failed: {0}")]
1111    Focus(#[from] FocusError),
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117    use chrono::{LocalResult, TimeZone, Timelike, Weekday};
1118    use costroid_focus::{PRICING_CATEGORY_STANDARD, PRICING_STATUS_MISSING_PRICE};
1119    use costroid_providers::{CursorProvider, DataLocation, ProviderError};
1120    use std::path::PathBuf;
1121
1122    fn fixture_path(parts: &[&str]) -> PathBuf {
1123        let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1124        path.push("..");
1125        path.push("..");
1126        path.push("fixtures");
1127        for part in parts {
1128            path.push(part);
1129        }
1130        path
1131    }
1132
1133    fn timestamp() -> DateTime<Utc> {
1134        utc_datetime(2026, 1, 1, 10, 0, 0)
1135    }
1136
1137    fn utc_datetime(
1138        year: i32,
1139        month: u32,
1140        day: u32,
1141        hour: u32,
1142        minute: u32,
1143        second: u32,
1144    ) -> DateTime<Utc> {
1145        match Utc.with_ymd_and_hms(year, month, day, hour, minute, second) {
1146            LocalResult::Single(value) => value,
1147            LocalResult::Ambiguous(_, _) | LocalResult::None => {
1148                panic!("test timestamp should be valid")
1149            }
1150        }
1151    }
1152
1153    fn usage_event(
1154        tool: ProviderId,
1155        access_path: AccessPath,
1156        timestamp: DateTime<Utc>,
1157    ) -> UsageEvent {
1158        UsageEvent {
1159            tool,
1160            model: "gpt-5.5".to_string(),
1161            timestamp,
1162            input_tokens: 10,
1163            output_tokens: 20,
1164            cache_read_tokens: 30,
1165            cache_write_tokens: 0,
1166            project: Some("/work/project".to_string()),
1167            access_path,
1168        }
1169    }
1170
1171    fn record(
1172        access_path: FocusAccessPath,
1173        timestamp: DateTime<Utc>,
1174        model: &str,
1175        project: Option<&str>,
1176        token_type: TokenType,
1177        token_count: u64,
1178    ) -> FocusRecord {
1179        match FocusRecord::unpriced_usage(UnpricedUsage {
1180            timestamp,
1181            tool: "codex".to_string(),
1182            model: model.to_string(),
1183            token_type,
1184            token_count,
1185            project: project.map(ToString::to_string),
1186            access_path,
1187            service_name: "Codex".to_string(),
1188            service_provider_name: "OpenAI".to_string(),
1189            host_provider_name: "OpenAI".to_string(),
1190            invoice_issuer_name: "OpenAI".to_string(),
1191            billing_currency: DEFAULT_BILLING_CURRENCY.to_string(),
1192        }) {
1193            Ok(value) => value,
1194            Err(err) => panic!("record should build: {err}"),
1195        }
1196    }
1197
1198    fn snapshot_with_rows(
1199        generated_at: DateTime<Utc>,
1200        focus_rows: Vec<FocusRecord>,
1201        limit_windows: Vec<LimitWindow>,
1202    ) -> EngineSnapshot {
1203        EngineSnapshot {
1204            generated_at,
1205            usage_events: Vec::new(),
1206            focus_rows,
1207            limit_windows,
1208            providers: Vec::new(),
1209        }
1210    }
1211
1212    fn cost_for(rows: &[FocusRecord], token_type: &str) -> Decimal {
1213        match rows.iter().find(|row| row.x_token_type == token_type) {
1214            Some(row) => row.billed_cost,
1215            None => panic!("{token_type} row should exist"),
1216        }
1217    }
1218
1219    #[test]
1220    fn bundled_pricing_is_valid_json() {
1221        assert!(bundled_pricing_value().is_ok());
1222    }
1223
1224    #[test]
1225    fn bundled_pricing_deserializes_decimal_string_rates() {
1226        let catalog = match PricingCatalog::bundled() {
1227            Ok(value) => value,
1228            Err(err) => panic!("bundled pricing should parse: {err}"),
1229        };
1230
1231        let rate = match catalog.rate("gpt-5.5", TokenType::Input) {
1232            Some(value) => value,
1233            None => panic!("gpt-5.5 input rate should exist"),
1234        };
1235
1236        assert_eq!(rate.price, Decimal::new(500, 2));
1237        assert_eq!(catalog.currency, "USD");
1238        assert_eq!(
1239            catalog.sku_price_id(rate),
1240            "openai:gpt-5.5:input:tokens:2026-06-02"
1241        );
1242    }
1243
1244    #[test]
1245    fn default_options_match_now_screen_defaults() {
1246        let options = EngineOptions::default();
1247        let now_options = NowOptions::default();
1248        let trends_options = TrendsOptions::default();
1249
1250        assert_eq!(options.period, Period::Week);
1251        assert_eq!(options.group_by, GroupBy::Model);
1252        assert_eq!(now_options.cost_period, Period::Week);
1253        assert_eq!(now_options.group_by, GroupBy::Model);
1254        assert_eq!(trends_options.period, Period::Week);
1255        assert_eq!(trends_options.group_by, GroupBy::Model);
1256    }
1257
1258    #[test]
1259    fn usage_events_convert_to_one_record_per_nonzero_meter() {
1260        let event = usage_event(ProviderId::Codex, AccessPath::Subscription, timestamp());
1261        let rows = match focus_records_from_usage(&[event]) {
1262            Ok(value) => value,
1263            Err(err) => panic!("conversion should succeed: {err}"),
1264        };
1265
1266        assert_eq!(rows.len(), 3);
1267        assert!(rows.iter().all(|row| row.x_estimated));
1268        assert!(rows
1269            .iter()
1270            .all(|row| row.pricing_category.as_deref() == Some(PRICING_CATEGORY_STANDARD)));
1271        assert!(rows
1272            .iter()
1273            .all(|row| row.x_pricing_status == PRICING_STATUS_PRICED));
1274    }
1275
1276    #[test]
1277    fn priced_usage_applies_costs_per_model_meter() {
1278        let event = UsageEvent {
1279            tool: ProviderId::Codex,
1280            model: "gpt-5.5".to_string(),
1281            timestamp: timestamp(),
1282            input_tokens: 10,
1283            output_tokens: 20,
1284            cache_read_tokens: 30,
1285            cache_write_tokens: 0,
1286            project: Some("/work/project".to_string()),
1287            access_path: AccessPath::Api,
1288        };
1289
1290        let rows = match focus_records_from_usage(&[event]) {
1291            Ok(value) => value,
1292            Err(err) => panic!("conversion should succeed: {err}"),
1293        };
1294
1295        let input = match rows.iter().find(|row| row.x_token_type == "input") {
1296            Some(value) => value,
1297            None => panic!("input row should exist"),
1298        };
1299        let output = match rows.iter().find(|row| row.x_token_type == "output") {
1300            Some(value) => value,
1301            None => panic!("output row should exist"),
1302        };
1303        let cache_read = match rows.iter().find(|row| row.x_token_type == "cache_read") {
1304            Some(value) => value,
1305            None => panic!("cache_read row should exist"),
1306        };
1307
1308        // Per-token representation: PricingQuantity is the token count, PricingUnit
1309        // is "tokens", and ListUnitPrice is per-token (5.00 / 1M = 0.000005).
1310        assert_eq!(input.pricing_quantity, Some(Decimal::from(10)));
1311        assert_eq!(input.consumed_quantity, Some(Decimal::from(10)));
1312        assert_eq!(input.pricing_unit.as_deref(), Some("tokens"));
1313        assert_eq!(
1314            input.pricing_category.as_deref(),
1315            Some(PRICING_CATEGORY_STANDARD)
1316        );
1317        assert_eq!(input.list_unit_price, Some(Decimal::new(5, 6)));
1318        assert_eq!(input.contracted_unit_price, Some(Decimal::new(5, 6)));
1319        // Cost is unchanged from M4.5: 10 tokens x $5.00/1M = $0.00005.
1320        assert_eq!(input.billed_cost, Decimal::new(5, 5));
1321        assert_eq!(input.effective_cost, input.billed_cost);
1322        assert_eq!(input.list_cost, input.billed_cost);
1323        assert_eq!(input.contracted_cost, input.billed_cost);
1324        assert_eq!(
1325            input.sku_price_id.as_deref(),
1326            Some("openai:gpt-5.5:input:tokens:2026-06-02")
1327        );
1328        assert_eq!(input.service_name, "OpenAI API");
1329        assert_eq!(input.billing_currency, "USD");
1330        assert_eq!(input.x_pricing_status, PRICING_STATUS_PRICED);
1331
1332        assert_eq!(output.billed_cost, Decimal::new(6, 4));
1333        assert_eq!(cache_read.billed_cost, Decimal::new(15, 6));
1334    }
1335
1336    #[test]
1337    fn cost_equals_per_token_price_times_quantity_and_matches_legacy_formula() {
1338        let event = UsageEvent {
1339            tool: ProviderId::Codex,
1340            model: "gpt-5.5".to_string(),
1341            timestamp: timestamp(),
1342            input_tokens: 1_234_567,
1343            output_tokens: 20,
1344            cache_read_tokens: 30,
1345            cache_write_tokens: 0,
1346            project: None,
1347            access_path: AccessPath::Api,
1348        };
1349        let rows = match focus_records_from_usage(&[event]) {
1350            Ok(value) => value,
1351            Err(err) => panic!("conversion should succeed: {err}"),
1352        };
1353        let million = Decimal::from(1_000_000_u64);
1354        let legacy =
1355            |tokens: u64, per_million: Decimal| (Decimal::from(tokens) / million) * per_million;
1356        for row in &rows {
1357            let unit = match row.list_unit_price {
1358                Some(value) => value,
1359                None => panic!("priced row should have a unit price"),
1360            };
1361            let quantity = match row.pricing_quantity {
1362                Some(value) => value,
1363                None => panic!("priced row should have a pricing quantity"),
1364            };
1365            // FOCUS invariant, exact in Decimal: ListCost == ListUnitPrice x PricingQuantity.
1366            assert_eq!(row.list_cost, unit * quantity);
1367            assert_eq!(row.billed_cost, row.list_cost);
1368        }
1369        // Bit-for-bit identical to the pre-M6b (tokens / 1e6) x rate formula.
1370        assert_eq!(
1371            cost_for(&rows, "input"),
1372            legacy(1_234_567, Decimal::new(500, 2))
1373        );
1374        assert_eq!(cost_for(&rows, "output"), legacy(20, Decimal::new(3000, 2)));
1375        assert_eq!(
1376            cost_for(&rows, "cache_read"),
1377            legacy(30, Decimal::new(50, 2))
1378        );
1379    }
1380
1381    #[test]
1382    fn claude_sonnet_prices_all_token_meters() {
1383        let event = UsageEvent {
1384            tool: ProviderId::ClaudeCode,
1385            model: "claude-sonnet-4-6".to_string(),
1386            timestamp: timestamp(),
1387            input_tokens: 1_000_000,
1388            output_tokens: 1_000_000,
1389            cache_read_tokens: 1_000_000,
1390            cache_write_tokens: 1_000_000,
1391            project: None,
1392            access_path: AccessPath::Subscription,
1393        };
1394
1395        let rows = match focus_records_from_usage(&[event]) {
1396            Ok(value) => value,
1397            Err(err) => panic!("conversion should succeed: {err}"),
1398        };
1399
1400        assert_eq!(rows.len(), 4);
1401        assert!(rows
1402            .iter()
1403            .all(|row| row.x_pricing_status == PRICING_STATUS_PRICED));
1404        assert!(rows.iter().all(|row| row.x_estimated));
1405        assert!(rows.iter().all(|row| row.x_access_path == "subscription"));
1406        assert_eq!(cost_for(&rows, "input"), Decimal::new(3, 0));
1407        assert_eq!(cost_for(&rows, "output"), Decimal::new(15, 0));
1408        assert_eq!(cost_for(&rows, "cache_read"), Decimal::new(30, 2));
1409        assert_eq!(cost_for(&rows, "cache_write"), Decimal::new(375, 2));
1410    }
1411
1412    #[test]
1413    fn known_model_missing_meter_keeps_unpriced_convention() {
1414        let event = UsageEvent {
1415            tool: ProviderId::Codex,
1416            model: "gpt-5.5".to_string(),
1417            timestamp: timestamp(),
1418            input_tokens: 0,
1419            output_tokens: 0,
1420            cache_read_tokens: 0,
1421            cache_write_tokens: 1_000_000,
1422            project: None,
1423            access_path: AccessPath::Api,
1424        };
1425
1426        let rows = match focus_records_from_usage(&[event]) {
1427            Ok(value) => value,
1428            Err(err) => panic!("conversion should succeed: {err}"),
1429        };
1430        let row = &rows[0];
1431
1432        assert_eq!(row.x_pricing_status, PRICING_STATUS_MISSING_PRICE);
1433        assert_eq!(row.billed_cost, Decimal::from(0));
1434        assert_eq!(row.list_unit_price, None);
1435        assert_eq!(row.contracted_unit_price, None);
1436        assert_eq!(row.sku_price_id, None);
1437        // FOCUS 1.3: null when SkuPriceId is null. Token count survives on x_.
1438        assert_eq!(row.pricing_category, None);
1439        assert_eq!(row.pricing_quantity, None);
1440        assert_eq!(row.pricing_unit, None);
1441        assert_eq!(row.consumed_quantity, None);
1442        assert_eq!(row.x_consumed_tokens, Decimal::from(1_000_000));
1443        assert_eq!(row.service_name, "OpenAI API");
1444        assert_eq!(row.billing_currency, "USD");
1445    }
1446
1447    #[test]
1448    fn unknown_model_keeps_unpriced_convention_with_unknown_status() {
1449        let event = UsageEvent {
1450            tool: ProviderId::Cursor,
1451            model: "mystery-model".to_string(),
1452            timestamp: timestamp(),
1453            input_tokens: 1_000_000,
1454            output_tokens: 0,
1455            cache_read_tokens: 0,
1456            cache_write_tokens: 0,
1457            project: None,
1458            access_path: AccessPath::Unknown,
1459        };
1460
1461        let rows = match focus_records_from_usage(&[event]) {
1462            Ok(value) => value,
1463            Err(err) => panic!("conversion should succeed: {err}"),
1464        };
1465        let row = &rows[0];
1466
1467        assert_eq!(row.x_pricing_status, PRICING_STATUS_UNKNOWN_MODEL);
1468        assert_eq!(row.billed_cost, Decimal::from(0));
1469        assert_eq!(row.list_unit_price, None);
1470        assert_eq!(row.contracted_unit_price, None);
1471        assert_eq!(row.sku_price_id, None);
1472        // FOCUS 1.3: null when SkuPriceId is null. Token count survives on x_.
1473        assert_eq!(row.pricing_category, None);
1474        assert_eq!(row.pricing_quantity, None);
1475        assert_eq!(row.pricing_unit, None);
1476        assert_eq!(row.consumed_quantity, None);
1477        assert_eq!(row.x_consumed_tokens, Decimal::from(1_000_000));
1478        assert_eq!(row.service_name, "Cursor");
1479        assert_eq!(row.billing_currency, DEFAULT_BILLING_CURRENCY);
1480    }
1481
1482    #[test]
1483    fn exact_match_priced_costs_are_invariant_under_resolution() {
1484        // Cardinal rule: adding suffix-tolerant routing must not move any
1485        // already-priced model's cost. Exact matches take the same path as before.
1486        let events = vec![
1487            UsageEvent {
1488                tool: ProviderId::Codex,
1489                model: "gpt-5.5".to_string(),
1490                timestamp: timestamp(),
1491                input_tokens: 1_000_000,
1492                output_tokens: 1_000_000,
1493                cache_read_tokens: 1_000_000,
1494                cache_write_tokens: 0,
1495                project: None,
1496                access_path: AccessPath::Api,
1497            },
1498            UsageEvent {
1499                tool: ProviderId::ClaudeCode,
1500                model: "claude-sonnet-4-6".to_string(),
1501                timestamp: timestamp(),
1502                input_tokens: 1_000_000,
1503                output_tokens: 1_000_000,
1504                cache_read_tokens: 1_000_000,
1505                cache_write_tokens: 1_000_000,
1506                project: None,
1507                access_path: AccessPath::Subscription,
1508            },
1509        ];
1510        let rows = match focus_records_from_usage(&events) {
1511            Ok(value) => value,
1512            Err(err) => panic!("conversion should succeed: {err}"),
1513        };
1514        let cost_of = |model: &str, meter: &str| -> Decimal {
1515            match rows
1516                .iter()
1517                .find(|r| r.x_model == model && r.x_token_type == meter)
1518            {
1519                Some(r) => r.billed_cost,
1520                None => panic!("missing row for {model}/{meter}"),
1521            }
1522        };
1523        // gpt-5.5 per-1M: input 5.00, output 30.00, cache_read 0.50 (no cache_write).
1524        assert_eq!(cost_of("gpt-5.5", "input"), Decimal::new(5, 0));
1525        assert_eq!(cost_of("gpt-5.5", "output"), Decimal::new(30, 0));
1526        assert_eq!(cost_of("gpt-5.5", "cache_read"), Decimal::new(50, 2));
1527        // claude-sonnet-4-6 per-1M: 3.00 / 15.00 / 0.30 / 3.75.
1528        assert_eq!(cost_of("claude-sonnet-4-6", "input"), Decimal::new(3, 0));
1529        assert_eq!(cost_of("claude-sonnet-4-6", "output"), Decimal::new(15, 0));
1530        assert_eq!(
1531            cost_of("claude-sonnet-4-6", "cache_read"),
1532            Decimal::new(30, 2)
1533        );
1534        assert_eq!(
1535            cost_of("claude-sonnet-4-6", "cache_write"),
1536            Decimal::new(375, 2)
1537        );
1538        // Exact matches route to their own rate: SkuPriceId embeds the model id.
1539        for row in rows
1540            .iter()
1541            .filter(|r| r.x_pricing_status == PRICING_STATUS_PRICED)
1542        {
1543            let sku_price_id = match &row.sku_price_id {
1544                Some(value) => value,
1545                None => panic!("priced row should carry a SkuPriceId"),
1546            };
1547            assert!(
1548                sku_price_id.contains(&row.x_model),
1549                "exact-match SkuPriceId should embed the model id: {sku_price_id}"
1550            );
1551        }
1552    }
1553
1554    #[test]
1555    fn dated_haiku_snapshot_resolves_to_base_rate_with_honest_ids() {
1556        // The real heavy-usage case: claude-haiku-4-5-20251001 is the dated snapshot
1557        // of the in-table base claude-haiku-4-5. It must price at the base rate,
1558        // while x_Model + SkuId keep the ACTUAL dated id and SkuPriceId points at the
1559        // base rate that priced it.
1560        let event = UsageEvent {
1561            tool: ProviderId::ClaudeCode,
1562            model: "claude-haiku-4-5-20251001".to_string(),
1563            timestamp: timestamp(),
1564            input_tokens: 1_000_000,
1565            output_tokens: 1_000_000,
1566            cache_read_tokens: 1_000_000,
1567            cache_write_tokens: 1_000_000,
1568            project: None,
1569            access_path: AccessPath::Subscription,
1570        };
1571        let rows = match focus_records_from_usage(&[event]) {
1572            Ok(value) => value,
1573            Err(err) => panic!("conversion should succeed: {err}"),
1574        };
1575        assert_eq!(rows.len(), 4);
1576        assert!(rows
1577            .iter()
1578            .all(|r| r.x_pricing_status == PRICING_STATUS_PRICED));
1579        let cost_of = |meter: &str| -> Decimal {
1580            match rows.iter().find(|r| r.x_token_type == meter) {
1581                Some(r) => r.billed_cost,
1582                None => panic!("missing meter {meter}"),
1583            }
1584        };
1585        // base claude-haiku-4-5 per-1M: 1.00 / 5.00 / 0.10 / 1.25.
1586        assert_eq!(cost_of("input"), Decimal::new(1, 0));
1587        assert_eq!(cost_of("output"), Decimal::new(5, 0));
1588        assert_eq!(cost_of("cache_read"), Decimal::new(10, 2));
1589        assert_eq!(cost_of("cache_write"), Decimal::new(125, 2));
1590        for row in &rows {
1591            // Honesty: the report shows what actually ran...
1592            assert_eq!(row.x_model, "claude-haiku-4-5-20251001");
1593            let expected_sku_id = format!("claude-haiku-4-5-20251001:{}", row.x_token_type);
1594            assert_eq!(row.sku_id.as_deref(), Some(expected_sku_id.as_str()));
1595            // ...while the price id references the BASE rate that priced it.
1596            let sku_price_id = match &row.sku_price_id {
1597                Some(value) => value,
1598                None => panic!("priced row should carry a SkuPriceId"),
1599            };
1600            assert!(
1601                sku_price_id.starts_with("anthropic:claude-haiku-4-5:"),
1602                "SkuPriceId should reference the base rate: {sku_price_id}"
1603            );
1604            assert!(
1605                !sku_price_id.contains("20251001"),
1606                "SkuPriceId must not embed the dated id: {sku_price_id}"
1607            );
1608        }
1609    }
1610
1611    #[test]
1612    fn openai_dashed_date_snapshot_resolves_to_base() {
1613        // OpenAI snapshots use -YYYY-MM-DD; gpt-5.5-2025-10-01 must price at gpt-5.5.
1614        let event = UsageEvent {
1615            tool: ProviderId::Codex,
1616            model: "gpt-5.5-2025-10-01".to_string(),
1617            timestamp: timestamp(),
1618            input_tokens: 1_000_000,
1619            output_tokens: 0,
1620            cache_read_tokens: 0,
1621            cache_write_tokens: 0,
1622            project: None,
1623            access_path: AccessPath::Api,
1624        };
1625        let rows = match focus_records_from_usage(&[event]) {
1626            Ok(value) => value,
1627            Err(err) => panic!("conversion should succeed: {err}"),
1628        };
1629        assert_eq!(rows.len(), 1);
1630        let row = &rows[0];
1631        assert_eq!(row.x_pricing_status, PRICING_STATUS_PRICED);
1632        assert_eq!(row.billed_cost, Decimal::new(5, 0));
1633        assert_eq!(row.x_model, "gpt-5.5-2025-10-01");
1634        let sku_price_id = match &row.sku_price_id {
1635            Some(value) => value,
1636            None => panic!("priced row should carry a SkuPriceId"),
1637        };
1638        assert!(
1639            sku_price_id.starts_with("openai:gpt-5.5:"),
1640            "{sku_price_id}"
1641        );
1642    }
1643
1644    #[test]
1645    fn genuinely_new_or_fake_models_stay_unknown_not_missing_price() {
1646        // The "not too loose" guard: a version bump absent from the table
1647        // (claude-opus-4-9), a date-shaped id whose base is absent
1648        // (made-up-model-20251001), and a plain fake must all flag unknown_model
1649        // (NOT missing_price), with no cost and a null SkuPriceId.
1650        for model in [
1651            "claude-opus-4-9",
1652            "made-up-model-20251001",
1653            "totally-fake-xyz",
1654        ] {
1655            let event = UsageEvent {
1656                tool: ProviderId::ClaudeCode,
1657                model: model.to_string(),
1658                timestamp: timestamp(),
1659                input_tokens: 1_000_000,
1660                output_tokens: 0,
1661                cache_read_tokens: 0,
1662                cache_write_tokens: 0,
1663                project: None,
1664                access_path: AccessPath::Subscription,
1665            };
1666            let rows = match focus_records_from_usage(&[event]) {
1667                Ok(value) => value,
1668                Err(err) => panic!("conversion should succeed: {err}"),
1669            };
1670            assert_eq!(rows.len(), 1);
1671            let row = &rows[0];
1672            assert_eq!(
1673                row.x_pricing_status, PRICING_STATUS_UNKNOWN_MODEL,
1674                "{model} must be unknown_model, not missing_price"
1675            );
1676            assert_eq!(row.billed_cost, Decimal::from(0));
1677            assert_eq!(row.sku_price_id, None);
1678            assert_eq!(row.pricing_quantity, None);
1679        }
1680    }
1681
1682    #[test]
1683    fn opus_4_8_prices_at_published_rates() {
1684        // Step 2 (table refresh): once the curated claude-opus-4-8 entry exists, the
1685        // genuinely-new model flips unknown_model -> priced at its own exact rate.
1686        let event = UsageEvent {
1687            tool: ProviderId::ClaudeCode,
1688            model: "claude-opus-4-8".to_string(),
1689            timestamp: timestamp(),
1690            input_tokens: 1_000_000,
1691            output_tokens: 1_000_000,
1692            cache_read_tokens: 1_000_000,
1693            cache_write_tokens: 1_000_000,
1694            project: None,
1695            access_path: AccessPath::Subscription,
1696        };
1697        let rows = match focus_records_from_usage(&[event]) {
1698            Ok(value) => value,
1699            Err(err) => panic!("conversion should succeed: {err}"),
1700        };
1701        assert_eq!(rows.len(), 4);
1702        assert!(rows
1703            .iter()
1704            .all(|r| r.x_pricing_status == PRICING_STATUS_PRICED));
1705        let cost_of = |meter: &str| -> Decimal {
1706            match rows.iter().find(|r| r.x_token_type == meter) {
1707                Some(r) => r.billed_cost,
1708                None => panic!("missing meter {meter}"),
1709            }
1710        };
1711        // published claude-opus-4-8 per-1M: 5.00 / 25.00 / 0.50 / 6.25.
1712        assert_eq!(cost_of("input"), Decimal::new(5, 0));
1713        assert_eq!(cost_of("output"), Decimal::new(25, 0));
1714        assert_eq!(cost_of("cache_read"), Decimal::new(50, 2));
1715        assert_eq!(cost_of("cache_write"), Decimal::new(625, 2));
1716        for row in &rows {
1717            // Exact match: SkuPriceId references opus-4-8 itself, not an aliased base.
1718            let sku_price_id = match &row.sku_price_id {
1719                Some(value) => value,
1720                None => panic!("priced row should carry a SkuPriceId"),
1721            };
1722            assert!(
1723                sku_price_id.starts_with("anthropic:claude-opus-4-8:"),
1724                "{sku_price_id}"
1725            );
1726        }
1727    }
1728
1729    #[test]
1730    fn strip_date_suffix_only_strips_real_snapshots() {
1731        // Version components / base ids are never treated as dates.
1732        assert_eq!(strip_date_suffix("claude-opus-4-8"), None);
1733        assert_eq!(strip_date_suffix("gpt-5.5"), None);
1734        assert_eq!(strip_date_suffix("gpt-5.4"), None);
1735        assert_eq!(strip_date_suffix("claude-haiku-4-5"), None);
1736        assert_eq!(strip_date_suffix("mystery-model"), None);
1737        // Anthropic compact 8-digit date.
1738        assert_eq!(
1739            strip_date_suffix("claude-haiku-4-5-20251001"),
1740            Some("claude-haiku-4-5")
1741        );
1742        assert_eq!(
1743            strip_date_suffix("claude-3-5-sonnet-20241022"),
1744            Some("claude-3-5-sonnet")
1745        );
1746        // OpenAI dashed date.
1747        assert_eq!(strip_date_suffix("gpt-5.5-2025-10-01"), Some("gpt-5.5"));
1748        assert_eq!(strip_date_suffix("gpt-4o-2024-08-06"), Some("gpt-4o"));
1749        // Wrong digit counts / malformed are left unmatched (conservative).
1750        assert_eq!(strip_date_suffix("claude-haiku-4-5-2025100"), None); // 7 digits
1751        assert_eq!(strip_date_suffix("claude-haiku-4-5-202510011"), None); // 9 digits
1752        assert_eq!(strip_date_suffix("claude-haiku-4-5-2025"), None); // 4 digits
1753                                                                      // Empty base rejected; no panic on degenerate inputs.
1754        assert_eq!(strip_date_suffix("-20251001"), None);
1755        assert_eq!(strip_date_suffix("20251001"), None);
1756        assert_eq!(strip_date_suffix(""), None);
1757    }
1758
1759    #[test]
1760    fn resolve_key_treats_absent_version_as_unknown_until_an_entry_exists() {
1761        // Demonstrates the step-1 -> step-2 transition independently of the bundled
1762        // table: a version bump is unknown (resolve_key None, not a missing meter)
1763        // while absent, and resolves exactly once an entry is added.
1764        let without = r#"{"schema_version":"1","as_of":"2026-06-02","currency":"USD","models":[{"provider":"anthropic","model":"claude-opus-4-7","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"5.00"}]}]}"#;
1765        let with = r#"{"schema_version":"1","as_of":"2026-06-02","currency":"USD","models":[{"provider":"anthropic","model":"claude-opus-4-7","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"5.00"}]},{"provider":"anthropic","model":"claude-opus-4-8","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"5.00"}]}]}"#;
1766        let absent = match PricingCatalog::from_json(without) {
1767            Ok(value) => value,
1768            Err(err) => panic!("parse should succeed: {err}"),
1769        };
1770        let present = match PricingCatalog::from_json(with) {
1771            Ok(value) => value,
1772            Err(err) => panic!("parse should succeed: {err}"),
1773        };
1774        // A version bump is not a date suffix, so it never folds onto opus-4-7.
1775        assert_eq!(absent.resolve_key("claude-opus-4-8"), None);
1776        assert_eq!(
1777            present.resolve_key("claude-opus-4-8"),
1778            Some("claude-opus-4-8")
1779        );
1780    }
1781
1782    #[test]
1783    fn explicit_dated_entry_overrides_base_alias_fallback() {
1784        // Escape hatch: an exact dated entry wins over the date-stripped base, so a
1785        // repriced snapshot can be pinned without code changes.
1786        let json = r#"{"schema_version":"1","as_of":"2026-06-02","currency":"USD","models":[{"provider":"anthropic","model":"claude-haiku-4-5","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"1.00"}]},{"provider":"anthropic","model":"claude-haiku-4-5-20251001","service_name":"Anthropic API","rates":[{"meter":"input","unit":"1M_tokens","price":"9.99"}]}]}"#;
1787        let catalog = match PricingCatalog::from_json(json) {
1788            Ok(value) => value,
1789            Err(err) => panic!("parse should succeed: {err}"),
1790        };
1791        assert_eq!(
1792            catalog.resolve_key("claude-haiku-4-5-20251001"),
1793            Some("claude-haiku-4-5-20251001")
1794        );
1795        let rate = match catalog.rate("claude-haiku-4-5-20251001", TokenType::Input) {
1796            Some(value) => value,
1797            None => panic!("explicit dated entry should have a rate"),
1798        };
1799        assert_eq!(rate.price, Decimal::new(999, 2));
1800    }
1801
1802    #[test]
1803    fn api_and_subscription_priced_rows_stay_in_separate_lanes() {
1804        let rows = match focus_records_from_usage(&[
1805            UsageEvent {
1806                tool: ProviderId::Codex,
1807                model: "gpt-5.5".to_string(),
1808                timestamp: timestamp(),
1809                input_tokens: 1_000_000,
1810                output_tokens: 0,
1811                cache_read_tokens: 0,
1812                cache_write_tokens: 0,
1813                project: None,
1814                access_path: AccessPath::Api,
1815            },
1816            UsageEvent {
1817                tool: ProviderId::Codex,
1818                model: "gpt-5.5".to_string(),
1819                timestamp: timestamp(),
1820                input_tokens: 0,
1821                output_tokens: 1_000_000,
1822                cache_read_tokens: 0,
1823                cache_write_tokens: 0,
1824                project: None,
1825                access_path: AccessPath::Subscription,
1826            },
1827        ]) {
1828            Ok(value) => value,
1829            Err(err) => panic!("conversion should succeed: {err}"),
1830        };
1831        let snapshot = snapshot_with_rows(timestamp(), rows, Vec::new());
1832
1833        let summary = now_summary(&snapshot, NowOptions::default());
1834        let api = match summary
1835            .current_costs
1836            .iter()
1837            .find(|summary| summary.lane == CostLane::Api)
1838        {
1839            Some(value) => value,
1840            None => panic!("api lane should exist"),
1841        };
1842        let subscription = match summary
1843            .current_costs
1844            .iter()
1845            .find(|summary| summary.lane == CostLane::SubscriptionEstimate)
1846        {
1847            Some(value) => value,
1848            None => panic!("subscription lane should exist"),
1849        };
1850
1851        assert_eq!(api.totals.billed_cost, Decimal::new(5, 0));
1852        assert_eq!(subscription.totals.billed_cost, Decimal::new(30, 0));
1853        assert_eq!(api.totals.pricing_coverage.priced_rows, 1);
1854        assert_eq!(subscription.totals.pricing_coverage.priced_rows, 1);
1855        assert_eq!(api.totals.estimated_rows, 1);
1856        assert_eq!(subscription.totals.estimated_rows, 1);
1857    }
1858
1859    #[test]
1860    fn export_helpers_emit_json_and_csv() {
1861        let rows = match focus_records_from_usage(&[usage_event(
1862            ProviderId::ClaudeCode,
1863            AccessPath::Unknown,
1864            timestamp(),
1865        )]) {
1866            Ok(value) => value,
1867            Err(err) => panic!("conversion should succeed: {err}"),
1868        };
1869        let json = match export_focus_json(rows.clone()) {
1870            Ok(value) => value,
1871            Err(err) => panic!("json export should succeed: {err}"),
1872        };
1873        let csv = match export_focus_csv(&rows) {
1874            Ok(value) => value,
1875            Err(err) => panic!("csv export should succeed: {err}"),
1876        };
1877
1878        assert!(json.contains("\"focusVersion\": \"1.3\""));
1879        assert!(csv.starts_with("BilledCost,EffectiveCost,ListCost,ContractedCost"));
1880    }
1881
1882    #[test]
1883    fn now_summary_keeps_access_path_lanes_separate() {
1884        let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
1885        let rows = vec![
1886            record(
1887                FocusAccessPath::Api,
1888                generated_at,
1889                "shared-model",
1890                Some("/work/a"),
1891                TokenType::Input,
1892                10,
1893            ),
1894            record(
1895                FocusAccessPath::Subscription,
1896                generated_at,
1897                "shared-model",
1898                Some("/work/a"),
1899                TokenType::Output,
1900                20,
1901            ),
1902            record(
1903                FocusAccessPath::Unknown,
1904                generated_at,
1905                "shared-model",
1906                Some("/work/a"),
1907                TokenType::CacheRead,
1908                30,
1909            ),
1910        ];
1911        let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
1912
1913        let summary = now_summary(&snapshot, NowOptions::default());
1914
1915        assert_eq!(summary.current_costs.len(), 3);
1916        assert!(summary
1917            .current_costs
1918            .iter()
1919            .any(|summary| summary.lane == CostLane::Api && summary.totals.tokens.input == 10));
1920        assert!(summary.current_costs.iter().any(|summary| {
1921            summary.lane == CostLane::SubscriptionEstimate && summary.totals.tokens.output == 20
1922        }));
1923        assert!(summary.current_costs.iter().any(|summary| {
1924            summary.lane == CostLane::UnknownAccess && summary.totals.tokens.cache_read == 30
1925        }));
1926    }
1927
1928    #[test]
1929    fn engine_totals_tokens_from_unpriced_rows_via_x_consumed_tokens() {
1930        // Unpriced rows null ConsumedQuantity (FOCUS 1.3), so the engine must read
1931        // token totals from x_ConsumedTokens — else unpriced usage would vanish.
1932        let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
1933        let row = record(
1934            FocusAccessPath::Api,
1935            generated_at,
1936            "unpriced-model",
1937            Some("/work/a"),
1938            TokenType::Input,
1939            4_242,
1940        );
1941        assert_eq!(
1942            row.consumed_quantity, None,
1943            "unpriced row nulls ConsumedQuantity"
1944        );
1945        assert_eq!(row.x_consumed_tokens, Decimal::from(4_242));
1946        let snapshot = snapshot_with_rows(generated_at, vec![row], Vec::new());
1947
1948        let summary = now_summary(&snapshot, NowOptions::default());
1949        let total: u64 = summary
1950            .current_costs
1951            .iter()
1952            .map(|summary| summary.totals.tokens.input)
1953            .sum();
1954        assert_eq!(total, 4_242);
1955    }
1956
1957    #[test]
1958    fn now_summary_uses_configurable_cost_period() {
1959        let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
1960        let previous_week = utc_datetime(2026, 1, 2, 12, 0, 0);
1961        let previous_month = utc_datetime(2025, 12, 31, 12, 0, 0);
1962        let rows = vec![
1963            record(
1964                FocusAccessPath::Api,
1965                previous_week,
1966                "old-model",
1967                Some("/work/a"),
1968                TokenType::Input,
1969                10,
1970            ),
1971            record(
1972                FocusAccessPath::Api,
1973                generated_at,
1974                "new-model",
1975                Some("/work/a"),
1976                TokenType::Input,
1977                20,
1978            ),
1979            record(
1980                FocusAccessPath::Api,
1981                previous_month,
1982                "last-month-model",
1983                Some("/work/a"),
1984                TokenType::Input,
1985                30,
1986            ),
1987        ];
1988        let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
1989
1990        let week = now_summary(&snapshot, NowOptions::default());
1991        let month = now_summary(
1992            &snapshot,
1993            NowOptions {
1994                cost_period: Period::Month,
1995                group_by: GroupBy::Model,
1996            },
1997        );
1998
1999        assert_eq!(week.current_costs.len(), 1);
2000        assert_eq!(week.current_costs[0].totals.tokens.input, 20);
2001        assert_eq!(month.current_costs.len(), 2);
2002        assert_eq!(
2003            month
2004                .current_costs
2005                .iter()
2006                .map(|summary| summary.totals.tokens.input)
2007                .sum::<u64>(),
2008            30
2009        );
2010        assert!(!month
2011            .current_costs
2012            .iter()
2013            .any(|summary| summary.group.value == "last-month-model"));
2014    }
2015
2016    #[test]
2017    fn trends_group_by_model_app_and_total() {
2018        let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
2019        let rows = vec![
2020            record(
2021                FocusAccessPath::Api,
2022                generated_at,
2023                "model-a",
2024                Some("/work/a"),
2025                TokenType::Input,
2026                10,
2027            ),
2028            record(
2029                FocusAccessPath::Api,
2030                generated_at,
2031                "model-b",
2032                Some("/work/b"),
2033                TokenType::Input,
2034                20,
2035            ),
2036            record(
2037                FocusAccessPath::Api,
2038                generated_at,
2039                "model-b",
2040                None,
2041                TokenType::Input,
2042                30,
2043            ),
2044        ];
2045        let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
2046
2047        let by_model = trends_summary(
2048            &snapshot,
2049            TrendsOptions {
2050                period: Period::Week,
2051                group_by: GroupBy::Model,
2052            },
2053        );
2054        let by_app = trends_summary(
2055            &snapshot,
2056            TrendsOptions {
2057                period: Period::Week,
2058                group_by: GroupBy::App,
2059            },
2060        );
2061        let total = trends_summary(
2062            &snapshot,
2063            TrendsOptions {
2064                period: Period::Week,
2065                group_by: GroupBy::Total,
2066            },
2067        );
2068
2069        assert_eq!(by_model.totals.len(), 2);
2070        assert!(by_model
2071            .totals
2072            .iter()
2073            .any(|summary| summary.group.value == "model-a"));
2074        assert_eq!(by_app.totals.len(), 3);
2075        assert!(by_app
2076            .totals
2077            .iter()
2078            .any(|summary| summary.group.value == UNKNOWN_GROUP_VALUE));
2079        assert_eq!(total.totals.len(), 1);
2080        assert_eq!(total.totals[0].group.value, TOTAL_GROUP_VALUE);
2081        assert_eq!(total.totals[0].totals.tokens.input, 60);
2082    }
2083
2084    #[test]
2085    fn trends_buckets_by_selected_local_periods() {
2086        let monday_local = local_datetime(2026, 1, 5, 12, 0, 0);
2087        let sunday_local = local_datetime(2026, 1, 11, 12, 0, 0);
2088        let next_monday_local = local_datetime(2026, 1, 12, 12, 0, 0);
2089        let rows = vec![
2090            record(
2091                FocusAccessPath::Api,
2092                monday_local.with_timezone(&Utc),
2093                "model",
2094                Some("/work/a"),
2095                TokenType::Input,
2096                10,
2097            ),
2098            record(
2099                FocusAccessPath::Api,
2100                sunday_local.with_timezone(&Utc),
2101                "model",
2102                Some("/work/a"),
2103                TokenType::Input,
2104                20,
2105            ),
2106            record(
2107                FocusAccessPath::Api,
2108                next_monday_local.with_timezone(&Utc),
2109                "model",
2110                Some("/work/a"),
2111                TokenType::Input,
2112                30,
2113            ),
2114        ];
2115        let snapshot = snapshot_with_rows(monday_local.with_timezone(&Utc), rows, Vec::new());
2116
2117        let week = trends_summary(
2118            &snapshot,
2119            TrendsOptions {
2120                period: Period::Week,
2121                group_by: GroupBy::Total,
2122            },
2123        );
2124
2125        assert_eq!(week.buckets.len(), 2);
2126        assert_eq!(
2127            week.buckets[0].period.start.with_timezone(&Local).weekday(),
2128            Weekday::Mon
2129        );
2130        assert_eq!(
2131            week.buckets[1].period.start.with_timezone(&Local).weekday(),
2132            Weekday::Mon
2133        );
2134        assert_ne!(week.buckets[0].period.start, week.buckets[1].period.start);
2135        assert_eq!(week.buckets[0].totals.tokens.input, 30);
2136        assert_eq!(week.buckets[1].totals.tokens.input, 30);
2137    }
2138
2139    #[test]
2140    fn trends_day_buckets_split_at_local_midnight() {
2141        let before_midnight = local_datetime(2026, 1, 5, 23, 59, 59);
2142        let after_midnight = local_datetime(2026, 1, 6, 0, 0, 0);
2143        let rows = vec![
2144            record(
2145                FocusAccessPath::Api,
2146                before_midnight.with_timezone(&Utc),
2147                "model",
2148                Some("/work/a"),
2149                TokenType::Input,
2150                10,
2151            ),
2152            record(
2153                FocusAccessPath::Api,
2154                after_midnight.with_timezone(&Utc),
2155                "model",
2156                Some("/work/a"),
2157                TokenType::Input,
2158                20,
2159            ),
2160        ];
2161        let snapshot = snapshot_with_rows(after_midnight.with_timezone(&Utc), rows, Vec::new());
2162
2163        let day = trends_summary(
2164            &snapshot,
2165            TrendsOptions {
2166                period: Period::Day,
2167                group_by: GroupBy::Total,
2168            },
2169        );
2170
2171        assert_eq!(day.buckets.len(), 2);
2172        assert_ne!(day.buckets[0].period.start, day.buckets[1].period.start);
2173        assert_eq!(day.buckets[0].totals.row_count, 1);
2174        assert_eq!(day.buckets[0].totals.tokens.input, 10);
2175        assert_eq!(day.buckets[1].totals.row_count, 1);
2176        assert_eq!(day.buckets[1].totals.tokens.input, 20);
2177    }
2178
2179    #[test]
2180    fn trends_month_buckets_split_by_local_month() {
2181        let january = local_datetime(2025, 1, 31, 12, 0, 0);
2182        let february = local_datetime(2025, 2, 1, 12, 0, 0);
2183        let rows = vec![
2184            record(
2185                FocusAccessPath::Api,
2186                january.with_timezone(&Utc),
2187                "model",
2188                Some("/work/a"),
2189                TokenType::Input,
2190                10,
2191            ),
2192            record(
2193                FocusAccessPath::Api,
2194                february.with_timezone(&Utc),
2195                "model",
2196                Some("/work/a"),
2197                TokenType::Input,
2198                20,
2199            ),
2200        ];
2201        let snapshot = snapshot_with_rows(february.with_timezone(&Utc), rows, Vec::new());
2202
2203        let month = trends_summary(
2204            &snapshot,
2205            TrendsOptions {
2206                period: Period::Month,
2207                group_by: GroupBy::Total,
2208            },
2209        );
2210
2211        assert_eq!(month.buckets.len(), 2);
2212        assert_eq!(
2213            month.buckets[0].period.start.with_timezone(&Local).month(),
2214            1
2215        );
2216        assert_eq!(month.buckets[0].totals.row_count, 1);
2217        assert_eq!(month.buckets[0].totals.tokens.input, 10);
2218        assert_eq!(
2219            month.buckets[1].period.start.with_timezone(&Local).month(),
2220            2
2221        );
2222        assert_eq!(month.buckets[1].totals.row_count, 1);
2223        assert_eq!(month.buckets[1].totals.tokens.input, 20);
2224    }
2225
2226    #[test]
2227    fn trends_year_buckets_split_by_local_year() {
2228        let current_year = local_datetime(2025, 12, 31, 12, 0, 0);
2229        let next_year = local_datetime(2026, 1, 1, 12, 0, 0);
2230        let rows = vec![
2231            record(
2232                FocusAccessPath::Api,
2233                current_year.with_timezone(&Utc),
2234                "model",
2235                Some("/work/a"),
2236                TokenType::Input,
2237                10,
2238            ),
2239            record(
2240                FocusAccessPath::Api,
2241                next_year.with_timezone(&Utc),
2242                "model",
2243                Some("/work/a"),
2244                TokenType::Input,
2245                30,
2246            ),
2247        ];
2248        let snapshot = snapshot_with_rows(next_year.with_timezone(&Utc), rows, Vec::new());
2249
2250        let year = trends_summary(
2251            &snapshot,
2252            TrendsOptions {
2253                period: Period::Year,
2254                group_by: GroupBy::Total,
2255            },
2256        );
2257
2258        assert_eq!(year.buckets.len(), 2);
2259        assert_eq!(
2260            year.buckets[0].period.start.with_timezone(&Local).year(),
2261            2025
2262        );
2263        assert_eq!(year.buckets[0].totals.row_count, 1);
2264        assert_eq!(year.buckets[0].totals.tokens.input, 10);
2265        assert_eq!(
2266            year.buckets[1].period.start.with_timezone(&Local).year(),
2267            2026
2268        );
2269        assert_eq!(year.buckets[1].totals.row_count, 1);
2270        assert_eq!(year.buckets[1].totals.tokens.input, 30);
2271    }
2272
2273    #[test]
2274    fn local_period_boundaries_start_at_local_midnight() {
2275        let local = local_datetime(2026, 1, 15, 0, 30, 0);
2276
2277        let day = period_range_for(Period::Day, local.with_timezone(&Utc));
2278        let month = period_range_for(Period::Month, local.with_timezone(&Utc));
2279        let year = period_range_for(Period::Year, local.with_timezone(&Utc));
2280
2281        assert_eq!(day.start.with_timezone(&Local).hour(), 0);
2282        assert_eq!(day.start.with_timezone(&Local).minute(), 0);
2283        assert_eq!(month.start.with_timezone(&Local).day(), 1);
2284        assert_eq!(year.start.with_timezone(&Local).month(), 1);
2285        assert_eq!(year.start.with_timezone(&Local).day(), 1);
2286    }
2287
2288    #[test]
2289    fn placeholder_pricing_reports_missing_price_and_tokens() {
2290        let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
2291        let rows = vec![record(
2292            FocusAccessPath::Api,
2293            generated_at,
2294            "model",
2295            Some("/work/a"),
2296            TokenType::Input,
2297            10,
2298        )];
2299        let snapshot = snapshot_with_rows(generated_at, rows, Vec::new());
2300
2301        let summary = now_summary(&snapshot, NowOptions::default());
2302        let totals = &summary.current_costs[0].totals;
2303
2304        assert_eq!(totals.billed_cost, Decimal::from(0));
2305        assert_eq!(totals.effective_cost, Decimal::from(0));
2306        assert_eq!(totals.tokens.input, 10);
2307        assert_eq!(totals.pricing_coverage.missing_price_rows, 1);
2308        assert_eq!(totals.estimated_rows, 1);
2309    }
2310
2311    #[test]
2312    fn pricing_coverage_tracks_unknown_models_separately() {
2313        let generated_at = utc_datetime(2026, 1, 7, 12, 0, 0);
2314        let mut row = record(
2315            FocusAccessPath::Api,
2316            generated_at,
2317            "model",
2318            Some("/work/a"),
2319            TokenType::Input,
2320            10,
2321        );
2322        row.x_pricing_status = PRICING_STATUS_UNKNOWN_MODEL.to_string();
2323        let snapshot = snapshot_with_rows(generated_at, vec![row], Vec::new());
2324
2325        let summary = now_summary(&snapshot, NowOptions::default());
2326
2327        assert_eq!(
2328            summary.current_costs[0]
2329                .totals
2330                .pricing_coverage
2331                .unknown_model_rows,
2332            1
2333        );
2334    }
2335
2336    #[test]
2337    fn limit_availability_distinguishes_unavailable_available_partial_and_stale() {
2338        let now = utc_datetime(2026, 1, 7, 12, 0, 0);
2339        let limits = vec![
2340            LimitWindow {
2341                tool: ProviderId::ClaudeCode,
2342                plan: None,
2343                kind: LimitKind::FiveHour,
2344                used_fraction: None,
2345                resets_at: None,
2346                label: Some("unavailable".to_string()),
2347            },
2348            LimitWindow {
2349                tool: ProviderId::Codex,
2350                plan: Some("plus".to_string()),
2351                kind: LimitKind::FiveHour,
2352                used_fraction: Some(0.5),
2353                resets_at: Some(now + Duration::minutes(30)),
2354                label: None,
2355            },
2356            LimitWindow {
2357                tool: ProviderId::Codex,
2358                plan: Some("plus".to_string()),
2359                kind: LimitKind::Weekly,
2360                used_fraction: Some(0.6),
2361                resets_at: None,
2362                label: None,
2363            },
2364            LimitWindow {
2365                tool: ProviderId::Codex,
2366                plan: Some("plus".to_string()),
2367                kind: LimitKind::Weekly,
2368                used_fraction: Some(0.7),
2369                resets_at: Some(now - Duration::minutes(5)),
2370                label: None,
2371            },
2372        ];
2373        let snapshot = snapshot_with_rows(now, Vec::new(), limits);
2374
2375        let summary = now_summary(&snapshot, NowOptions::default());
2376
2377        assert!(matches!(
2378            summary.limits[0].availability,
2379            LimitAvailability::Unavailable { .. }
2380        ));
2381        assert!(matches!(
2382            summary.limits[1].availability,
2383            LimitAvailability::Available {
2384                reset_in_seconds: 1800,
2385                ..
2386            }
2387        ));
2388        assert!(matches!(
2389            summary.limits[2].availability,
2390            LimitAvailability::Partial { .. }
2391        ));
2392        match &summary.limits[3].availability {
2393            LimitAvailability::Partial {
2394                reset_in_seconds,
2395                reason,
2396                ..
2397            } => {
2398                assert_eq!(*reset_in_seconds, Some(0));
2399                assert!(reason.contains("stale"));
2400            }
2401            _ => panic!("expired reset should be partial stale data"),
2402        }
2403    }
2404
2405    #[test]
2406    fn snapshot_collection_degrades_provider_errors() {
2407        let env = HostEnv::new(PathBuf::from("/home/example"), Vec::new(), false);
2408        let now = timestamp();
2409        // Cursor plays the "missing" role here: it now has dedicated detect-and-defer
2410        // semantics (any discovered Cursor → `Detected`), so the generic error path is
2411        // exercised with Claude instead. Cursor's discovered path is covered by
2412        // `cursor_detected_status_defers_usage_and_quota`.
2413        let providers: Vec<Box<dyn Provider>> = vec![
2414            Box::new(FakeProvider::failing_usage(ProviderId::ClaudeCode)),
2415            Box::new(FakeProvider::missing(ProviderId::Cursor)),
2416            Box::new(FakeProvider::available(
2417                ProviderId::Codex,
2418                vec![usage_event(
2419                    ProviderId::Codex,
2420                    AccessPath::Subscription,
2421                    now,
2422                )],
2423                vec![LimitWindow {
2424                    tool: ProviderId::Codex,
2425                    plan: Some("plus".to_string()),
2426                    kind: LimitKind::FiveHour,
2427                    used_fraction: Some(0.4),
2428                    resets_at: Some(now + Duration::hours(1)),
2429                    label: None,
2430                }],
2431            )),
2432        ];
2433
2434        let snapshot = match collect_snapshot_from_providers(&env, providers, now) {
2435            Ok(value) => value,
2436            Err(err) => panic!("snapshot should collect with non-fatal provider errors: {err}"),
2437        };
2438
2439        assert_eq!(snapshot.providers.len(), 3);
2440        assert!(snapshot.providers.iter().any(|status| {
2441            status.provider == ProviderId::ClaudeCode
2442                && status.status == ProviderStatusKind::Partial
2443        }));
2444        assert!(snapshot.providers.iter().any(|status| {
2445            status.provider == ProviderId::Cursor && status.status == ProviderStatusKind::Missing
2446        }));
2447        assert!(snapshot.providers.iter().any(|status| {
2448            status.provider == ProviderId::Codex
2449                && status.status == ProviderStatusKind::Available
2450                && status.focus_rows == 3
2451        }));
2452        assert_eq!(snapshot.focus_rows.len(), 3);
2453        assert_eq!(snapshot.limit_windows.len(), 1);
2454    }
2455
2456    #[test]
2457    fn cursor_detected_status_defers_usage_and_quota() {
2458        // The real CursorProvider against the fake `.cursor` fixture tree: the install
2459        // is detected with its selected model + logged-in flag, but usage and quota are
2460        // deferred to Phase 2 (zero events, zero limits, zero FOCUS rows).
2461        let env = HostEnv::new(fixture_path(&["cursor", "home"]), Vec::new(), false);
2462        let providers: Vec<Box<dyn Provider>> = vec![Box::new(CursorProvider)];
2463
2464        let snapshot = match collect_snapshot_from_providers(&env, providers, timestamp()) {
2465            Ok(value) => value,
2466            Err(err) => panic!("cursor detect-and-defer should collect: {err}"),
2467        };
2468
2469        assert_eq!(snapshot.providers.len(), 1);
2470        let status = &snapshot.providers[0];
2471        assert_eq!(status.provider, ProviderId::Cursor);
2472        assert_eq!(status.status, ProviderStatusKind::Detected);
2473        assert_eq!(status.usage_events, 0);
2474        assert_eq!(status.focus_rows, 0);
2475        assert_eq!(status.limit_windows, 0);
2476
2477        let message = status.message.as_deref().unwrap_or_default();
2478        for needle in [
2479            "BETA",
2480            "composer-2.5",
2481            "Composer 2.5 Fast",
2482            "logged in",
2483            "live (Phase 2)",
2484        ] {
2485            assert!(
2486                message.contains(needle),
2487                "detected message {message:?} should contain {needle:?}"
2488            );
2489        }
2490
2491        assert!(snapshot.focus_rows.is_empty());
2492        assert!(snapshot.limit_windows.is_empty());
2493        // Cursor contributes no dollars: there is nothing for cost math to touch.
2494        assert!(snapshot.usage_events.is_empty());
2495    }
2496
2497    fn local_datetime(
2498        year: i32,
2499        month: u32,
2500        day: u32,
2501        hour: u32,
2502        minute: u32,
2503        second: u32,
2504    ) -> DateTime<Local> {
2505        match Local.with_ymd_and_hms(year, month, day, hour, minute, second) {
2506            LocalResult::Single(value) => value,
2507            LocalResult::Ambiguous(first, _) => first,
2508            LocalResult::None => {
2509                panic!("test local timestamp should be valid in the host timezone")
2510            }
2511        }
2512    }
2513
2514    struct FakeProvider {
2515        provider: ProviderId,
2516        discoverable: bool,
2517        usage_error: bool,
2518        usage: Vec<UsageEvent>,
2519        limits: Vec<LimitWindow>,
2520    }
2521
2522    impl FakeProvider {
2523        fn missing(provider: ProviderId) -> Self {
2524            Self {
2525                provider,
2526                discoverable: false,
2527                usage_error: false,
2528                usage: Vec::new(),
2529                limits: Vec::new(),
2530            }
2531        }
2532
2533        fn failing_usage(provider: ProviderId) -> Self {
2534            Self {
2535                provider,
2536                discoverable: true,
2537                usage_error: true,
2538                usage: Vec::new(),
2539                limits: Vec::new(),
2540            }
2541        }
2542
2543        fn available(
2544            provider: ProviderId,
2545            usage: Vec<UsageEvent>,
2546            limits: Vec<LimitWindow>,
2547        ) -> Self {
2548            Self {
2549                provider,
2550                discoverable: true,
2551                usage_error: false,
2552                usage,
2553                limits,
2554            }
2555        }
2556    }
2557
2558    impl Provider for FakeProvider {
2559        fn id(&self) -> ProviderId {
2560            self.provider
2561        }
2562
2563        fn discover(&self, _env: &HostEnv) -> Result<Option<DataLocation>, ProviderError> {
2564            if self.discoverable {
2565                Ok(Some(DataLocation {
2566                    provider: self.provider,
2567                    root: PathBuf::from("/fake"),
2568                    files: vec![PathBuf::from("/fake/data.jsonl")],
2569                }))
2570            } else {
2571                Ok(None)
2572            }
2573        }
2574
2575        fn parse_usage(&self, _loc: &DataLocation) -> Result<Vec<UsageEvent>, ProviderError> {
2576            if self.usage_error {
2577                Err(ProviderError::DataUnavailable {
2578                    provider: self.provider,
2579                    message: "synthetic usage failure".to_string(),
2580                })
2581            } else {
2582                Ok(self.usage.clone())
2583            }
2584        }
2585
2586        fn parse_limits(&self, _loc: &DataLocation) -> Result<Vec<LimitWindow>, ProviderError> {
2587            Ok(self.limits.clone())
2588        }
2589    }
2590}