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