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
29pub use crate::models::AggregatedAnalysisRow;
33
34#[derive(Debug, Clone, Serialize)]
37pub struct AnalysisData {
38 pub rows: Vec<AggregatedAnalysisRow>,
44 pub per_provider: PerProviderAnalysisRows,
49 pub provider_days: ProviderActiveDays,
51}
52
53pub struct AnalysisCollection {
60 pub data: AnalysisData,
62 pub diagnostics: ScanDiagnostics,
64}
65
66#[derive(Debug, Default, Clone, Serialize)]
72pub struct PerProviderAnalysisRows {
73 pub claude: Vec<AggregatedAnalysisRow>,
75 pub codex: Vec<AggregatedAnalysisRow>,
77 pub copilot: Vec<AggregatedAnalysisRow>,
79 pub gemini: Vec<AggregatedAnalysisRow>,
81 pub grok: Vec<AggregatedAnalysisRow>,
83 pub opencode: Vec<AggregatedAnalysisRow>,
85 pub cursor: Vec<AggregatedAnalysisRow>,
87}
88
89#[derive(Debug, Clone)]
95pub struct AnalysisSession {
96 pub provider: ExtensionType,
98 pub date: String,
100 pub analysis: CodeAnalysis,
102}
103
104pub use crate::scan::{ScanDiagnostics, ScanFailure};
107
108#[derive(Debug, Clone, Default)]
115pub struct AnalysisDataset {
116 pub sessions: Vec<AnalysisSession>,
118 pub diagnostics: ScanDiagnostics,
123}
124
125impl AnalysisDataset {
126 pub fn is_empty(&self) -> bool {
128 self.sessions.is_empty()
129 }
130
131 pub fn len(&self) -> usize {
133 self.sessions.len()
134 }
135
136 pub fn summarize(&self) -> AnalysisData {
138 project_analysis_dataset(self)
139 }
140
141 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
163pub fn aggregate_sessions_by_model(time_range: TimeRange) -> Result<AnalysisData> {
193 aggregate_sessions_by_model_with_providers(time_range, ProvidersConfig::default())
194}
195
196pub 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
205pub 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
221pub 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
239pub 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
248pub 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
268pub 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
286pub 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
311fn 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
459pub 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
473pub 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
725pub 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
735pub 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
761fn 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#[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 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 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
934struct 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
1150fn 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 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}