1use crate::VERSION;
2use crate::constants::buffer;
3use crate::models::{
4 ClaudeCodeLog, CodeAnalysis, CodexLog, CopilotEvent, ExtensionType, GeminiSession,
5};
6use crate::pricing::TierThresholds;
7use crate::session::claude::parse_claude_logs_with_diagnostics;
8use crate::session::codex::parse_codex_log_iter_with_diagnostics;
9use crate::session::copilot::parse_copilot_events_with_diagnostics;
10use crate::session::detector::{RecordClassifier, detect_extension_type};
11use crate::session::diagnostics::{ParseDiagnostics, ParsedAnalysis};
12use crate::session::gemini::parse_gemini_events_with_diagnostics;
13use crate::session::grok::{is_grok_signals, parse_grok_session};
14use crate::session::state::ParseMode;
15use crate::utils::{get_current_user, get_machine_id, read_json, read_jsonl};
16use anyhow::{Context, Result, bail};
17use serde::de::DeserializeOwned;
18use serde_json::Value;
19use std::cell::RefCell;
20use std::fs::File;
21use std::io::{BufRead, BufReader};
22use std::path::Path;
23use std::rc::Rc;
24
25#[doc(hidden)]
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct SessionFileParseDiagnostics {
32 skipped_records: usize,
33}
34
35impl SessionFileParseDiagnostics {
36 pub fn skipped_records(self) -> usize {
39 self.skipped_records
40 }
41}
42
43#[derive(Debug, Default)]
44struct ParseWarningSummary {
45 unreadable_records: usize,
46 malformed_records: usize,
47 unsupported_schema_records: usize,
48 first_reason: Option<String>,
49}
50
51impl ParseWarningSummary {
52 fn record_unreadable(&mut self, line: usize, error: &std::io::Error) {
53 self.unreadable_records += 1;
54 if self.first_reason.is_none() {
55 self.first_reason = Some(format!("unreadable line {line}: {error}"));
56 }
57 }
58
59 fn record_malformed(&mut self, line: usize, error: &serde_json::Error) {
60 self.malformed_records += 1;
61 if self.first_reason.is_none() {
62 self.first_reason = Some(format!(
63 "malformed line {line}: {} at line {} column {}",
64 json_error_category(error),
65 error.line(),
66 error.column()
67 ));
68 }
69 }
70
71 fn record_unsupported_schema(&mut self, provider: ExtensionType, error: &serde_json::Error) {
72 self.unsupported_schema_records += 1;
73 if self.first_reason.is_none() {
74 self.first_reason = Some(format!("unsupported {provider} schema: {error}"));
75 }
76 }
77
78 fn emit(&self, path: &Path) {
79 let total =
80 self.unreadable_records + self.malformed_records + self.unsupported_schema_records;
81 if total == 0 {
82 return;
83 }
84 let first_reason = self
85 .first_reason
86 .as_deref()
87 .unwrap_or("unknown parse failure");
88 log::warn!(
89 "session parser skipped {total} record(s) from {}: unreadable={}, malformed={}, unsupported_schema={}; first failure: {first_reason}",
90 path.display(),
91 self.unreadable_records,
92 self.malformed_records,
93 self.unsupported_schema_records
94 );
95 }
96}
97
98pub fn parse_session_file_to_value<P: AsRef<Path>>(path: P) -> Result<Value> {
125 let analysis = parse_session_file_typed(path)?;
126 if analysis.records.is_empty() && analysis.extension_name.is_empty() {
127 return Ok(serde_json::json!({}));
130 }
131 Ok(serde_json::to_value(&analysis)?)
132}
133
134pub fn parse_session_file_typed<P: AsRef<Path>>(path: P) -> Result<CodeAnalysis> {
164 parse_session_file_typed_with_mode(path, ParseMode::Full)
165}
166
167pub fn parse_session_file_typed_with_mode<P: AsRef<Path>>(
195 path: P,
196 mode: ParseMode,
197) -> Result<CodeAnalysis> {
198 Ok(parse_session_file_with_diagnostics(path, mode)?.0)
199}
200
201#[doc(hidden)]
203pub fn parse_session_file_with_diagnostics<P: AsRef<Path>>(
204 path: P,
205 mode: ParseMode,
206) -> Result<(CodeAnalysis, SessionFileParseDiagnostics)> {
207 let path = path.as_ref();
208 let parsed = parse_session_file_typed_with_mode_internal(path, mode)?;
209 validate_parsed_source(path, &parsed.diagnostics)?;
210 let diagnostics = SessionFileParseDiagnostics {
211 skipped_records: parsed.diagnostics.partial_failure_count(),
212 };
213 Ok((parsed.analysis, diagnostics))
214}
215
216fn parse_session_file_typed_with_mode_internal(
217 path: &Path,
218 mode: ParseMode,
219) -> Result<ParsedAnalysis> {
220 if let Some(parsed) = stream_parse_autodetect(path, mode)? {
221 return Ok(parsed);
222 }
223
224 let data = match read_jsonl(path) {
228 Ok(data) => data,
229 Err(_) => read_json(path)?,
230 };
231
232 if data.is_empty() {
233 return Ok(empty_parsed_analysis());
234 }
235
236 let ext_type = detect_extension_type(&data)?;
237 dispatch_by_vec(data, ext_type, mode, path, None)
238}
239
240pub fn parse_session_file_typed_as<P: AsRef<Path>>(
274 path: P,
275 provider: ExtensionType,
276 mode: ParseMode,
277) -> Result<CodeAnalysis> {
278 let path = path.as_ref();
279 let parsed = parse_session_file_typed_as_with_diagnostics(path, provider, mode, None)?;
280 validate_parsed_source(path, &parsed.diagnostics)?;
281 Ok(parsed.analysis)
282}
283
284pub(crate) fn parse_session_file_typed_as_with_diagnostics(
285 path: &Path,
286 provider: ExtensionType,
287 mode: ParseMode,
288 tiers: Option<&TierThresholds>,
289) -> Result<ParsedAnalysis> {
290 if let Some(parsed) = stream_parse_known(path, provider, mode, tiers)? {
291 return Ok(parsed);
292 }
293
294 let data = match read_jsonl(path) {
297 Ok(data) => data,
298 Err(_) => read_json(path)?,
299 };
300
301 if data.is_empty() {
302 return Ok(empty_parsed_analysis());
303 }
304
305 dispatch_by_vec(data, provider, mode, path, tiers)
306}
307
308fn stream_parse_known(
324 path: &Path,
325 provider: ExtensionType,
326 mode: ParseMode,
327 tiers: Option<&TierThresholds>,
328) -> Result<Option<ParsedAnalysis>> {
329 match provider {
330 ExtensionType::ClaudeCode => {
331 let Some(mut stream) = prepare_typed_stream::<ClaudeCodeLog>(path, provider)? else {
332 return Ok(None);
333 };
334 let diagnostics = Rc::new(RefCell::new(stream.diagnostics));
335 let warnings = Rc::new(RefCell::new(stream.warnings));
336 let io_failure = Rc::new(RefCell::new(None));
337 let rest = iter_jsonl_typed(
338 &mut stream.reader,
339 provider,
340 Rc::clone(&diagnostics),
341 Rc::clone(&warnings),
342 Rc::clone(&io_failure),
343 );
344 let parsed = parse_claude_logs_with_diagnostics(
345 stream.first.into_iter().chain(rest),
346 mode,
347 tiers,
348 );
349 warnings.borrow().emit(path);
350 if let Some(error) = io_failure.borrow_mut().take() {
351 bail!("Failed to read session file {}: {error}", path.display());
352 }
353 let parsed = parsed?;
354 Ok(Some(finalize(
355 merge_extra_diagnostics(parsed, &diagnostics),
356 provider,
357 )))
358 }
359 ExtensionType::Codex => {
360 let Some(mut stream) = prepare_typed_stream::<CodexLog>(path, provider)? else {
361 return Ok(None);
362 };
363 let diagnostics = Rc::new(RefCell::new(stream.diagnostics));
364 let warnings = Rc::new(RefCell::new(stream.warnings));
365 let io_failure = Rc::new(RefCell::new(None));
366 let rest = iter_jsonl_typed(
367 &mut stream.reader,
368 provider,
369 Rc::clone(&diagnostics),
370 Rc::clone(&warnings),
371 Rc::clone(&io_failure),
372 );
373 let parsed = parse_codex_log_iter_with_diagnostics(
374 stream.first.into_iter().chain(rest),
375 mode,
376 tiers,
377 );
378 warnings.borrow().emit(path);
379 if let Some(error) = io_failure.borrow_mut().take() {
380 bail!("Failed to read session file {}: {error}", path.display());
381 }
382 let parsed = parsed?;
383 Ok(Some(finalize(
384 merge_extra_diagnostics(parsed, &diagnostics),
385 provider,
386 )))
387 }
388 ExtensionType::Copilot => {
389 let Some(mut stream) = prepare_typed_stream::<CopilotEvent>(path, provider)? else {
390 return Ok(None);
391 };
392 let diagnostics = Rc::new(RefCell::new(stream.diagnostics));
393 let warnings = Rc::new(RefCell::new(stream.warnings));
394 let io_failure = Rc::new(RefCell::new(None));
395 let rest = iter_jsonl_typed(
396 &mut stream.reader,
397 provider,
398 Rc::clone(&diagnostics),
399 Rc::clone(&warnings),
400 Rc::clone(&io_failure),
401 );
402 let parsed =
403 parse_copilot_events_with_diagnostics(stream.first.into_iter().chain(rest), mode);
404 warnings.borrow().emit(path);
405 if let Some(error) = io_failure.borrow_mut().take() {
406 bail!("Failed to read session file {}: {error}", path.display());
407 }
408 let parsed = parsed?;
409 Ok(Some(finalize(
410 merge_extra_diagnostics(parsed, &diagnostics),
411 provider,
412 )))
413 }
414 _ => stream_parse_known_dynamic(path, provider, mode, tiers),
415 }
416}
417
418fn stream_parse_known_dynamic(
420 path: &Path,
421 provider: ExtensionType,
422 mode: ParseMode,
423 tiers: Option<&TierThresholds>,
424) -> Result<Option<ParsedAnalysis>> {
425 let file =
426 File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
427 let mut reader = BufReader::with_capacity(buffer::FILE_READ_BUFFER, file);
428
429 let first_line = match read_next_non_empty_line(&mut reader)? {
430 Some(line) => line,
431 None => return Ok(None), };
433
434 let first_value: Value = match serde_json::from_str(first_line.trim()) {
435 Ok(v) => v,
436 Err(_) => return Ok(None), };
438
439 let parsed = dispatch_streaming_buffered(
440 provider,
441 vec![first_value],
442 reader,
443 mode,
444 ParseDiagnostics::default(),
445 Rc::new(RefCell::new(ParseWarningSummary::default())),
446 path,
447 tiers,
448 )?;
449 Ok(Some(finalize(parsed, provider)))
450}
451
452struct TypedStream<T> {
453 first: Option<T>,
454 reader: BufReader<File>,
455 diagnostics: ParseDiagnostics,
456 warnings: ParseWarningSummary,
457}
458
459fn prepare_typed_stream<T>(path: &Path, provider: ExtensionType) -> Result<Option<TypedStream<T>>>
464where
465 T: DeserializeOwned,
466{
467 let file =
468 File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
469 let mut reader = BufReader::with_capacity(buffer::FILE_READ_BUFFER, file);
470 let mut line = Vec::with_capacity(buffer::AVG_JSONL_LINE_SIZE);
471
472 if !read_next_non_empty_bytes(&mut reader, &mut line)? {
473 return Ok(None);
474 }
475
476 let bytes = trim_ascii_whitespace(&line);
477 let mut diagnostics = ParseDiagnostics::default();
478 let mut warnings = ParseWarningSummary::default();
479 let first = match serde_json::from_slice::<T>(bytes) {
480 Ok(record) => Some(record),
481 Err(typed_error) => match serde_json::from_slice::<Value>(bytes) {
482 Ok(value) => {
483 record_typed_schema_failure(
484 provider,
485 &value,
486 &mut diagnostics,
487 &mut warnings,
488 &typed_error,
489 );
490 None
491 }
492 Err(_) => return Ok(None),
496 },
497 };
498
499 Ok(Some(TypedStream {
500 first,
501 reader,
502 diagnostics,
503 warnings,
504 }))
505}
506
507fn stream_parse_autodetect(path: &Path, mode: ParseMode) -> Result<Option<ParsedAnalysis>> {
533 let file =
534 File::open(path).with_context(|| format!("Failed to open file: {}", path.display()))?;
535 let mut reader = BufReader::with_capacity(buffer::FILE_READ_BUFFER, file);
536
537 let mut buffered: Vec<Value> = Vec::with_capacity(8);
538 let mut first_line_was_json = None::<bool>;
539 let mut ext: Option<ExtensionType> = None;
540 let mut initial_diagnostics = ParseDiagnostics::default();
541 let mut classifier = RecordClassifier::default();
542 let warnings = Rc::new(RefCell::new(ParseWarningSummary::default()));
543 let mut line_number = 0_usize;
544
545 loop {
546 let line = match read_next_non_empty_line(&mut reader)? {
547 Some(line) => line,
548 None => break,
549 };
550 line_number += 1;
551
552 match serde_json::from_str::<Value>(line.trim()) {
553 Ok(v) => {
554 first_line_was_json.get_or_insert(true);
555 let classification = classifier.push(&v);
556 buffered.push(v);
557 if let Some(found) = classification {
561 ext = Some(found);
562 break;
563 }
564 }
565 Err(err) => {
566 if buffered.is_empty() {
573 first_line_was_json = Some(false);
574 break;
575 }
576 warnings.borrow_mut().record_malformed(line_number, &err);
577 initial_diagnostics.record_malformed();
578 }
579 }
580 }
581
582 if first_line_was_json == Some(false) {
583 return Ok(None);
584 }
585
586 if buffered.is_empty() {
587 return Ok(None);
588 }
589
590 let ext = ext.unwrap_or(ExtensionType::Codex);
594 let parsed = dispatch_streaming_buffered(
595 ext,
596 buffered,
597 reader,
598 mode,
599 initial_diagnostics,
600 warnings,
601 path,
602 None,
603 )?;
604 Ok(Some(finalize(parsed, ext)))
605}
606
607fn read_next_non_empty_line<R: BufRead>(reader: &mut R) -> Result<Option<String>> {
615 let mut line = String::new();
616 loop {
617 line.clear();
618 let n = reader
619 .read_line(&mut line)
620 .context("Failed to read line from session file")?;
621 if n == 0 {
622 return Ok(None);
623 }
624 if !line.trim().is_empty() {
625 return Ok(Some(line));
626 }
627 }
628}
629
630fn read_next_non_empty_bytes<R: BufRead>(reader: &mut R, line: &mut Vec<u8>) -> Result<bool> {
632 loop {
633 line.clear();
634 let count = reader
635 .read_until(b'\n', line)
636 .context("Failed to read line from session file")?;
637 if count == 0 {
638 return Ok(false);
639 }
640 if !trim_ascii_whitespace(line).is_empty() {
641 return Ok(true);
642 }
643 }
644}
645
646fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
647 let start = bytes
648 .iter()
649 .position(|byte| !byte.is_ascii_whitespace())
650 .unwrap_or(bytes.len());
651 let end = bytes
652 .iter()
653 .rposition(|byte| !byte.is_ascii_whitespace())
654 .map_or(start, |index| index + 1);
655 &bytes[start..end]
656}
657
658#[allow(clippy::too_many_arguments)] fn dispatch_streaming_buffered(
665 ext: ExtensionType,
666 buffered: Vec<Value>,
667 mut reader: BufReader<File>,
668 mode: ParseMode,
669 initial_diagnostics: ParseDiagnostics,
670 warnings: Rc<RefCell<ParseWarningSummary>>,
671 path: &Path,
672 tiers: Option<&TierThresholds>,
673) -> Result<ParsedAnalysis> {
674 let extra_diagnostics = Rc::new(RefCell::new(initial_diagnostics));
675 let io_failure = Rc::new(RefCell::new(None));
676 let parsed = match ext {
677 ExtensionType::ClaudeCode => {
678 let rest = iter_jsonl_values(
679 &mut reader,
680 Rc::clone(&extra_diagnostics),
681 Rc::clone(&warnings),
682 Rc::clone(&io_failure),
683 );
684 let logs = buffered.into_iter().chain(rest).filter_map(|value| {
685 deserialize_record::<ClaudeCodeLog>(value, ext, &extra_diagnostics, &warnings)
686 });
687 parse_claude_logs_with_diagnostics(logs, mode, tiers)
688 .map(|parsed| merge_extra_diagnostics(parsed, &extra_diagnostics))
689 }
690 ExtensionType::Codex => {
691 let rest = iter_jsonl_values(
692 &mut reader,
693 Rc::clone(&extra_diagnostics),
694 Rc::clone(&warnings),
695 Rc::clone(&io_failure),
696 );
697 let logs = buffered.into_iter().chain(rest).filter_map(|value| {
698 deserialize_record::<CodexLog>(value, ext, &extra_diagnostics, &warnings)
699 });
700 parse_codex_log_iter_with_diagnostics(logs, mode, tiers)
701 .map(|parsed| merge_extra_diagnostics(parsed, &extra_diagnostics))
702 }
703 ExtensionType::Copilot => {
704 let rest = iter_jsonl_values(
709 &mut reader,
710 Rc::clone(&extra_diagnostics),
711 Rc::clone(&warnings),
712 Rc::clone(&io_failure),
713 );
714 let events = buffered.into_iter().chain(rest).filter_map(|value| {
715 deserialize_record::<CopilotEvent>(value, ext, &extra_diagnostics, &warnings)
716 });
717 parse_copilot_events_with_diagnostics(events, mode)
718 .map(|parsed| merge_extra_diagnostics(parsed, &extra_diagnostics))
719 }
720 ExtensionType::Gemini => {
721 (|| {
727 let mut iter = buffered.into_iter();
728 let first = iter
729 .next()
730 .context("Gemini session missing top-level object")?;
731 let session: GeminiSession =
732 serde_json::from_value(first).context("Failed to parse Gemini session")?;
733
734 let rest_events = iter_jsonl_values(
735 &mut reader,
736 Rc::clone(&extra_diagnostics),
737 Rc::clone(&warnings),
738 Rc::clone(&io_failure),
739 );
740 parse_gemini_events_with_diagnostics(session, iter.chain(rest_events), mode, tiers)
741 .map(|parsed| merge_extra_diagnostics(parsed, &extra_diagnostics))
742 })()
743 }
744 ExtensionType::Grok => {
745 drop(reader);
746 parse_grok_session(path, mode)
747 }
748 ExtensionType::OpenCode => Ok(empty_parsed_analysis()),
751 ExtensionType::Cursor => Ok(empty_parsed_analysis()),
755 ExtensionType::Hermes => Ok(empty_parsed_analysis()),
758 };
759 warnings.borrow().emit(path);
760 if let Some(error) = io_failure.borrow_mut().take() {
761 bail!("Failed to read session file {}: {error}", path.display());
762 }
763 parsed
764}
765
766fn json_error_category(error: &serde_json::Error) -> &'static str {
767 match error.classify() {
768 serde_json::error::Category::Io => "I/O error",
769 serde_json::error::Category::Syntax => "syntax error",
770 serde_json::error::Category::Data => "data error",
771 serde_json::error::Category::Eof => "unexpected EOF",
772 }
773}
774
775fn iter_jsonl_typed<'a, T>(
781 reader: &'a mut BufReader<File>,
782 provider: ExtensionType,
783 diagnostics: Rc<RefCell<ParseDiagnostics>>,
784 warnings: Rc<RefCell<ParseWarningSummary>>,
785 io_failure: Rc<RefCell<Option<String>>>,
786) -> impl Iterator<Item = T> + 'a
787where
788 T: DeserializeOwned + 'a,
789{
790 let mut line = Vec::with_capacity(buffer::AVG_JSONL_LINE_SIZE);
791 let mut line_number = 1_usize;
792
793 std::iter::from_fn(move || {
794 loop {
795 line.clear();
796 line_number += 1;
797 match reader.read_until(b'\n', &mut line) {
798 Ok(0) => return None,
799 Ok(_) => {}
800 Err(err) => {
801 warnings.borrow_mut().record_unreadable(line_number, &err);
802 *io_failure.borrow_mut() = Some(err.to_string());
803 return None;
804 }
805 }
806
807 let bytes = trim_ascii_whitespace(&line);
808 if bytes.is_empty() {
809 continue;
810 }
811
812 match serde_json::from_slice::<T>(bytes) {
813 Ok(record) => return Some(record),
814 Err(typed_error) => match serde_json::from_slice::<Value>(bytes) {
815 Ok(value) => {
816 record_typed_schema_failure(
817 provider,
818 &value,
819 &mut diagnostics.borrow_mut(),
820 &mut warnings.borrow_mut(),
821 &typed_error,
822 );
823 }
824 Err(error) => {
825 warnings.borrow_mut().record_malformed(line_number, &error);
826 diagnostics.borrow_mut().record_malformed();
827 }
828 },
829 }
830 }
831 })
832}
833
834fn iter_jsonl_values<'a>(
840 reader: &'a mut BufReader<File>,
841 diagnostics: Rc<RefCell<ParseDiagnostics>>,
842 warnings: Rc<RefCell<ParseWarningSummary>>,
843 io_failure: Rc<RefCell<Option<String>>>,
844) -> impl Iterator<Item = Value> + 'a {
845 let mut line = Vec::with_capacity(buffer::AVG_JSONL_LINE_SIZE);
848 let mut line_number = 0_usize;
849
850 std::iter::from_fn(move || {
851 loop {
852 line.clear();
853 line_number += 1;
854 match reader.read_until(b'\n', &mut line) {
855 Ok(0) => return None,
856 Ok(_) => {}
857 Err(err) => {
858 warnings.borrow_mut().record_unreadable(line_number, &err);
859 *io_failure.borrow_mut() = Some(err.to_string());
860 return None;
861 }
862 }
863
864 let bytes = trim_ascii_whitespace(&line);
865 if bytes.is_empty() {
866 continue;
867 }
868
869 match serde_json::from_slice::<Value>(bytes) {
870 Ok(value) => return Some(value),
871 Err(err) => {
872 warnings.borrow_mut().record_malformed(line_number, &err);
873 diagnostics.borrow_mut().record_malformed();
874 }
875 }
876 }
877 })
878}
879
880fn deserialize_record<T>(
881 value: Value,
882 provider: ExtensionType,
883 diagnostics: &Rc<RefCell<ParseDiagnostics>>,
884 warnings: &Rc<RefCell<ParseWarningSummary>>,
885) -> Option<T>
886where
887 T: serde::de::DeserializeOwned,
888{
889 let record_kind = raw_record_kind(provider, &value);
890 match serde_json::from_value::<T>(value) {
891 Ok(record) => Some(record),
892 Err(error) => {
893 record_typed_schema_failure_kind(
894 provider,
895 record_kind,
896 &mut diagnostics.borrow_mut(),
897 &mut warnings.borrow_mut(),
898 &error,
899 );
900 None
901 }
902 }
903}
904
905fn record_typed_schema_failure(
906 provider: ExtensionType,
907 value: &Value,
908 diagnostics: &mut ParseDiagnostics,
909 warnings: &mut ParseWarningSummary,
910 error: &serde_json::Error,
911) {
912 record_typed_schema_failure_kind(
913 provider,
914 raw_record_kind(provider, value),
915 diagnostics,
916 warnings,
917 error,
918 );
919}
920
921fn record_typed_schema_failure_kind(
922 provider: ExtensionType,
923 (recognized, relevant): (bool, bool),
924 diagnostics: &mut ParseDiagnostics,
925 warnings: &mut ParseWarningSummary,
926 error: &serde_json::Error,
927) {
928 if recognized {
929 diagnostics.record_recognized_source();
930 if relevant {
931 diagnostics.record_relevant(false);
932 }
933 } else {
934 diagnostics.record_unrecognized();
935 }
936 if relevant {
937 warnings.record_unsupported_schema(provider, error);
938 }
939}
940
941fn raw_record_kind(provider: ExtensionType, value: &Value) -> (bool, bool) {
942 let record_type = value.get("type").and_then(Value::as_str);
943 match provider {
944 ExtensionType::ClaudeCode => {
945 let recognized = matches!(
946 record_type,
947 Some(
948 "assistant"
949 | "user"
950 | "system"
951 | "summary"
952 | "progress"
953 | "file-history-snapshot"
954 | "queue-operation"
955 | "attachment"
956 | "bridge-session"
957 | "permission-mode"
958 | "mode"
959 | "last-prompt"
960 | "ai-title"
961 | "agent-name"
962 | "pr-link"
963 | "started"
964 | "result"
965 | "agent-setting"
966 | "frame-link"
967 )
968 ) || value.get("toolUseResult").is_some();
969 let user_tool_result = record_type == Some("user")
970 && value
971 .pointer("/message/content")
972 .and_then(Value::as_array)
973 .is_some_and(|items| {
974 items.iter().any(|item| {
975 item.get("type").and_then(Value::as_str) == Some("tool_result")
976 })
977 });
978 let relevant = record_type == Some("assistant")
979 || value.get("toolUseResult").is_some()
980 || user_tool_result;
981 (recognized, relevant)
982 }
983 ExtensionType::Codex => {
984 let recognized = matches!(
985 record_type,
986 Some(
987 "session_meta"
988 | "turn_context"
989 | "event_msg"
990 | "response_item"
991 | "inter_agent_communication_metadata"
992 | "world_state"
993 | "compacted"
994 )
995 );
996 let payload_type = value.pointer("/payload/type");
997 let relevant = match record_type {
998 Some("event_msg") => payload_type
999 .is_some_and(|kind| kind.as_str() == Some("token_count") || !kind.is_string()),
1000 Some("response_item") => payload_type.is_some_and(|kind| {
1001 matches!(
1002 kind.as_str(),
1003 Some(
1004 "function_call"
1005 | "function_call_output"
1006 | "custom_tool_call"
1007 | "custom_tool_call_output"
1008 )
1009 ) || !kind.is_string()
1010 }),
1011 _ => false,
1012 };
1013 (recognized, relevant)
1014 }
1015 ExtensionType::Copilot => {
1016 let recognized = matches!(
1017 record_type,
1018 Some(
1019 "session.start"
1020 | "session.model_change"
1021 | "session.task_complete"
1022 | "session.shutdown"
1023 | "session.info"
1024 | "session.mode_changed"
1025 | "system.message"
1026 | "user.message"
1027 | "assistant.message"
1028 | "assistant.turn_start"
1029 | "assistant.turn_end"
1030 | "tool.execution_start"
1031 | "tool.execution_complete"
1032 | "hook.start"
1033 | "hook.end"
1034 | "abort"
1035 | "subagent.started"
1036 | "subagent.completed"
1037 | "system.notification"
1038 | "session.resume"
1039 )
1040 );
1041 let relevant = matches!(
1042 record_type,
1043 Some("session.shutdown" | "tool.execution_start" | "tool.execution_complete")
1044 ) || (record_type == Some("assistant.message")
1045 && value.pointer("/data/outputTokens").is_some());
1046 (recognized, relevant)
1047 }
1048 ExtensionType::Gemini => (false, false),
1049 ExtensionType::Grok => (is_grok_signals(value), is_grok_signals(value)),
1050 ExtensionType::OpenCode | ExtensionType::Cursor | ExtensionType::Hermes => (false, false),
1051 }
1052}
1053
1054fn merge_extra_diagnostics(
1055 mut parsed: ParsedAnalysis,
1056 extra: &Rc<RefCell<ParseDiagnostics>>,
1057) -> ParsedAnalysis {
1058 parsed.diagnostics.merge(*extra.borrow());
1059 parsed
1060}
1061
1062fn dispatch_by_vec(
1066 data: Vec<Value>,
1067 ext_type: ExtensionType,
1068 mode: ParseMode,
1069 path: &Path,
1070 tiers: Option<&TierThresholds>,
1071) -> Result<ParsedAnalysis> {
1072 let extra_diagnostics = Rc::new(RefCell::new(ParseDiagnostics::default()));
1073 let warnings = Rc::new(RefCell::new(ParseWarningSummary::default()));
1074 let parsed = match ext_type {
1075 ExtensionType::ClaudeCode => {
1076 let logs = data.into_iter().filter_map(|value| {
1077 deserialize_record::<ClaudeCodeLog>(value, ext_type, &extra_diagnostics, &warnings)
1078 });
1079 parse_claude_logs_with_diagnostics(logs, mode, tiers)?
1080 }
1081 ExtensionType::Codex => {
1082 let logs = data.into_iter().filter_map(|value| {
1083 deserialize_record::<CodexLog>(value, ext_type, &extra_diagnostics, &warnings)
1084 });
1085 parse_codex_log_iter_with_diagnostics(logs, mode, tiers)?
1086 }
1087 ExtensionType::Copilot => {
1088 let events = data.into_iter().filter_map(|value| {
1089 deserialize_record::<CopilotEvent>(value, ext_type, &extra_diagnostics, &warnings)
1090 });
1091 parse_copilot_events_with_diagnostics(events, mode)?
1092 }
1093 ExtensionType::Gemini
1094 | ExtensionType::OpenCode
1095 | ExtensionType::Cursor
1096 | ExtensionType::Hermes => {
1097 let mut diagnostics = ParseDiagnostics::default();
1104 for _ in data {
1105 diagnostics.record_unrecognized();
1106 }
1107 ParsedAnalysis::new(empty_analysis(), diagnostics)
1108 }
1109 ExtensionType::Grok => parse_grok_session(path, mode)?,
1110 };
1111 warnings.borrow().emit(path);
1112 Ok(finalize(
1113 merge_extra_diagnostics(parsed, &extra_diagnostics),
1114 ext_type,
1115 ))
1116}
1117
1118fn empty_analysis() -> CodeAnalysis {
1119 CodeAnalysis {
1120 user: String::new(),
1121 extension_name: String::new(),
1122 insights_version: String::new(),
1123 machine_id: String::new(),
1124 records: Vec::new(),
1125 }
1126}
1127
1128fn empty_parsed_analysis() -> ParsedAnalysis {
1129 ParsedAnalysis::new(empty_analysis(), ParseDiagnostics::default())
1130}
1131
1132fn finalize(mut parsed: ParsedAnalysis, ext_type: ExtensionType) -> ParsedAnalysis {
1134 parsed.analysis.user = get_current_user();
1135 parsed.analysis.extension_name = ext_type.to_string();
1136 parsed.analysis.machine_id = get_machine_id().to_string();
1137 parsed.analysis.insights_version = VERSION.to_string();
1138 parsed
1139}
1140
1141fn validate_parsed_source(path: &Path, diagnostics: &ParseDiagnostics) -> Result<()> {
1142 if diagnostics.source_records == 0 {
1143 return Ok(());
1144 }
1145 if diagnostics.recognized_records == 0 {
1146 bail!(
1147 "session file {} contained no recognized provider records",
1148 path.display()
1149 );
1150 }
1151 if diagnostics.relevant_records > 0 && diagnostics.normalized_records == 0 {
1152 bail!(
1153 "session file {} contained {} analyzer-relevant provider records, but none used a supported schema",
1154 path.display(),
1155 diagnostics.relevant_records
1156 );
1157 }
1158 Ok(())
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164 use crate::session::detector::{record_inspections, reset_record_inspections};
1165 use tempfile::TempDir;
1166
1167 #[test]
1168 fn auto_detection_inspects_each_preamble_record_once() {
1169 let temp = TempDir::new().unwrap();
1170 let path = temp.path().join("long-preamble.jsonl");
1171 let sentinel = r#"{"type":"file-history-snapshot","messageId":"m1","snapshot":{}}"#;
1172 let assistant = r#"{"type":"assistant","sessionId":"session","parentUuid":"parent","timestamp":"2026-07-14T00:00:00Z","message":{"model":"claude-opus-4-7","usage":{"input_tokens":1,"output_tokens":1},"content":[]}}"#;
1173 let mut contents =
1174 String::with_capacity((sentinel.len() + 1) * 10_000 + assistant.len() + 1);
1175 for _ in 0..10_000 {
1176 contents.push_str(sentinel);
1177 contents.push('\n');
1178 }
1179 contents.push_str(assistant);
1180 contents.push('\n');
1181 std::fs::write(&path, contents).unwrap();
1182
1183 reset_record_inspections();
1184 let parsed = stream_parse_autodetect(&path, ParseMode::UsageOnly)
1185 .unwrap()
1186 .expect("JSONL source should use streaming detection");
1187
1188 assert_eq!(parsed.analysis.extension_name, "Claude-Code");
1189 assert_eq!(record_inspections(), 10_001);
1190 }
1191}