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