Skip to main content

vct_core/analysis/
aggregator.rs

1use crate::config::ProvidersConfig;
2use crate::constants::{FastHashMap, FastHashSet, capacity};
3use crate::models::TimeRange;
4use crate::models::{CodeAnalysis, ExtensionType, ProviderActiveDays};
5use crate::session::cursor::{
6    discover_cursor_store_dbs, load_conversation_model_snapshot,
7    read_cursor_analysis_with_diagnostics, read_store_analysis,
8};
9use crate::session::diagnostics::DatabaseAnalysisRow;
10use crate::session::opencode::read_opencode_analysis_with_diagnostics;
11use crate::session::parser::parse_session_file_typed_as_with_diagnostics;
12use crate::session::sqlite::is_cacheable_sqlite_failure;
13use crate::session::state::ParseMode;
14use crate::summary_cache::{
15    CompactSourceSummary, SourceFingerprint, SummaryCacheKey, SummaryKind, SummaryScanCache,
16};
17use crate::utils::directory::{FileInfo, collect_provider_files_diagnostics};
18use crate::utils::{
19    COPILOT_SESSION_MAX_DEPTH, GROK_SESSION_MAX_DEPTH, HelperPaths, get_current_user,
20    get_machine_id, is_claude_session_file, is_codex_session_file, is_copilot_session_file,
21    is_gemini_session_file, is_grok_session_file,
22};
23use anyhow::Result;
24use rayon::prelude::*;
25use serde::{Serialize, Serializer, ser::SerializeSeq};
26use std::collections::HashSet;
27use std::path::Path;
28
29// `AggregatedAnalysisRow` is a neutral DTO shared with the scan cache, so it
30// lives in `models`; re-exported here to keep the `analysis::AggregatedAnalysisRow`
31// (and `analysis::aggregator::AggregatedAnalysisRow`) paths working.
32pub use crate::models::AggregatedAnalysisRow;
33
34/// Bundle of aggregated analysis rows plus the per-provider active-day counts
35/// the display layer needs for daily averages.
36#[derive(Debug, Clone, Serialize)]
37pub struct AnalysisData {
38    /// Rows aggregated across *all* providers, keyed by model name.
39    ///
40    /// Drives the main per-model table. Same-named models from different
41    /// providers (e.g. Copilot CLI + Claude Code both using
42    /// `claude-sonnet-4-6`) share a single row here.
43    pub rows: Vec<AggregatedAnalysisRow>,
44    /// Same aggregation, but partitioned by **source directory** rather
45    /// than by model name. Drives the per-provider summary footer so
46    /// Copilot-originated sessions cannot be mis-attributed to Claude Code
47    /// just because their model name starts with `claude-`.
48    pub per_provider: PerProviderAnalysisRows,
49    /// Distinct active-day count per provider, used to derive daily averages.
50    pub provider_days: ProviderActiveDays,
51}
52
53/// A compact summary plus diagnostics from the source scan that produced it.
54///
55/// The legacy aggregation entry points return only [`AnalysisData`] for TUI
56/// callers that intentionally operate on best-effort data. Noninteractive
57/// callers can use the `*_with_diagnostics` variants and reject an all-failed
58/// scan or surface partial failures before rendering `data`.
59pub struct AnalysisCollection {
60    /// Successfully parsed metrics, even when some other sources failed.
61    pub data: AnalysisData,
62    /// Candidate, success, and failure information for the scan.
63    pub diagnostics: ScanDiagnostics,
64}
65
66/// Aggregated analysis rows partitioned by the source directory they came from.
67///
68/// Attribution is by provider directory, not by model name, so a model that
69/// appears under more than one provider (e.g. `claude-sonnet-4-6` recorded by
70/// both Claude Code and Copilot CLI) lands in the correct bucket.
71#[derive(Debug, Default, Clone, Serialize)]
72pub struct PerProviderAnalysisRows {
73    /// Rows from the Claude Code session directory.
74    pub claude: Vec<AggregatedAnalysisRow>,
75    /// Rows from the Codex session directory.
76    pub codex: Vec<AggregatedAnalysisRow>,
77    /// Rows from the Copilot CLI session directory.
78    pub copilot: Vec<AggregatedAnalysisRow>,
79    /// Rows from the Gemini CLI session directory.
80    pub gemini: Vec<AggregatedAnalysisRow>,
81    /// Rows from the Grok CLI session directory.
82    pub grok: Vec<AggregatedAnalysisRow>,
83    /// Rows from the OpenCode database.
84    pub opencode: Vec<AggregatedAnalysisRow>,
85    /// Rows from the Cursor chat stores.
86    pub cursor: Vec<AggregatedAnalysisRow>,
87}
88
89/// One parsed session in the canonical batch-analysis dataset.
90///
91/// `provider` and `date` retain the source provenance needed by the summary
92/// projection. They are intentionally not part of the public JSON shape; the
93/// nested [`CodeAnalysis`] is the same object emitted by single-file analysis.
94#[derive(Debug, Clone)]
95pub struct AnalysisSession {
96    /// Provider selected from the source directory or database.
97    pub provider: ExtensionType,
98    /// Local `YYYY-MM-DD` date used by the active-day summary.
99    pub date: String,
100    /// Complete normalized parser result for this session.
101    pub analysis: CodeAnalysis,
102}
103
104// Usage and analysis both report the one unified scan-diagnostics type; it is
105// re-exported here so callers can reach it as `analysis::ScanDiagnostics`.
106pub use crate::scan::{ScanDiagnostics, ScanFailure};
107
108/// Canonical batch-analysis dataset before any display-specific projection.
109///
110/// The in-memory entries retain provider and date provenance. Serialization is
111/// deliberately transparent: the JSON value is an array of [`CodeAnalysis`]
112/// objects, so every element has exactly the same schema as a single-file
113/// golden result.
114#[derive(Debug, Clone, Default)]
115pub struct AnalysisDataset {
116    /// Sessions in deterministic provider and source order.
117    pub sessions: Vec<AnalysisSession>,
118    /// Candidate, success, and failure information from collection.
119    ///
120    /// The custom [`Serialize`] implementation deliberately omits this field
121    /// so canonical batch JSON remains a transparent `CodeAnalysis[]`.
122    pub diagnostics: ScanDiagnostics,
123}
124
125impl AnalysisDataset {
126    /// Returns whether the dataset contains no parsed sessions.
127    pub fn is_empty(&self) -> bool {
128        self.sessions.is_empty()
129    }
130
131    /// Returns the number of parsed sessions.
132    pub fn len(&self) -> usize {
133        self.sessions.len()
134    }
135
136    /// Projects this canonical dataset into the compact display summaries.
137    pub fn summarize(&self) -> AnalysisData {
138        project_analysis_dataset(self)
139    }
140
141    /// Projects the dataset while retaining its collection diagnostics.
142    pub fn summarize_with_diagnostics(&self) -> AnalysisCollection {
143        AnalysisCollection {
144            data: self.summarize(),
145            diagnostics: self.diagnostics.clone(),
146        }
147    }
148}
149
150impl Serialize for AnalysisDataset {
151    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
152    where
153        S: Serializer,
154    {
155        let mut sequence = serializer.serialize_seq(Some(self.sessions.len()))?;
156        for session in &self.sessions {
157            sequence.serialize_element(&session.analysis)?;
158        }
159        sequence.end()
160    }
161}
162
163/// Aggregate file-operation metrics across every provider's session files,
164/// keyed by model.
165///
166/// Scans every enabled analysis provider's session files or database, sums
167/// tool-call counts and line counts by model within `time_range`, and returns
168/// rows sorted by model name alongside per-provider active-day counts. Parsed
169/// sessions are folded directly into the compact summary in
170/// [`ParseMode::UsageOnly`], so this path never retains a cross-provider
171/// [`AnalysisDataset`]. Missing provider directories are skipped, and
172/// individual source failures are logged rather than aborting the scan.
173///
174/// # Errors
175///
176/// Returns an error if the provider paths cannot be resolved. Directory
177/// traversal and metadata errors are currently skipped by the walker rather
178/// than propagated.
179///
180/// # Examples
181///
182/// ```no_run
183/// use vct_core::analysis::aggregate_sessions_by_model;
184/// use vct_core::TimeRange;
185///
186/// let data = aggregate_sessions_by_model(TimeRange::All)?;
187/// for row in &data.rows {
188///     println!("{}: {} edit lines", row.model, row.edit_lines);
189/// }
190/// # Ok::<(), anyhow::Error>(())
191/// ```
192pub fn aggregate_sessions_by_model(time_range: TimeRange) -> Result<AnalysisData> {
193    aggregate_sessions_by_model_with_providers(time_range, ProvidersConfig::default())
194}
195
196/// [`aggregate_sessions_by_model`] with explicit per-provider toggles (from
197/// `~/.vct/config.toml`). A disabled provider is skipped entirely.
198pub fn aggregate_sessions_by_model_with_providers(
199    time_range: TimeRange,
200    providers: ProvidersConfig,
201) -> Result<AnalysisData> {
202    Ok(aggregate_sessions_by_model_with_diagnostics(time_range, providers)?.data)
203}
204
205/// Streaming counterpart of [`aggregate_sessions_by_model_with_providers`] that also
206/// returns source diagnostics for noninteractive callers.
207///
208/// Parsed sessions are added to the summary as each provider completes. Only
209/// one provider's parallel parse results are temporarily retained at a time.
210pub fn aggregate_sessions_by_model_with_diagnostics(
211    time_range: TimeRange,
212    providers: ProvidersConfig,
213) -> Result<AnalysisCollection> {
214    aggregate_sessions_by_model_from_paths_with_diagnostics(
215        &crate::utils::resolve_paths()?,
216        time_range,
217        providers,
218    )
219}
220
221/// Aggregates file-operation metrics from provider session directories rooted at
222/// an explicit [`HelperPaths`].
223///
224/// The env-free, injectable counterpart of [`aggregate_sessions_by_model`]:
225/// every provider path comes from `paths` rather than the resolved home
226/// directory, so tests can point them at a temp tree and exercise the real
227/// aggregation without mutating process-global `HOME`.
228pub fn aggregate_sessions_by_model_from_paths(
229    paths: &HelperPaths,
230    time_range: TimeRange,
231) -> Result<AnalysisData> {
232    aggregate_sessions_by_model_from_paths_with_providers(
233        paths,
234        time_range,
235        ProvidersConfig::default(),
236    )
237}
238
239/// [`aggregate_sessions_by_model_from_paths`] with explicit provider toggles.
240pub fn aggregate_sessions_by_model_from_paths_with_providers(
241    paths: &HelperPaths,
242    time_range: TimeRange,
243    providers: ProvidersConfig,
244) -> Result<AnalysisData> {
245    Ok(aggregate_sessions_by_model_from_paths_with_diagnostics(paths, time_range, providers)?.data)
246}
247
248/// Env-free streaming aggregation with source diagnostics.
249pub fn aggregate_sessions_by_model_from_paths_with_diagnostics(
250    paths: &HelperPaths,
251    time_range: TimeRange,
252    providers: ProvidersConfig,
253) -> Result<AnalysisCollection> {
254    let mut projection = AnalysisProjection::new();
255    let diagnostics = visit_analysis_sessions_from_paths_with(
256        paths,
257        time_range,
258        providers,
259        ParseMode::UsageOnly,
260        &mut |session| projection.add_session(&session),
261    )?;
262    Ok(AnalysisCollection {
263        data: projection.finish(),
264        diagnostics,
265    })
266}
267
268/// Collects the canonical batch-analysis dataset from the current user's home.
269///
270/// Providers are always appended in this order: Claude, Codex, Copilot,
271/// Gemini, Grok, OpenCode, Cursor. `mode` controls only detail retention; every
272/// scalar counter remains available to downstream projections.
273pub fn collect_analysis_sessions_with(
274    time_range: TimeRange,
275    providers: ProvidersConfig,
276    mode: ParseMode,
277) -> Result<AnalysisDataset> {
278    collect_analysis_sessions_from_paths_with(
279        &crate::utils::resolve_paths()?,
280        time_range,
281        providers,
282        mode,
283    )
284}
285
286/// Collects the canonical batch-analysis dataset from explicit provider paths.
287///
288/// File-backed providers are ordered by path before parallel parsing. Database
289/// results are ordered by date and their database source identity. Together with the
290/// fixed provider order this makes serialized batch JSON deterministic.
291pub fn collect_analysis_sessions_from_paths_with(
292    paths: &HelperPaths,
293    time_range: TimeRange,
294    providers: ProvidersConfig,
295    mode: ParseMode,
296) -> Result<AnalysisDataset> {
297    let mut sessions = Vec::new();
298    let diagnostics = visit_analysis_sessions_from_paths_with(
299        paths,
300        time_range,
301        providers,
302        mode,
303        &mut |session| sessions.push(session),
304    )?;
305    Ok(AnalysisDataset {
306        sessions,
307        diagnostics,
308    })
309}
310
311/// Visits parsed sessions in deterministic provider and source order.
312///
313/// The canonical collector passes a `Vec::push` visitor and retains every
314/// session. Summary aggregation passes an [`AnalysisProjection`] visitor and
315/// drops each parsed session immediately after folding it. This keeps source
316/// discovery, diagnostics, and ordering identical across both paths.
317fn visit_analysis_sessions_from_paths_with<F>(
318    paths: &HelperPaths,
319    time_range: TimeRange,
320    providers: ProvidersConfig,
321    mode: ParseMode,
322    visitor: &mut F,
323) -> Result<ScanDiagnostics>
324where
325    F: FnMut(AnalysisSession),
326{
327    let mut diagnostics = ScanDiagnostics::default();
328
329    if providers.claude {
330        visit_file_sessions(
331            &[paths.claude_session_dir.as_path()],
332            ExtensionType::ClaudeCode,
333            is_claude_session_file,
334            time_range,
335            None,
336            mode,
337            &mut diagnostics,
338            visitor,
339        )?;
340    }
341
342    if providers.codex {
343        visit_file_sessions(
344            &paths.codex_session_dirs(),
345            ExtensionType::Codex,
346            is_codex_session_file,
347            time_range,
348            None,
349            mode,
350            &mut diagnostics,
351            visitor,
352        )?;
353    }
354
355    if providers.copilot {
356        visit_file_sessions(
357            &[paths.copilot_session_dir.as_path()],
358            ExtensionType::Copilot,
359            is_copilot_session_file,
360            time_range,
361            Some(COPILOT_SESSION_MAX_DEPTH),
362            mode,
363            &mut diagnostics,
364            visitor,
365        )?;
366    }
367
368    if providers.gemini {
369        visit_file_sessions(
370            &[paths.gemini_session_dir.as_path()],
371            ExtensionType::Gemini,
372            is_gemini_session_file,
373            time_range,
374            None,
375            mode,
376            &mut diagnostics,
377            visitor,
378        )?;
379    }
380
381    if providers.grok {
382        visit_file_sessions(
383            &[paths.grok_session_dir.as_path()],
384            ExtensionType::Grok,
385            is_grok_session_file,
386            time_range,
387            Some(GROK_SESSION_MAX_DEPTH),
388            mode,
389            &mut diagnostics,
390            visitor,
391        )?;
392    }
393
394    if providers.opencode && paths.opencode_db.exists() {
395        diagnostics.candidates += 1;
396        match read_opencode_analysis_with_diagnostics(&paths.opencode_db, time_range, mode) {
397            Ok(result) => {
398                if result.expected_records > 0 && result.parsed_records == 0 {
399                    record_failure(
400                        &mut diagnostics,
401                        ExtensionType::OpenCode,
402                        &paths.opencode_db,
403                        format!(
404                            "none of {} analysis records used a recognized schema",
405                            result.expected_records
406                        ),
407                    );
408                } else {
409                    diagnostics.parsed += 1;
410                    let failed_payloads = result
411                        .expected_records
412                        .saturating_sub(result.parsed_records)
413                        + result.failed_tool_parts;
414                    if failed_payloads > 0 {
415                        record_failure(
416                            &mut diagnostics,
417                            ExtensionType::OpenCode,
418                            &paths.opencode_db,
419                            format!(
420                                "{failed_payloads} analysis payloads used an unsupported schema"
421                            ),
422                        );
423                    }
424                }
425                visit_database_sessions(ExtensionType::OpenCode, result.rows, visitor);
426            }
427            Err(err) => record_failure(
428                &mut diagnostics,
429                ExtensionType::OpenCode,
430                &paths.opencode_db,
431                err.to_string(),
432            ),
433        }
434    }
435
436    if providers.cursor && paths.cursor_chats_dir.exists() {
437        let result = read_cursor_analysis_with_diagnostics(
438            &paths.cursor_chats_dir,
439            &paths.cursor_tracking_db,
440            time_range,
441            mode,
442        );
443        diagnostics.candidates += result.candidates;
444        diagnostics.parsed += result.parsed;
445        for failure in result.failures {
446            record_failure(
447                &mut diagnostics,
448                ExtensionType::Cursor,
449                &failure.path,
450                failure.error,
451            );
452        }
453        visit_database_sessions(ExtensionType::Cursor, result.rows, visitor);
454    }
455
456    Ok(diagnostics)
457}
458
459/// Incremental compact analysis scan rooted at the current user's paths.
460pub fn aggregate_sessions_by_model_with_cache(
461    time_range: TimeRange,
462    providers: ProvidersConfig,
463    cache: &mut SummaryScanCache,
464) -> Result<AnalysisCollection> {
465    aggregate_sessions_by_model_from_paths_with_cache(
466        &crate::utils::resolve_paths()?,
467        time_range,
468        providers,
469        cache,
470    )
471}
472
473/// Incremental compact analysis scan rooted at explicit provider paths.
474///
475/// File sources share the same compact cache shape as the usage collector.
476/// Database entries retain only model counters, dates, and source diagnostics.
477pub fn aggregate_sessions_by_model_from_paths_with_cache(
478    paths: &HelperPaths,
479    time_range: TimeRange,
480    providers: ProvidersConfig,
481    cache: &mut SummaryScanCache,
482) -> Result<AnalysisCollection> {
483    cache.begin_scan();
484    let mut projection = AnalysisProjection::new();
485    let mut diagnostics = ScanDiagnostics::default();
486    let mut seen = FastHashSet::default();
487
488    crate::scan::scan_all_cached_files(
489        paths,
490        providers,
491        time_range,
492        cache,
493        &mut seen,
494        &mut projection,
495        &mut diagnostics,
496        None,
497    )?;
498
499    if providers.opencode && paths.opencode_db.exists() {
500        scan_opencode_analysis(
501            paths,
502            time_range,
503            cache,
504            &mut seen,
505            &mut projection,
506            &mut diagnostics,
507        );
508    }
509    if providers.cursor && paths.cursor_chats_dir.exists() {
510        scan_cursor_analysis(
511            paths,
512            time_range,
513            cache,
514            &mut seen,
515            &mut projection,
516            &mut diagnostics,
517        );
518    }
519
520    cache.retain_kinds(&seen, &[SummaryKind::File, SummaryKind::AnalysisDatabase]);
521    diagnostics.finalize();
522    Ok(AnalysisCollection {
523        data: projection.finish(),
524        diagnostics,
525    })
526}
527
528fn scan_opencode_analysis(
529    paths: &HelperPaths,
530    time_range: TimeRange,
531    cache: &mut SummaryScanCache,
532    seen: &mut FastHashSet<SummaryCacheKey>,
533    projection: &mut AnalysisProjection,
534    diagnostics: &mut ScanDiagnostics,
535) {
536    let provider = ExtensionType::OpenCode;
537    let source = &paths.opencode_db;
538    diagnostics.candidates += 1;
539    let key = SummaryCacheKey::new(SummaryKind::AnalysisDatabase, provider, source, time_range);
540    seen.insert(key.clone());
541    let fingerprint = match SourceFingerprint::sqlite(source, &[]) {
542        Ok(value) => value,
543        Err(error) => {
544            record_failure(diagnostics, provider, source, error.to_string());
545            return;
546        }
547    };
548    if let Some(cached) = cache.get(&key, &fingerprint) {
549        crate::scan::fold_cached(provider, source, cached, projection, diagnostics);
550        return;
551    }
552
553    cache.record_parse();
554    match read_opencode_analysis_with_diagnostics(source, time_range, ParseMode::UsageOnly) {
555        Ok(result) => {
556            let complete_failure = result.expected_records > 0 && result.parsed_records == 0;
557            let failed = result
558                .expected_records
559                .saturating_sub(result.parsed_records)
560                + result.failed_tool_parts;
561            let failure = if complete_failure {
562                Some(format!(
563                    "none of {} analysis records used a recognized schema",
564                    result.expected_records
565                ))
566            } else if failed > 0 {
567                Some(format!(
568                    "{failed} analysis payloads used an unsupported schema"
569                ))
570            } else {
571                None
572            };
573            let mut summary = CompactSourceSummary::default();
574            for row in result.rows {
575                summary.add_analysis(row.analysis, row.date, 0.0, true);
576            }
577            let loaded = crate::scan::LoadedCompactSummary {
578                summary,
579                parsed: !complete_failure,
580                failure,
581            };
582            crate::scan::fold_loaded(provider, source, &loaded, projection, diagnostics);
583            cache.insert(
584                key,
585                fingerprint,
586                loaded.summary,
587                loaded.parsed,
588                loaded.failure,
589            );
590        }
591        Err(error) => {
592            let failure = error.to_string();
593            record_failure(diagnostics, provider, source, failure.clone());
594            if is_cacheable_sqlite_failure(&error) {
595                cache.insert(
596                    key,
597                    fingerprint,
598                    CompactSourceSummary::default(),
599                    false,
600                    Some(failure),
601                );
602            }
603        }
604    }
605}
606
607fn scan_cursor_analysis(
608    paths: &HelperPaths,
609    time_range: TimeRange,
610    cache: &mut SummaryScanCache,
611    seen: &mut FastHashSet<SummaryCacheKey>,
612    projection: &mut AnalysisProjection,
613    diagnostics: &mut ScanDiagnostics,
614) {
615    let provider = ExtensionType::Cursor;
616    let source = &paths.cursor_chats_dir;
617    let discovery = discover_cursor_store_dbs(source);
618    if !discovery.failures.is_empty() {
619        cache.preserve_provider_keys(seen, SummaryKind::AnalysisDatabase, provider);
620    }
621    for failure in discovery.failures {
622        diagnostics.candidates += 1;
623        record_failure(diagnostics, provider, &failure.path, failure.error);
624    }
625
626    let tracking_db = &paths.cursor_tracking_db;
627    let (conv_models, tracking_fingerprint, tracking_ok) =
628        match load_conversation_model_snapshot(tracking_db) {
629            Ok(snapshot) => (snapshot.models, snapshot.fingerprint, true),
630            Err(error) => {
631                record_failure(diagnostics, provider, tracking_db, error.to_string());
632                (FastHashMap::default(), None, false)
633            }
634        };
635    let user = get_current_user();
636    let machine = get_machine_id().to_string();
637
638    for store in discovery.stores {
639        diagnostics.candidates += 1;
640        let key = SummaryCacheKey::new(SummaryKind::AnalysisDatabase, provider, &store, time_range);
641        seen.insert(key.clone());
642        let fingerprint = if tracking_ok {
643            SourceFingerprint::sqlite_with_dependency(
644                &store,
645                tracking_db,
646                tracking_fingerprint.as_ref(),
647            )
648        } else {
649            SourceFingerprint::sqlite(&store, &[])
650        };
651        let fingerprint = match fingerprint {
652            Ok(fingerprint) => fingerprint,
653            Err(error) => {
654                record_failure(diagnostics, provider, &store, error.to_string());
655                continue;
656            }
657        };
658        if tracking_ok && let Some(cached) = cache.get(&key, &fingerprint) {
659            crate::scan::fold_cached(provider, &store, cached, projection, diagnostics);
660            continue;
661        }
662
663        cache.record_parse();
664        match read_store_analysis(
665            &store,
666            &conv_models,
667            time_range,
668            ParseMode::UsageOnly,
669            &user,
670            &machine,
671        ) {
672            Ok(result) => {
673                let complete_failure =
674                    result.normalized_messages == 0 && result.failed_payloads > 0;
675                let failure = if complete_failure {
676                    Some(format!(
677                        "none of {} analyzer payloads used a supported schema",
678                        result.failed_payloads
679                    ))
680                } else if result.failed_payloads > 0 {
681                    Some(format!(
682                        "{} analyzer payloads used an unsupported schema",
683                        result.failed_payloads
684                    ))
685                } else {
686                    None
687                };
688                let mut summary = CompactSourceSummary::default();
689                for (date, analysis) in result.rows {
690                    summary.add_analysis(analysis, date, 0.0, true);
691                }
692                let loaded = crate::scan::LoadedCompactSummary {
693                    summary,
694                    parsed: !complete_failure,
695                    failure,
696                };
697                crate::scan::fold_loaded(provider, &store, &loaded, projection, diagnostics);
698                if tracking_ok {
699                    cache.insert(
700                        key,
701                        fingerprint,
702                        loaded.summary,
703                        loaded.parsed,
704                        loaded.failure,
705                    );
706                }
707            }
708            Err(error) => {
709                let failure = error.to_string();
710                record_failure(diagnostics, provider, &store, failure.clone());
711                if tracking_ok && is_cacheable_sqlite_failure(&error) {
712                    cache.insert(
713                        key,
714                        fingerprint,
715                        CompactSourceSummary::default(),
716                        false,
717                        Some(failure),
718                    );
719                }
720            }
721        }
722    }
723}
724
725/// Projects a canonical dataset into the compact model/provider summaries used
726/// by the TUI, text, and table renderers.
727pub fn project_analysis_dataset(dataset: &AnalysisDataset) -> AnalysisData {
728    let mut projection = AnalysisProjection::new();
729    for session in &dataset.sessions {
730        projection.add_session(session);
731    }
732    projection.finish()
733}
734
735/// Projects one complete parser result into the same summary shape as a batch.
736///
737/// This is the single-file seam for `analysis FILE --text` and `--table`; it
738/// deliberately shares the batch projection instead of duplicating counters in
739/// CLI wiring.
740pub fn project_code_analysis(analysis: &CodeAnalysis) -> AnalysisData {
741    let provider = extension_type_from_name(&analysis.extension_name);
742    let mut projection = AnalysisProjection::new();
743    projection.add_analysis(provider, analysis);
744
745    let mut dates = HashSet::new();
746    for record in &analysis.records {
747        if let Some(date) = local_date_from_millis(record.timestamp) {
748            dates.insert(date);
749        }
750    }
751    if dates.is_empty() && !analysis.records.is_empty() {
752        dates.insert("single".to_string());
753    }
754    for date in dates {
755        projection.add_date(provider, date);
756    }
757
758    projection.finish()
759}
760
761/// Drains a model-keyed map into a `Vec` sorted by model name.
762fn into_sorted_rows(map: FastHashMap<String, AggregatedAnalysisRow>) -> Vec<AggregatedAnalysisRow> {
763    let mut v: Vec<AggregatedAnalysisRow> = map.into_values().collect();
764    v.sort_unstable_by(|a, b| a.model.cmp(&b.model));
765    v
766}
767
768type FileSessionOutcome =
769    std::result::Result<(Option<AnalysisSession>, Option<ScanFailure>), ScanFailure>;
770
771/// Visits one file-backed provider in deterministic path order.
772#[allow(clippy::too_many_arguments)]
773fn visit_file_sessions<F, V>(
774    dirs: &[&Path],
775    provider: ExtensionType,
776    filter_fn: F,
777    time_range: TimeRange,
778    max_depth: Option<usize>,
779    mode: ParseMode,
780    diagnostics: &mut ScanDiagnostics,
781    visitor: &mut V,
782) -> Result<()>
783where
784    F: Copy + Fn(&Path) -> bool + Sync + Send,
785    V: FnMut(AnalysisSession),
786{
787    let discovery = collect_provider_files_diagnostics(dirs, filter_fn, time_range, max_depth);
788    diagnostics.candidates += discovery.failures.len();
789    for failure in discovery.failures {
790        record_failure(diagnostics, provider, &failure.path, failure.error);
791    }
792
793    let mut files = discovery.files;
794    files.sort_unstable_by(|a, b| a.path.cmp(&b.path));
795    diagnostics.candidates += files.len();
796
797    // `Vec::into_par_iter` is indexed, so collecting retains the sorted source
798    // order while moving each path/date directly into its outcome.
799    let outcomes: Vec<FileSessionOutcome> = files
800        .into_par_iter()
801        .map(|file_info| {
802            let FileInfo {
803                path,
804                modified_date,
805            } = file_info;
806            match parse_session_file_typed_as_with_diagnostics(&path, provider, mode, None) {
807                Ok(parsed) if parsed.diagnostics.is_complete_failure() => {
808                    let error = if parsed.diagnostics.recognized_records == 0 {
809                        "source contained no recognized provider records".to_string()
810                    } else {
811                        format!(
812                            "none of {} analyzer-relevant provider records used a supported schema",
813                            parsed.diagnostics.relevant_records
814                        )
815                    };
816                    Err(ScanFailure {
817                        provider,
818                        source: path,
819                        error,
820                    })
821                }
822                Ok(parsed)
823                    if parsed.diagnostics.should_emit_session()
824                        && parsed.analysis.records.is_empty() =>
825                {
826                    Err(ScanFailure {
827                        provider,
828                        source: path,
829                        error: "normalized source produced no analysis records".to_string(),
830                    })
831                }
832                Ok(parsed) => {
833                    let partial_failure_count = parsed.diagnostics.partial_failure_count();
834                    let partial_failure = (partial_failure_count > 0).then_some(ScanFailure {
835                        provider,
836                        source: path,
837                        error: crate::session::diagnostics::partial_failure_reason(
838                            partial_failure_count,
839                        ),
840                    });
841                    let session = parsed.diagnostics.should_emit_session().then_some({
842                        AnalysisSession {
843                            provider,
844                            date: modified_date,
845                            analysis: parsed.analysis,
846                        }
847                    });
848                    Ok((session, partial_failure))
849                }
850                Err(err) => Err(ScanFailure {
851                    provider,
852                    source: path,
853                    error: err.to_string(),
854                }),
855            }
856        })
857        .collect();
858
859    for outcome in outcomes {
860        match outcome {
861            Ok((session, partial_failure)) => {
862                diagnostics.parsed += 1;
863                if let Some(session) = session {
864                    visitor(session);
865                }
866                if let Some(failure) = partial_failure {
867                    push_failure(diagnostics, failure);
868                }
869            }
870            Err(failure) => push_failure(diagnostics, failure),
871        }
872    }
873    Ok(())
874}
875
876fn record_failure(
877    diagnostics: &mut ScanDiagnostics,
878    provider: ExtensionType,
879    source: &Path,
880    error: String,
881) {
882    push_failure(
883        diagnostics,
884        ScanFailure {
885            provider,
886            source: source.to_path_buf(),
887            error,
888        },
889    );
890}
891
892fn push_failure(diagnostics: &mut ScanDiagnostics, failure: ScanFailure) {
893    // A partial parse keeps its recognized data; logging it as "failed to
894    // collect" would read as a dropped source.
895    if crate::session::diagnostics::is_partial_failure_reason(&failure.error) {
896        log::warn!(
897            "{} analysis from {}: {}",
898            failure.provider,
899            failure.source.display(),
900            failure.error
901        );
902    } else {
903        log::warn!(
904            "failed to collect {} analysis from {}: {}",
905            failure.provider,
906            failure.source.display(),
907            failure.error
908        );
909    }
910    diagnostics.failures.push(failure);
911}
912
913fn visit_database_sessions<F>(
914    provider: ExtensionType,
915    mut rows: Vec<DatabaseAnalysisRow>,
916    visitor: &mut F,
917) where
918    F: FnMut(AnalysisSession),
919{
920    rows.sort_unstable_by(|a, b| {
921        a.date
922            .cmp(&b.date)
923            .then_with(|| a.source_id.cmp(&b.source_id))
924    });
925    for row in rows {
926        visitor(AnalysisSession {
927            provider,
928            date: row.date,
929            analysis: row.analysis,
930        });
931    }
932}
933
934/// Mutable accumulator shared by batch and single-file projections.
935struct AnalysisProjection {
936    all: FastHashMap<String, AggregatedAnalysisRow>,
937    claude: FastHashMap<String, AggregatedAnalysisRow>,
938    codex: FastHashMap<String, AggregatedAnalysisRow>,
939    copilot: FastHashMap<String, AggregatedAnalysisRow>,
940    gemini: FastHashMap<String, AggregatedAnalysisRow>,
941    grok: FastHashMap<String, AggregatedAnalysisRow>,
942    opencode: FastHashMap<String, AggregatedAnalysisRow>,
943    cursor: FastHashMap<String, AggregatedAnalysisRow>,
944    all_dates: HashSet<String>,
945    claude_dates: HashSet<String>,
946    codex_dates: HashSet<String>,
947    copilot_dates: HashSet<String>,
948    gemini_dates: HashSet<String>,
949    grok_dates: HashSet<String>,
950    opencode_dates: HashSet<String>,
951    cursor_dates: HashSet<String>,
952    hermes_dates: HashSet<String>,
953}
954
955impl crate::scan::CompactSink for AnalysisProjection {
956    fn fold(&mut self, provider: ExtensionType, summary: &CompactSourceSummary) {
957        self.add_compact(provider, summary);
958    }
959}
960
961impl AnalysisProjection {
962    fn new() -> Self {
963        Self {
964            all: FastHashMap::with_capacity(capacity::MODEL_COMBINATIONS),
965            claude: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
966            codex: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
967            copilot: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
968            gemini: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
969            grok: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
970            opencode: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
971            cursor: FastHashMap::with_capacity(capacity::MODELS_PER_SESSION),
972            all_dates: HashSet::new(),
973            claude_dates: HashSet::new(),
974            codex_dates: HashSet::new(),
975            copilot_dates: HashSet::new(),
976            gemini_dates: HashSet::new(),
977            grok_dates: HashSet::new(),
978            opencode_dates: HashSet::new(),
979            cursor_dates: HashSet::new(),
980            hermes_dates: HashSet::new(),
981        }
982    }
983
984    fn add_session(&mut self, session: &AnalysisSession) {
985        self.add_analysis(Some(session.provider), &session.analysis);
986        self.add_date(Some(session.provider), session.date.clone());
987    }
988
989    fn add_analysis(&mut self, provider: Option<ExtensionType>, analysis: &CodeAnalysis) {
990        aggregate_analysis_result(&mut self.all, analysis);
991        let provider_rows = match provider {
992            Some(ExtensionType::ClaudeCode) => Some(&mut self.claude),
993            Some(ExtensionType::Codex) => Some(&mut self.codex),
994            Some(ExtensionType::Copilot) => Some(&mut self.copilot),
995            Some(ExtensionType::Gemini) => Some(&mut self.gemini),
996            Some(ExtensionType::Grok) => Some(&mut self.grok),
997            Some(ExtensionType::OpenCode) => Some(&mut self.opencode),
998            Some(ExtensionType::Cursor) => Some(&mut self.cursor),
999            Some(ExtensionType::Hermes) | None => None,
1000        };
1001        if let Some(rows) = provider_rows {
1002            aggregate_analysis_result(rows, analysis);
1003        }
1004    }
1005
1006    fn add_compact(&mut self, provider: ExtensionType, summary: &CompactSourceSummary) {
1007        merge_compact_rows(&mut self.all, &summary.analysis);
1008        let provider_rows = match provider {
1009            ExtensionType::ClaudeCode => Some(&mut self.claude),
1010            ExtensionType::Codex => Some(&mut self.codex),
1011            ExtensionType::Copilot => Some(&mut self.copilot),
1012            ExtensionType::Gemini => Some(&mut self.gemini),
1013            ExtensionType::Grok => Some(&mut self.grok),
1014            ExtensionType::OpenCode => Some(&mut self.opencode),
1015            ExtensionType::Cursor => Some(&mut self.cursor),
1016            ExtensionType::Hermes => None,
1017        };
1018        if let Some(rows) = provider_rows {
1019            merge_compact_rows(rows, &summary.analysis);
1020        }
1021
1022        self.all_dates
1023            .extend(summary.analysis_dates.iter().cloned());
1024        let dates = match provider {
1025            ExtensionType::ClaudeCode => Some(&mut self.claude_dates),
1026            ExtensionType::Codex => Some(&mut self.codex_dates),
1027            ExtensionType::Copilot => Some(&mut self.copilot_dates),
1028            ExtensionType::Gemini => Some(&mut self.gemini_dates),
1029            ExtensionType::Grok => Some(&mut self.grok_dates),
1030            ExtensionType::OpenCode => Some(&mut self.opencode_dates),
1031            ExtensionType::Cursor => Some(&mut self.cursor_dates),
1032            ExtensionType::Hermes => Some(&mut self.hermes_dates),
1033        };
1034        if let Some(dates) = dates {
1035            dates.extend(summary.analysis_dates.iter().cloned());
1036        }
1037    }
1038
1039    fn add_date(&mut self, provider: Option<ExtensionType>, date: String) {
1040        self.all_dates.insert(date.clone());
1041        match provider {
1042            Some(ExtensionType::ClaudeCode) => {
1043                self.claude_dates.insert(date);
1044            }
1045            Some(ExtensionType::Codex) => {
1046                self.codex_dates.insert(date);
1047            }
1048            Some(ExtensionType::Copilot) => {
1049                self.copilot_dates.insert(date);
1050            }
1051            Some(ExtensionType::Gemini) => {
1052                self.gemini_dates.insert(date);
1053            }
1054            Some(ExtensionType::Grok) => {
1055                self.grok_dates.insert(date);
1056            }
1057            Some(ExtensionType::OpenCode) => {
1058                self.opencode_dates.insert(date);
1059            }
1060            Some(ExtensionType::Cursor) => {
1061                self.cursor_dates.insert(date);
1062            }
1063            Some(ExtensionType::Hermes) => {
1064                self.hermes_dates.insert(date);
1065            }
1066            None => {}
1067        }
1068    }
1069
1070    fn finish(self) -> AnalysisData {
1071        let provider_days = ProviderActiveDays {
1072            claude: self.claude_dates.len(),
1073            codex: self.codex_dates.len(),
1074            copilot: self.copilot_dates.len(),
1075            gemini: self.gemini_dates.len(),
1076            grok: self.grok_dates.len(),
1077            opencode: self.opencode_dates.len(),
1078            cursor: self.cursor_dates.len(),
1079            hermes: self.hermes_dates.len(),
1080            total: self.all_dates.len(),
1081        };
1082        AnalysisData {
1083            rows: into_sorted_rows(self.all),
1084            per_provider: PerProviderAnalysisRows {
1085                claude: into_sorted_rows(self.claude),
1086                codex: into_sorted_rows(self.codex),
1087                copilot: into_sorted_rows(self.copilot),
1088                gemini: into_sorted_rows(self.gemini),
1089                grok: into_sorted_rows(self.grok),
1090                opencode: into_sorted_rows(self.opencode),
1091                cursor: into_sorted_rows(self.cursor),
1092            },
1093            provider_days,
1094        }
1095    }
1096}
1097
1098fn merge_compact_rows(
1099    target: &mut FastHashMap<String, AggregatedAnalysisRow>,
1100    source: &FastHashMap<String, AggregatedAnalysisRow>,
1101) {
1102    for (model, row) in source {
1103        let entry = target
1104            .entry(model.clone())
1105            .or_insert_with(|| AggregatedAnalysisRow {
1106                model: model.clone(),
1107                edit_lines: 0,
1108                read_lines: 0,
1109                write_lines: 0,
1110                bash_count: 0,
1111                edit_count: 0,
1112                read_count: 0,
1113                todo_write_count: 0,
1114                write_count: 0,
1115            });
1116        entry.edit_lines += row.edit_lines;
1117        entry.read_lines += row.read_lines;
1118        entry.write_lines += row.write_lines;
1119        entry.bash_count += row.bash_count;
1120        entry.edit_count += row.edit_count;
1121        entry.read_count += row.read_count;
1122        entry.todo_write_count += row.todo_write_count;
1123        entry.write_count += row.write_count;
1124    }
1125}
1126
1127fn extension_type_from_name(name: &str) -> Option<ExtensionType> {
1128    match name {
1129        "Claude-Code" => Some(ExtensionType::ClaudeCode),
1130        "Codex" => Some(ExtensionType::Codex),
1131        "Copilot-CLI" => Some(ExtensionType::Copilot),
1132        "Gemini" => Some(ExtensionType::Gemini),
1133        "Grok" => Some(ExtensionType::Grok),
1134        "OpenCode" => Some(ExtensionType::OpenCode),
1135        "Cursor" => Some(ExtensionType::Cursor),
1136        "Hermes" => Some(ExtensionType::Hermes),
1137        _ => None,
1138    }
1139}
1140
1141fn local_date_from_millis(timestamp: i64) -> Option<String> {
1142    chrono::DateTime::<chrono::Utc>::from_timestamp_millis(timestamp).map(|datetime| {
1143        datetime
1144            .with_timezone(&chrono::Local)
1145            .format("%Y-%m-%d")
1146            .to_string()
1147    })
1148}
1149
1150/// Folds one parsed session's per-model counters into `aggregated`.
1151///
1152/// Each model in the session's `conversation_usage` gets (or creates) a row,
1153/// and that record's line and tool-call counts are added in. Synthetic models
1154/// (model name containing `<synthetic>`) are skipped so placeholder usage does
1155/// not pollute the per-model breakdown.
1156fn aggregate_analysis_result(
1157    aggregated: &mut FastHashMap<String, AggregatedAnalysisRow>,
1158    analysis: &CodeAnalysis,
1159) {
1160    for record in &analysis.records {
1161        for model in record.conversation_usage.keys() {
1162            if model.contains("<synthetic>") {
1163                continue;
1164            }
1165
1166            let entry = aggregated
1167                .entry(model.clone())
1168                .or_insert_with(|| AggregatedAnalysisRow {
1169                    model: model.clone(),
1170                    edit_lines: 0,
1171                    read_lines: 0,
1172                    write_lines: 0,
1173                    bash_count: 0,
1174                    edit_count: 0,
1175                    read_count: 0,
1176                    todo_write_count: 0,
1177                    write_count: 0,
1178                });
1179
1180            entry.edit_lines += record.total_edit_lines;
1181            entry.read_lines += record.total_read_lines;
1182            entry.write_lines += record.total_write_lines;
1183
1184            entry.bash_count += record.tool_call_counts.bash;
1185            entry.edit_count += record.tool_call_counts.edit;
1186            entry.read_count += record.tool_call_counts.read;
1187            entry.todo_write_count += record.tool_call_counts.todo_write;
1188            entry.write_count += record.tool_call_counts.write;
1189        }
1190    }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196    use crate::models::{CodeAnalysisRecord, CodeAnalysisToolCalls};
1197    use serde_json::json;
1198
1199    fn analysis_with_advisor() -> CodeAnalysis {
1200        let mut conversation_usage = FastHashMap::default();
1201        conversation_usage.insert("claude-haiku-4-5".to_string(), json!({ "input_tokens": 4 }));
1202        let mut advisor_usage = FastHashMap::default();
1203        advisor_usage.insert(
1204            "claude-opus-4-8".to_string(),
1205            json!({ "input_tokens": 47579 }),
1206        );
1207
1208        let record = CodeAnalysisRecord {
1209            total_unique_files: 1,
1210            total_write_lines: 10,
1211            total_read_lines: 20,
1212            total_edit_lines: 5,
1213            total_write_characters: 0,
1214            total_read_characters: 0,
1215            total_edit_characters: 0,
1216            write_file_details: vec![],
1217            read_file_details: vec![],
1218            edit_file_details: vec![],
1219            run_command_details: vec![],
1220            tool_call_counts: CodeAnalysisToolCalls {
1221                read: 4,
1222                write: 1,
1223                edit: 2,
1224                todo_write: 1,
1225                bash: 3,
1226            },
1227            conversation_usage,
1228            advisor_usage,
1229            task_id: String::new(),
1230            timestamp: 0,
1231            folder_path: String::new(),
1232            git_remote_url: String::new(),
1233        };
1234
1235        CodeAnalysis {
1236            user: String::new(),
1237            extension_name: String::new(),
1238            insights_version: String::new(),
1239            machine_id: String::new(),
1240            records: vec![record],
1241        }
1242    }
1243
1244    #[test]
1245    fn advisor_model_is_not_credited_with_file_operations() {
1246        // Regression guard: advisor-message usage lives in `advisor_usage`, not
1247        // `conversation_usage`, so the aggregator must not create a row for the
1248        // advisor model or credit it with the main model's tool / line counts.
1249        let analysis = analysis_with_advisor();
1250        let mut aggregated = FastHashMap::default();
1251        aggregate_analysis_result(&mut aggregated, &analysis);
1252
1253        let main = aggregated
1254            .get("claude-haiku-4-5")
1255            .expect("main model row must exist");
1256        assert_eq!(main.read_lines, 20);
1257        assert_eq!(main.bash_count, 3);
1258
1259        assert!(
1260            aggregated.get("claude-opus-4-8").is_none(),
1261            "advisor model must not be credited with the main model's file operations"
1262        );
1263    }
1264}