Skip to main content

vct_core/usage/
aggregator.rs

1//! Aggregates per-model token usage across the file-backed provider session trees.
2//!
3//! Each provider directory is walked with the provider fixed by its *source
4//! path* (never re-detected from file contents), parsed in
5//! [`ParseMode::UsageOnly`] to skip the heavy file-operation payloads, and the
6//! small per-model usage maps are merged into a [`UsageData`]. The provider is
7//! tracked twice on purpose — once merged across providers (the per-model
8//! table) and once kept per source directory (the per-provider footer) — see
9//! [`UsageData`] for why.
10
11use crate::config::ProvidersConfig;
12use crate::constants::{FastHashMap, FastHashSet, capacity};
13use crate::models::TimeRange;
14use crate::models::{
15    CodeAnalysis, ExtensionType, PerProviderUsage, Provider, ProviderActiveDays, UsageResult,
16};
17use crate::pricing::TierThresholds;
18use crate::session::cursor::{
19    discover_cursor_store_dbs, load_conversation_model_snapshot, read_cursor_usage_store,
20};
21use crate::session::diagnostics::DatabaseUsageRead;
22use crate::session::hermes::read_hermes_usage_contributions;
23use crate::session::opencode::read_opencode_usage_contributions;
24use crate::session::sqlite::is_cacheable_sqlite_failure;
25use crate::session::{
26    ParseMode, parse_session_file_typed_as, read_cursor_usage, read_hermes_usage,
27    read_opencode_usage,
28};
29use crate::summary_cache::{
30    CompactSourceSummary, SourceFingerprint, SummaryCacheKey, SummaryKind, SummaryScanCache,
31};
32use crate::utils::{
33    COPILOT_SESSION_MAX_DEPTH, GROK_SESSION_MAX_DEPTH, HelperPaths, collect_files_with_max_depth,
34    is_claude_session_file, is_codex_session_file, is_copilot_session_file, is_gemini_session_file,
35    is_grok_session_file, merge_usage_values, resolve_paths,
36};
37use anyhow::Result;
38use rayon::prelude::*;
39use serde::Serialize;
40use serde_json::Value;
41use std::collections::HashSet;
42use std::path::Path;
43use std::sync::Arc;
44
45/// Aggregated token usage plus the per-provider active-day counts.
46///
47/// Built only by [`aggregate_usage_from_home`]; all fields are public for the
48/// display layer to read. Token totals are tracked two ways at once because the
49/// two views need different attribution: [`models`](UsageData::models) merges a
50/// shared model (e.g. `claude-sonnet-4-6` emitted by both Claude Code and
51/// Copilot CLI) into one row, while [`per_provider`](UsageData::per_provider)
52/// keeps the same tokens scoped to the source directory so the footer can
53/// attribute them correctly. The shared tokens are merged, not summed, so they
54/// are never double-counted across the two maps.
55///
56/// # Examples
57///
58/// ```no_run
59/// use vct_core::{aggregate_usage_from_home, TimeRange};
60///
61/// let data = aggregate_usage_from_home(TimeRange::All)?;
62/// // Total distinct days that contributed any usage, across all providers.
63/// println!("active days: {}", data.provider_days.total);
64/// # Ok::<(), anyhow::Error>(())
65/// ```
66#[derive(Debug, Clone, Serialize)]
67pub struct UsageData {
68    /// Tokens aggregated across *all* providers, keyed by model name.
69    ///
70    /// Drives the per-model summary table where, e.g., `claude-sonnet-4-6`
71    /// tokens from Claude Code and Copilot CLI share a single row.
72    pub models: UsageResult,
73    /// Tokens kept separate per source directory, keyed by provider → model.
74    ///
75    /// Drives the per-provider totals in the summary footer. Keeping this
76    /// split at aggregation time avoids the display layer from having to
77    /// guess a model's provider from its name, which broke once Copilot CLI
78    /// started emitting real (Claude / OpenAI / …) model names.
79    pub per_provider: PerProviderUsage,
80    /// Count of distinct calendar dates that contributed usage, per provider
81    /// and overall.
82    pub provider_days: ProviderActiveDays,
83    /// Provider-authoritative per-model cost (USD), summed from the source.
84    pub stored_costs: StoredCosts,
85}
86
87// Usage and analysis both report the one unified scan-diagnostics type; it is
88// re-exported here so callers can reach it as `usage::ScanDiagnostics`.
89pub use crate::scan::{ScanDiagnostics, ScanFailure};
90
91/// Usage data paired with source-collection diagnostics.
92pub struct UsageCollection {
93    /// Successfully collected usage.
94    pub data: UsageData,
95    /// Candidate, success, and failure counts from the scan.
96    pub diagnostics: ScanDiagnostics,
97}
98
99/// Provider-authoritative per-model costs, kept **separate per provider**.
100///
101/// OpenCode and Hermes record their own costs. The Cursor map is retained for
102/// source compatibility, but the local Cursor estimate now carries zero stored
103/// cost and is priced by an exact LiteLLM match in the display layer. Separate
104/// maps prevent a colliding bare model name from cross-contaminating providers.
105#[derive(Debug, Default, Clone, Serialize)]
106pub struct StoredCosts {
107    /// OpenCode's per-model stored cost, keyed by model name.
108    pub opencode: FastHashMap<String, f64>,
109    /// Cursor's per-model dashboard cost, keyed by model name.
110    pub cursor: FastHashMap<String, f64>,
111    /// Hermes's per-model stored cost, keyed by model name.
112    pub hermes: FastHashMap<String, f64>,
113}
114
115/// Extracts token usage data from a typed `CodeAnalysis`.
116///
117/// Reads directly from the typed `conversation_usage` map instead of walking
118/// `Value` via `.get(...)`, so no intermediate `serde_json::Value` tree is
119/// built or retained here.
120fn extract_conversation_usage_from_analysis(analysis: CodeAnalysis) -> FastHashMap<String, Value> {
121    let mut conversation_usage = FastHashMap::with_capacity(capacity::MODELS_PER_SESSION);
122
123    let mut merge_into = |model: String, usage: Value| {
124        conversation_usage
125            .entry(model)
126            .and_modify(|existing_usage| merge_usage_values(existing_usage, &usage))
127            .or_insert(usage);
128    };
129
130    for record in analysis.records {
131        for (model, usage) in record.conversation_usage {
132            merge_into(model, usage);
133        }
134        // Claude advisor-message tokens live in a separate map so the
135        // `analysis` aggregator ignores them; the `usage` path folds them in
136        // here, attributed to the advisor's own model for correct pricing.
137        for (model, usage) in record.advisor_usage {
138            merge_into(model, usage);
139        }
140    }
141
142    conversation_usage
143}
144
145/// Aggregates token usage from all AI provider session directories.
146///
147/// Scans the file-backed provider session trees resolved by [`resolve_paths`],
148/// filtered by `time_range`, and rolls every session's
149/// per-model usage into a [`UsageData`]. Missing provider directories are
150/// skipped silently, and a source file or OpenCode database that fails to parse
151/// logs a warning to stderr and is excluded rather than aborting the whole scan.
152///
153/// # Errors
154///
155/// Returns an error if [`resolve_paths`] cannot determine the provider
156/// directories (e.g. the home directory is unavailable). Directory traversal
157/// and metadata errors are currently skipped by the walker rather than
158/// propagated.
159///
160/// # Examples
161///
162/// ```no_run
163/// use vct_core::{aggregate_usage_from_home, TimeRange};
164///
165/// let data = aggregate_usage_from_home(TimeRange::All)?;
166/// for model in data.models.keys() {
167///     println!("{model}");
168/// }
169/// # Ok::<(), anyhow::Error>(())
170/// ```
171pub fn aggregate_usage_from_home(time_range: TimeRange) -> Result<UsageData> {
172    aggregate_usage_from_home_with_providers(time_range, ProvidersConfig::default())
173}
174
175/// [`aggregate_usage_from_home`] with explicit per-provider toggles (from
176/// `~/.vct/config.toml`). A disabled provider is skipped entirely.
177pub fn aggregate_usage_from_home_with_providers(
178    time_range: TimeRange,
179    providers: ProvidersConfig,
180) -> Result<UsageData> {
181    aggregate_usage_from_paths_with_providers(&resolve_paths()?, time_range, providers)
182}
183
184/// Aggregates token usage from provider session directories rooted at an
185/// explicit [`HelperPaths`].
186///
187/// The env-free, injectable counterpart of [`aggregate_usage_from_home`]:
188/// every provider path comes from `paths` rather than the resolved home
189/// directory, so tests can point them at a temp tree and exercise the real
190/// aggregation without mutating process-global `HOME`. See
191/// [`aggregate_usage_from_home`] for the aggregation semantics.
192///
193/// # Errors
194///
195/// Returns an error only under the same conditions as
196/// [`aggregate_usage_from_home`].
197pub fn aggregate_usage_from_paths(paths: &HelperPaths, time_range: TimeRange) -> Result<UsageData> {
198    aggregate_usage_from_paths_with_providers(paths, time_range, ProvidersConfig::default())
199}
200
201/// [`aggregate_usage_from_paths`] with explicit provider toggles (the injectable core
202/// used by the CLI once `config.toml` is loaded).
203pub fn aggregate_usage_from_paths_with_providers(
204    paths: &HelperPaths,
205    time_range: TimeRange,
206    providers: ProvidersConfig,
207) -> Result<UsageData> {
208    let mut result = FastHashMap::with_capacity(capacity::MODEL_COMBINATIONS);
209    let mut per_provider = PerProviderUsage::default();
210    let mut stored_costs = StoredCosts::default();
211
212    let mut claude_dates: HashSet<String> = HashSet::new();
213    let mut codex_dates: HashSet<String> = HashSet::new();
214    let mut copilot_dates: HashSet<String> = HashSet::new();
215    let mut gemini_dates: HashSet<String> = HashSet::new();
216    let mut grok_dates: HashSet<String> = HashSet::new();
217    let mut opencode_dates: HashSet<String> = HashSet::new();
218    let mut cursor_dates: HashSet<String> = HashSet::new();
219    let mut hermes_dates: HashSet<String> = HashSet::new();
220
221    if providers.claude && paths.claude_session_dir.exists() {
222        // Walks the projects tree recursively, so top-level `<session>.jsonl` logs
223        // and `<session>/subagents/agent-*.jsonl` logs are both collected here.
224        process_usage_directory(
225            &paths.claude_session_dir,
226            ExtensionType::ClaudeCode,
227            &mut result,
228            &mut per_provider.claude,
229            &mut claude_dates,
230            is_claude_session_file,
231            time_range,
232            None,
233        )?;
234    }
235
236    if providers.codex && paths.codex_session_dir.exists() {
237        process_usage_directory(
238            &paths.codex_session_dir,
239            ExtensionType::Codex,
240            &mut result,
241            &mut per_provider.codex,
242            &mut codex_dates,
243            is_codex_session_file,
244            time_range,
245            None,
246        )?;
247    }
248
249    if providers.copilot && paths.copilot_session_dir.exists() {
250        // `events.jsonl` always lives exactly two levels under
251        // `session-state/`. Bounding the walk here keeps per-session
252        // snapshot subtrees (`rewind-snapshots/backups/*`, `files/*`, …)
253        // out of the `WalkDir` iteration entirely, so the scan cost stays
254        // linear in the number of sessions rather than total artifacts.
255        process_usage_directory(
256            &paths.copilot_session_dir,
257            ExtensionType::Copilot,
258            &mut result,
259            &mut per_provider.copilot,
260            &mut copilot_dates,
261            is_copilot_session_file,
262            time_range,
263            Some(COPILOT_SESSION_MAX_DEPTH),
264        )?;
265    }
266
267    if providers.gemini && paths.gemini_session_dir.exists() {
268        process_usage_directory(
269            &paths.gemini_session_dir,
270            ExtensionType::Gemini,
271            &mut result,
272            &mut per_provider.gemini,
273            &mut gemini_dates,
274            is_gemini_session_file,
275            time_range,
276            None,
277        )?;
278    }
279
280    if providers.grok && paths.grok_session_dir.exists() {
281        process_usage_directory(
282            &paths.grok_session_dir,
283            ExtensionType::Grok,
284            &mut result,
285            &mut per_provider.grok,
286            &mut grok_dates,
287            is_grok_session_file,
288            time_range,
289            Some(GROK_SESSION_MAX_DEPTH),
290        )?;
291    }
292
293    // OpenCode lives in a single SQLite database rather than a session
294    // directory, so it is read directly instead of walked.
295    if providers.opencode
296        && paths.opencode_db.exists()
297        && let Err(err) = process_opencode_usage(
298            &paths.opencode_db,
299            &mut result,
300            &mut per_provider.opencode,
301            &mut stored_costs.opencode,
302            &mut opencode_dates,
303            time_range,
304        )
305    {
306        log::warn!(
307            "failed to read OpenCode DB {}: {err}",
308            paths.opencode_db.display()
309        );
310    }
311
312    // Cursor's usage is a local estimate from its chat stores (read directly like
313    // OpenCode, not a walked session directory), so it is only attempted when the
314    // chat stores are present — matching the analysis path.
315    if providers.cursor
316        && paths.cursor_chats_dir.exists()
317        && let Err(err) = process_cursor_usage(
318            &paths.cursor_chats_dir,
319            &paths.cursor_tracking_db,
320            &mut result,
321            &mut per_provider.cursor,
322            &mut stored_costs.cursor,
323            &mut cursor_dates,
324            time_range,
325        )
326    {
327        log::warn!("failed to read Cursor usage: {err}");
328    }
329
330    // Hermes, like OpenCode, is a single SQLite database read directly.
331    if providers.hermes
332        && paths.hermes_db.exists()
333        && let Err(err) = process_hermes_usage(
334            &paths.hermes_db,
335            &mut result,
336            &mut per_provider.hermes,
337            &mut stored_costs.hermes,
338            &mut hermes_dates,
339            time_range,
340        )
341    {
342        log::warn!(
343            "failed to read Hermes DB {}: {err}",
344            paths.hermes_db.display()
345        );
346    }
347
348    let mut all_dates: HashSet<&String> = HashSet::new();
349    all_dates.extend(claude_dates.iter());
350    all_dates.extend(codex_dates.iter());
351    all_dates.extend(copilot_dates.iter());
352    all_dates.extend(gemini_dates.iter());
353    all_dates.extend(grok_dates.iter());
354    all_dates.extend(opencode_dates.iter());
355    all_dates.extend(cursor_dates.iter());
356    all_dates.extend(hermes_dates.iter());
357
358    let provider_days = ProviderActiveDays {
359        claude: claude_dates.len(),
360        codex: codex_dates.len(),
361        copilot: copilot_dates.len(),
362        gemini: gemini_dates.len(),
363        grok: grok_dates.len(),
364        opencode: opencode_dates.len(),
365        cursor: cursor_dates.len(),
366        hermes: hermes_dates.len(),
367        total: all_dates.len(),
368    };
369
370    Ok(UsageData {
371        models: result,
372        per_provider,
373        provider_days,
374        stored_costs,
375    })
376}
377
378/// Optional knobs for a usage scan.
379///
380/// `tiers` is the per-request context-tier snapshot derived from the current
381/// pricing map (see [`TierThresholds`]); `None` (the default) classifies
382/// nothing and every request bills at base rates.
383#[derive(Debug, Default, Clone)]
384pub struct UsageScanOptions {
385    /// "Model → lowest tier threshold" snapshot for per-request classification.
386    pub tiers: Option<Arc<TierThresholds>>,
387}
388
389/// Diagnostics-aware usage scan rooted at the current user's provider paths.
390pub fn aggregate_usage_from_home_with_diagnostics(
391    time_range: TimeRange,
392    providers: ProvidersConfig,
393) -> Result<UsageCollection> {
394    aggregate_usage_from_home_with_diagnostics_opts(
395        time_range,
396        providers,
397        &UsageScanOptions::default(),
398    )
399}
400
401/// [`aggregate_usage_from_home_with_diagnostics`] with scan options.
402pub fn aggregate_usage_from_home_with_diagnostics_opts(
403    time_range: TimeRange,
404    providers: ProvidersConfig,
405    options: &UsageScanOptions,
406) -> Result<UsageCollection> {
407    let mut cache = SummaryScanCache::new();
408    aggregate_usage_from_paths_with_cache_opts(
409        &resolve_paths()?,
410        time_range,
411        providers,
412        &mut cache,
413        options,
414    )
415}
416
417/// Diagnostics-aware usage scan rooted at explicit provider paths.
418pub fn aggregate_usage_from_paths_with_diagnostics(
419    paths: &HelperPaths,
420    time_range: TimeRange,
421    providers: ProvidersConfig,
422) -> Result<UsageCollection> {
423    let mut cache = SummaryScanCache::new();
424    aggregate_usage_from_paths_with_cache(paths, time_range, providers, &mut cache)
425}
426
427/// Incremental usage scan backed by a process-local compact summary cache.
428///
429/// Reusing `cache` across calls reparses only sources whose fingerprint
430/// changed. Cached schema failures retain their diagnostics, while metadata,
431/// open, and read errors are not inserted and are retried next time.
432pub fn aggregate_usage_from_paths_with_cache(
433    paths: &HelperPaths,
434    time_range: TimeRange,
435    providers: ProvidersConfig,
436    cache: &mut SummaryScanCache,
437) -> Result<UsageCollection> {
438    aggregate_usage_from_paths_with_cache_opts(
439        paths,
440        time_range,
441        providers,
442        cache,
443        &UsageScanOptions::default(),
444    )
445}
446
447/// [`aggregate_usage_from_paths_with_cache`] with scan options.
448pub fn aggregate_usage_from_paths_with_cache_opts(
449    paths: &HelperPaths,
450    time_range: TimeRange,
451    providers: ProvidersConfig,
452    cache: &mut SummaryScanCache,
453    options: &UsageScanOptions,
454) -> Result<UsageCollection> {
455    aggregate_usage_from_paths_with_cache_inner(paths, time_range, providers, cache, options)
456}
457
458fn aggregate_usage_from_paths_with_cache_inner(
459    paths: &HelperPaths,
460    time_range: TimeRange,
461    providers: ProvidersConfig,
462    cache: &mut SummaryScanCache,
463    options: &UsageScanOptions,
464) -> Result<UsageCollection> {
465    // Cached summaries embed the tier classification, so a changed threshold
466    // snapshot (daily pricing reload) invalidates every cached entry.
467    let tiers = options.tiers.as_deref();
468    cache.ensure_tier_fingerprint(tiers.map_or(0, TierThresholds::fingerprint));
469    cache.begin_scan();
470    let mut accumulator = UsageAccumulator::default();
471    let mut diagnostics = ScanDiagnostics::default();
472    let mut seen = FastHashSet::default();
473
474    crate::scan::scan_all_cached_files(
475        paths,
476        providers,
477        time_range,
478        cache,
479        &mut seen,
480        &mut accumulator,
481        &mut diagnostics,
482        tiers,
483    )?;
484
485    if providers.opencode && paths.opencode_db.exists() {
486        scan_usage_database(
487            ExtensionType::OpenCode,
488            &paths.opencode_db,
489            SourceFingerprint::sqlite(&paths.opencode_db, &[]),
490            time_range,
491            cache,
492            &mut seen,
493            &mut accumulator,
494            &mut diagnostics,
495            || read_opencode_usage_contributions(&paths.opencode_db, time_range),
496        );
497    }
498    if providers.cursor && paths.cursor_chats_dir.exists() {
499        scan_cursor_usage_database(
500            &paths.cursor_chats_dir,
501            &paths.cursor_tracking_db,
502            time_range,
503            cache,
504            &mut seen,
505            &mut accumulator,
506            &mut diagnostics,
507        );
508    }
509    if providers.hermes && paths.hermes_db.exists() {
510        scan_usage_database(
511            ExtensionType::Hermes,
512            &paths.hermes_db,
513            SourceFingerprint::sqlite(&paths.hermes_db, &[]),
514            time_range,
515            cache,
516            &mut seen,
517            &mut accumulator,
518            &mut diagnostics,
519            || read_hermes_usage_contributions(&paths.hermes_db, time_range),
520        );
521    }
522
523    cache.retain_kinds(&seen, &[SummaryKind::File, SummaryKind::UsageDatabase]);
524    diagnostics.finalize();
525    Ok(UsageCollection {
526        data: accumulator.finish(),
527        diagnostics,
528    })
529}
530
531#[allow(clippy::too_many_arguments)]
532fn scan_usage_database<F>(
533    provider: ExtensionType,
534    source: &Path,
535    fingerprint: Result<SourceFingerprint>,
536    time_range: TimeRange,
537    cache: &mut SummaryScanCache,
538    seen: &mut FastHashSet<SummaryCacheKey>,
539    accumulator: &mut UsageAccumulator,
540    diagnostics: &mut ScanDiagnostics,
541    loader: F,
542) where
543    F: FnOnce() -> Result<DatabaseUsageRead>,
544{
545    diagnostics.candidates += 1;
546    let key = SummaryCacheKey::new(SummaryKind::UsageDatabase, provider, source, time_range);
547    seen.insert(key.clone());
548    let fingerprint = match fingerprint {
549        Ok(value) => value,
550        Err(error) => {
551            diagnostics.failures.push(ScanFailure {
552                provider,
553                source: source.to_path_buf(),
554                error: error.to_string(),
555            });
556            return;
557        }
558    };
559    if let Some(cached) = cache.get(&key, &fingerprint) {
560        crate::scan::fold_cached(provider, source, cached, accumulator, diagnostics);
561        return;
562    }
563
564    cache.record_parse();
565    match loader() {
566        Ok(read) => {
567            let complete_failure = read.expected_records > 0 && read.parsed_records == 0;
568            let failed = read.failed_records();
569            let mut summary = CompactSourceSummary::default();
570            for contribution in read.rows {
571                summary.add_usage_contribution(contribution);
572            }
573            let loaded = crate::scan::LoadedCompactSummary {
574                summary,
575                parsed: !complete_failure,
576                failure: if complete_failure {
577                    Some(format!(
578                        "none of {} usage records used a supported schema",
579                        read.expected_records
580                    ))
581                } else if failed > 0 {
582                    Some(format!("{failed} usage records used an unsupported schema"))
583                } else {
584                    None
585                },
586            };
587            crate::scan::fold_loaded(provider, source, &loaded, accumulator, diagnostics);
588            cache.insert(
589                key,
590                fingerprint,
591                loaded.summary,
592                loaded.parsed,
593                loaded.failure,
594            );
595        }
596        Err(error) => {
597            let failure = error.to_string();
598            diagnostics.failures.push(ScanFailure {
599                provider,
600                source: source.to_path_buf(),
601                error: failure.clone(),
602            });
603            if is_cacheable_sqlite_failure(&error) {
604                cache.insert(
605                    key,
606                    fingerprint,
607                    CompactSourceSummary::default(),
608                    false,
609                    Some(failure),
610                );
611            }
612        }
613    }
614}
615
616fn scan_cursor_usage_database(
617    chats_dir: &Path,
618    tracking_db: &Path,
619    time_range: TimeRange,
620    cache: &mut SummaryScanCache,
621    seen: &mut FastHashSet<SummaryCacheKey>,
622    accumulator: &mut UsageAccumulator,
623    diagnostics: &mut ScanDiagnostics,
624) {
625    let provider = ExtensionType::Cursor;
626    let discovery = discover_cursor_store_dbs(chats_dir);
627    if !discovery.failures.is_empty() {
628        cache.preserve_provider_keys(seen, SummaryKind::UsageDatabase, provider);
629    }
630    for failure in discovery.failures {
631        diagnostics.candidates += 1;
632        diagnostics.failures.push(ScanFailure {
633            provider,
634            source: failure.path,
635            error: failure.error,
636        });
637    }
638
639    let (conv_models, tracking_fingerprint, tracking_ok) =
640        match load_conversation_model_snapshot(tracking_db) {
641            Ok(snapshot) => (snapshot.models, snapshot.fingerprint, true),
642            Err(error) => {
643                diagnostics.failures.push(ScanFailure {
644                    provider,
645                    source: tracking_db.to_path_buf(),
646                    error: error.to_string(),
647                });
648                (FastHashMap::default(), None, false)
649            }
650        };
651
652    for store in discovery.stores {
653        diagnostics.candidates += 1;
654        let key = SummaryCacheKey::new(SummaryKind::UsageDatabase, provider, &store, time_range);
655        seen.insert(key.clone());
656        let fingerprint = if tracking_ok {
657            SourceFingerprint::sqlite_with_dependency(
658                &store,
659                tracking_db,
660                tracking_fingerprint.as_ref(),
661            )
662        } else {
663            SourceFingerprint::sqlite(&store, &[])
664        };
665        let fingerprint = match fingerprint {
666            Ok(fingerprint) => fingerprint,
667            Err(error) => {
668                diagnostics.failures.push(ScanFailure {
669                    provider,
670                    source: store,
671                    error: error.to_string(),
672                });
673                continue;
674            }
675        };
676        if tracking_ok && let Some(cached) = cache.get(&key, &fingerprint) {
677            crate::scan::fold_cached(provider, &store, cached, accumulator, diagnostics);
678            continue;
679        }
680
681        cache.record_parse();
682        match read_cursor_usage_store(&store, &conv_models, time_range) {
683            Ok(read) => {
684                let complete_failure = read.expected_records > 0 && read.parsed_records == 0;
685                let failed = read.failed_records();
686                let mut summary = CompactSourceSummary::default();
687                for contribution in read.rows {
688                    summary.add_usage_contribution(contribution);
689                }
690                let loaded = crate::scan::LoadedCompactSummary {
691                    summary,
692                    parsed: !complete_failure,
693                    failure: if complete_failure {
694                        Some(format!(
695                            "none of {} Cursor usage payloads used a supported schema",
696                            read.expected_records
697                        ))
698                    } else if failed > 0 {
699                        Some(format!(
700                            "{failed} Cursor usage payloads used an unsupported schema"
701                        ))
702                    } else {
703                        None
704                    },
705                };
706                crate::scan::fold_loaded(provider, &store, &loaded, accumulator, diagnostics);
707                if tracking_ok {
708                    cache.insert(
709                        key,
710                        fingerprint,
711                        loaded.summary,
712                        loaded.parsed,
713                        loaded.failure,
714                    );
715                }
716            }
717            Err(error) => {
718                let failure = error.to_string();
719                diagnostics.failures.push(ScanFailure {
720                    provider,
721                    source: store.clone(),
722                    error: failure.clone(),
723                });
724                if tracking_ok && is_cacheable_sqlite_failure(&error) {
725                    cache.insert(
726                        key,
727                        fingerprint,
728                        CompactSourceSummary::default(),
729                        false,
730                        Some(failure),
731                    );
732                }
733            }
734        }
735    }
736}
737
738#[derive(Default)]
739struct UsageAccumulator {
740    models: UsageResult,
741    per_provider: PerProviderUsage,
742    stored_costs: StoredCosts,
743    claude_dates: HashSet<String>,
744    codex_dates: HashSet<String>,
745    copilot_dates: HashSet<String>,
746    gemini_dates: HashSet<String>,
747    grok_dates: HashSet<String>,
748    opencode_dates: HashSet<String>,
749    cursor_dates: HashSet<String>,
750    hermes_dates: HashSet<String>,
751}
752
753impl crate::scan::CompactSink for UsageAccumulator {
754    fn fold(&mut self, provider: ExtensionType, summary: &CompactSourceSummary) {
755        self.add(provider, summary);
756    }
757}
758
759impl UsageAccumulator {
760    fn add(&mut self, provider: ExtensionType, summary: &CompactSourceSummary) {
761        let provider_result = match provider {
762            ExtensionType::ClaudeCode => &mut self.per_provider.claude,
763            ExtensionType::Codex => &mut self.per_provider.codex,
764            ExtensionType::Copilot => &mut self.per_provider.copilot,
765            ExtensionType::Gemini => &mut self.per_provider.gemini,
766            ExtensionType::Grok => &mut self.per_provider.grok,
767            ExtensionType::OpenCode => &mut self.per_provider.opencode,
768            ExtensionType::Cursor => &mut self.per_provider.cursor,
769            ExtensionType::Hermes => &mut self.per_provider.hermes,
770        };
771        // Clone the model key only on a miss (an insert genuinely needs an owned
772        // key); a merge into an existing row needs no allocation at all.
773        for (model, usage) in &summary.usage {
774            match provider_result.get_mut(model) {
775                Some(existing) => merge_usage_values(existing, usage),
776                None => {
777                    provider_result.insert(model.clone(), usage.clone());
778                }
779            }
780            match self.models.get_mut(model) {
781                Some(existing) => merge_usage_values(existing, usage),
782                None => {
783                    self.models.insert(model.clone(), usage.clone());
784                }
785            }
786        }
787        for (model, tokens) in &summary.database_usage {
788            let usage = tokens.into_value();
789            match provider_result.get_mut(model) {
790                Some(existing) => merge_usage_values(existing, &usage),
791                None => {
792                    provider_result.insert(model.clone(), usage.clone());
793                }
794            }
795            match self.models.get_mut(model) {
796                Some(existing) => merge_usage_values(existing, &usage),
797                None => {
798                    // Last use of `usage`, so move it in rather than clone.
799                    self.models.insert(model.clone(), usage);
800                }
801            }
802        }
803
804        let stored = match provider {
805            ExtensionType::OpenCode => Some(&mut self.stored_costs.opencode),
806            ExtensionType::Cursor => Some(&mut self.stored_costs.cursor),
807            ExtensionType::Hermes => Some(&mut self.stored_costs.hermes),
808            _ => None,
809        };
810        if let Some(stored) = stored {
811            for (model, cost) in &summary.stored_costs {
812                *stored.entry(model.clone()).or_insert(0.0) += cost;
813            }
814        }
815
816        let dates = match provider {
817            ExtensionType::ClaudeCode => &mut self.claude_dates,
818            ExtensionType::Codex => &mut self.codex_dates,
819            ExtensionType::Copilot => &mut self.copilot_dates,
820            ExtensionType::Gemini => &mut self.gemini_dates,
821            ExtensionType::Grok => &mut self.grok_dates,
822            ExtensionType::OpenCode => &mut self.opencode_dates,
823            ExtensionType::Cursor => &mut self.cursor_dates,
824            ExtensionType::Hermes => &mut self.hermes_dates,
825        };
826        dates.extend(summary.usage_dates.iter().cloned());
827    }
828
829    fn finish(self) -> UsageData {
830        // Only the union's cardinality is needed, so union references rather
831        // than cloning every date string across the eight per-provider sets.
832        let mut all_dates: HashSet<&String> = HashSet::new();
833        all_dates.extend(self.claude_dates.iter());
834        all_dates.extend(self.codex_dates.iter());
835        all_dates.extend(self.copilot_dates.iter());
836        all_dates.extend(self.gemini_dates.iter());
837        all_dates.extend(self.grok_dates.iter());
838        all_dates.extend(self.opencode_dates.iter());
839        all_dates.extend(self.cursor_dates.iter());
840        all_dates.extend(self.hermes_dates.iter());
841        let total_days = all_dates.len();
842        UsageData {
843            models: self.models,
844            per_provider: self.per_provider,
845            provider_days: ProviderActiveDays {
846                claude: self.claude_dates.len(),
847                codex: self.codex_dates.len(),
848                copilot: self.copilot_dates.len(),
849                gemini: self.gemini_dates.len(),
850                grok: self.grok_dates.len(),
851                opencode: self.opencode_dates.len(),
852                cursor: self.cursor_dates.len(),
853                hermes: self.hermes_dates.len(),
854                total: total_days,
855            },
856            stored_costs: self.stored_costs,
857        }
858    }
859}
860
861/// Walks one provider directory and merges its usage into both result maps.
862///
863/// Files matching `filter_fn` (and within `max_depth`, when set) are parsed in
864/// parallel with the provider fixed to `provider` — never re-detected from
865/// contents — and each session's per-model tokens are merged into both
866/// `global_result` (cross-provider view) and `provider_result` (source-scoped
867/// view). Every contributing session's modified date is inserted into
868/// `unique_dates` for the active-day count. A file that fails to parse logs a
869/// warning and is skipped.
870///
871/// # Errors
872///
873/// Returns an error only if the candidate-file collector returns one. The
874/// current collector skips traversal and metadata errors, and per-file parse
875/// failures are logged and skipped rather than propagated.
876#[allow(clippy::too_many_arguments)] // per-provider helper; struct-wrapping the args would hurt readability
877fn process_usage_directory<P, F>(
878    dir: P,
879    provider: ExtensionType,
880    global_result: &mut UsageResult,
881    provider_result: &mut UsageResult,
882    unique_dates: &mut HashSet<String>,
883    filter_fn: F,
884    time_range: TimeRange,
885    max_depth: Option<usize>,
886) -> Result<()>
887where
888    P: AsRef<Path>,
889    F: Copy + Fn(&Path) -> bool + Sync + Send,
890{
891    let dir = dir.as_ref();
892    let files = collect_files_with_max_depth(dir, filter_fn, time_range, max_depth)?;
893
894    // Parse each file directly in `UsageOnly` mode, extract the small
895    // per-model usage map, then drop the analysis. The provider is fixed by
896    // the source directory — we do not re-detect from file contents, which
897    // would mis-classify Claude sessions whose first line is a metadata
898    // sentinel (`permission-mode`, `file-history-snapshot`) and silently drop
899    // their usage. We also deliberately bypass the global file cache here:
900    // the `usage` path never needs the heavy `write_file_details` /
901    // `edit_file_details` payloads, so caching the full analysis would waste
902    // the memory win from `UsageOnly`.
903    let file_results: Vec<(String, FastHashMap<String, Value>)> = files
904        .into_par_iter()
905        .filter_map(|file_info| {
906            match parse_session_file_typed_as(&file_info.path, provider, ParseMode::UsageOnly) {
907                Ok(analysis) => {
908                    let conversation_usage = extract_conversation_usage_from_analysis(analysis);
909                    Some((file_info.modified_date, conversation_usage))
910                }
911                Err(e) => {
912                    log::warn!("failed to analyze {}: {e}", file_info.path.display());
913                    None
914                }
915            }
916        })
917        .collect();
918
919    // Merge parallel results sequentially (this part is fast). Every
920    // per-model usage value is merged into *both* maps:
921    //   - `global_result` keeps the cross-provider view used by the main
922    //     per-model table,
923    //   - `provider_result` keeps the same tokens scoped to this provider
924    //     so the summary footer can attribute them to the right source
925    //     directory without having to guess from the model name.
926    for (date, conversation_usage) in file_results {
927        if usage_map_has_activity(&conversation_usage, 0.0) {
928            unique_dates.insert(date);
929        }
930
931        for (model, usage_value) in conversation_usage {
932            provider_result
933                .entry(model.clone())
934                .and_modify(|existing| merge_usage_values(existing, &usage_value))
935                .or_insert_with(|| usage_value.clone());
936
937            global_result
938                .entry(model)
939                .and_modify(|existing| merge_usage_values(existing, &usage_value))
940                .or_insert(usage_value);
941        }
942    }
943
944    Ok(())
945}
946
947/// Reads OpenCode's SQLite database and merges its per-model usage into both
948/// the global and OpenCode-scoped maps.
949///
950/// Mirrors the tail of [`process_usage_directory`] but sources sessions from
951/// the database (via [`read_opencode_usage`]) instead of a directory walk. Each
952/// row's date comes from the assistant message timestamp (falling back to
953/// `session.time_updated` on legacy schemas) and is recorded in `unique_dates`
954/// for the active-day count.
955///
956/// # Errors
957///
958/// Returns an error if the database cannot be opened or queried.
959fn process_opencode_usage(
960    db_path: &Path,
961    global_result: &mut UsageResult,
962    provider_result: &mut UsageResult,
963    stored_costs: &mut FastHashMap<String, f64>,
964    unique_dates: &mut HashSet<String>,
965    time_range: TimeRange,
966) -> Result<()> {
967    let sessions = read_opencode_usage(db_path, time_range)?;
968    fold_stored_cost_sessions(
969        sessions,
970        global_result,
971        provider_result,
972        stored_costs,
973        unique_dates,
974    );
975    Ok(())
976}
977
978/// Reads Cursor's per-model usage (a local estimate from the chat stores) and
979/// merges it into both the global and Cursor-scoped maps.
980///
981/// Mirrors [`process_opencode_usage`]: the estimate carries its own per-model
982/// tuple shape as stored-cost readers. Its zero stored cost lets the display
983/// layer accept only an exact LiteLLM match rather than a fuzzy price guess.
984fn process_cursor_usage(
985    chats_dir: &Path,
986    tracking_db: &Path,
987    global_result: &mut UsageResult,
988    provider_result: &mut UsageResult,
989    stored_costs: &mut FastHashMap<String, f64>,
990    unique_dates: &mut HashSet<String>,
991    time_range: TimeRange,
992) -> Result<()> {
993    let sessions = read_cursor_usage(chats_dir, tracking_db, time_range)?;
994    fold_stored_cost_sessions(
995        sessions,
996        global_result,
997        provider_result,
998        stored_costs,
999        unique_dates,
1000    );
1001    Ok(())
1002}
1003
1004/// Reads Hermes's per-model usage from its SQLite database and merges it into
1005/// both the global and Hermes-scoped maps.
1006///
1007/// Mirrors [`process_opencode_usage`]: Hermes stores its own per-model cost, so
1008/// it uses the same stored-cost path rather than a fuzzy price guess.
1009///
1010/// # Errors
1011///
1012/// Returns an error if the database cannot be opened or queried.
1013fn process_hermes_usage(
1014    db_path: &Path,
1015    global_result: &mut UsageResult,
1016    provider_result: &mut UsageResult,
1017    stored_costs: &mut FastHashMap<String, f64>,
1018    unique_dates: &mut HashSet<String>,
1019    time_range: TimeRange,
1020) -> Result<()> {
1021    let sessions = read_hermes_usage(db_path, time_range)?;
1022    fold_stored_cost_sessions(
1023        sessions,
1024        global_result,
1025        provider_result,
1026        stored_costs,
1027        unique_dates,
1028    );
1029    Ok(())
1030}
1031
1032/// Folds `(date, analysis, cost)` rows from a stored-cost provider (OpenCode /
1033/// Cursor) into the global + provider-scoped maps and the stored-cost table.
1034fn fold_stored_cost_sessions(
1035    sessions: Vec<(String, CodeAnalysis, f64)>,
1036    global_result: &mut UsageResult,
1037    provider_result: &mut UsageResult,
1038    stored_costs: &mut FastHashMap<String, f64>,
1039    unique_dates: &mut HashSet<String>,
1040) {
1041    for (date, analysis, session_cost) in sessions {
1042        let conversation_usage = extract_conversation_usage_from_analysis(analysis);
1043        if usage_map_has_activity(&conversation_usage, session_cost) {
1044            unique_dates.insert(date);
1045        }
1046        for (model, usage_value) in conversation_usage {
1047            *stored_costs.entry(model.clone()).or_insert(0.0) += session_cost;
1048
1049            provider_result
1050                .entry(model.clone())
1051                .and_modify(|existing| merge_usage_values(existing, &usage_value))
1052                .or_insert_with(|| usage_value.clone());
1053
1054            global_result
1055                .entry(model)
1056                .and_modify(|existing| merge_usage_values(existing, &usage_value))
1057                .or_insert(usage_value);
1058        }
1059    }
1060}
1061
1062fn usage_map_has_activity(usage: &FastHashMap<String, Value>, stored_cost: f64) -> bool {
1063    stored_cost != 0.0
1064        || usage
1065            .values()
1066            .any(|value| crate::utils::extract_token_counts(value).has_activity())
1067}
1068
1069impl UsageData {
1070    /// Returns the per-provider usage slice for `provider`, or `None`
1071    /// when the provider has no dedicated bucket (e.g. `Provider::Unknown`
1072    /// — the display layer's fallthrough view is fed by the global
1073    /// `models` map instead).
1074    pub fn provider_usage(&self, provider: Provider) -> Option<&UsageResult> {
1075        self.per_provider.get(provider)
1076    }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::*;
1082    use crate::utils::TokenCounts;
1083    use serde_json::json;
1084
1085    #[test]
1086    fn merge_preserves_tokens_across_mixed_shapes() {
1087        use crate::utils::extract_token_counts;
1088
1089        // A Codex `total_token_usage` value (input 1000 includes 200 cached).
1090        let codex = json!({
1091            "total_token_usage": {
1092                "input_tokens": 1000,
1093                "cached_input_tokens": 200,
1094                "output_tokens": 500,
1095                "total_tokens": 1500
1096            }
1097        });
1098        // A Cursor / flat value for the same model name.
1099        let flat = json!({
1100            "input_tokens": 100,
1101            "output_tokens": 20,
1102            "cache_read_input_tokens": 50,
1103            "cache_creation_input_tokens": 10
1104        });
1105
1106        // Codex disjoint counts: input 800, cache_read 200, output 500, total 1500.
1107        // Flat counts: input 100, output 20, cache_read 50, cache_creation 10.
1108        let expect = |c: TokenCounts| {
1109            assert_eq!(c.input_tokens, 800 + 100);
1110            assert_eq!(c.output_tokens, 500 + 20);
1111            assert_eq!(c.cache_read, 200 + 50);
1112            assert_eq!(c.cache_creation, 10);
1113            // Bucket sum: 1500 (Codex) + 180 (flat) = 1680; no tokens dropped.
1114            assert_eq!(c.total, 1680);
1115        };
1116
1117        // Merging is order-independent: neither side's tokens are dropped.
1118        let mut existing = codex.clone();
1119        merge_usage_values(&mut existing, &flat);
1120        expect(extract_token_counts(&existing));
1121
1122        let mut existing = flat.clone();
1123        merge_usage_values(&mut existing, &codex);
1124        expect(extract_token_counts(&existing));
1125    }
1126}