Skip to main content

vct_core/session/
parser.rs

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/// Content-safe warning summary used by the CLI's single-file path.
26///
27/// This type is public only because Cargo builds `src/main.rs` as a separate
28/// crate from the library. Provider diagnostics remain crate-private.
29#[doc(hidden)]
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct SessionFileParseDiagnostics {
32    skipped_records: usize,
33}
34
35impl SessionFileParseDiagnostics {
36    /// Number of malformed, unrecognized, or analyzer-relevant records skipped
37    /// after another record from the same source was recognized successfully.
38    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
98/// Parses a session file (JSONL or JSON) and returns the result as a
99/// `serde_json::Value` (the CLI single-file dump path).
100///
101/// Internally this is a thin wrapper over [`parse_session_file_typed`]; the
102/// conversion to `Value` happens once at the edge here rather than inside the
103/// cache, which keeps long sessions from being duplicated between typed and
104/// `Value` forms when multiple commands run against the same file.
105///
106/// An empty input yields `{}` (not a populated-but-empty `CodeAnalysis`), to
107/// preserve historical CLI behaviour.
108///
109/// # Errors
110///
111/// Returns an error if the file cannot be opened or read, if a nonempty source
112/// has no supported analyzer payload, or if the parsed [`crate::CodeAnalysis`]
113/// fails to serialise to a `serde_json::Value`.
114///
115/// # Examples
116///
117/// ```no_run
118/// use vct_core::parse_session_file_to_value;
119///
120/// let value = parse_session_file_to_value("session.jsonl")?;
121/// assert!(value.is_object());
122/// # Ok::<(), anyhow::Error>(())
123/// ```
124pub 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        // Preserve historical behaviour: empty input → `{}` rather than a
128        // fully-populated but empty `CodeAnalysis` object.
129        return Ok(serde_json::json!({}));
130    }
131    Ok(serde_json::to_value(&analysis)?)
132}
133
134/// Typed entry point that auto-detects the provider from file contents.
135///
136/// Prefer [`parse_session_file_typed_as`] whenever the caller already knows
137/// which provider the file belongs to (e.g. when walking
138/// `~/.claude/projects/*.jsonl` vs `~/.codex/sessions/*.jsonl`). Content-based
139/// detection is only intended for the CLI single-file path where the user
140/// hands us an arbitrary path.
141///
142/// Parses in [`ParseMode::Full`] — for callers that only consume tool
143/// counts and token usage (usage / aggregated analysis), use
144/// [`parse_session_file_typed_with_mode`] with [`ParseMode::UsageOnly`]
145/// to avoid allocating `write_file_details`/`edit_file_details` bodies.
146///
147/// # Errors
148///
149/// Returns an error if the file cannot be opened or read, if no record in a
150/// nonempty source has a recognized provider schema, or if every
151/// analyzer-relevant payload uses an unsupported schema. Empty input resolves
152/// to an empty [`CodeAnalysis`].
153///
154/// # Examples
155///
156/// ```no_run
157/// use vct_core::parse_session_file_typed;
158///
159/// let analysis = parse_session_file_typed("session.jsonl")?;
160/// println!("provider: {}", analysis.extension_name);
161/// # Ok::<(), anyhow::Error>(())
162/// ```
163pub fn parse_session_file_typed<P: AsRef<Path>>(path: P) -> Result<CodeAnalysis> {
164    parse_session_file_typed_with_mode(path, ParseMode::Full)
165}
166
167/// Content-detecting typed parse with an explicit [`ParseMode`].
168///
169/// Same auto-detection as [`parse_session_file_typed`], but the caller
170/// chooses whether to retain per-operation detail ([`ParseMode::Full`]) or
171/// only counts and totals ([`ParseMode::UsageOnly`]). The streaming path is
172/// tried first; only a file whose first line is not valid JSON (e.g. a
173/// pretty-printed single-object dump) falls back to reading the whole file.
174///
175/// # Errors
176///
177/// Returns an error if the file cannot be opened or read, if the fallback path
178/// cannot detect a provider, if no record in a nonempty source has a recognized
179/// provider schema, or if every analyzer-relevant payload uses an unsupported
180/// schema. Empty input resolves to an empty [`CodeAnalysis`].
181///
182/// # Examples
183///
184/// ```no_run
185/// use vct_core::session::parse_session_file_typed_with_mode;
186/// use vct_core::session::ParseMode;
187///
188/// let analysis =
189///     parse_session_file_typed_with_mode("session.jsonl", ParseMode::UsageOnly)?;
190/// // UsageOnly skips per-file detail bodies; counts still populate.
191/// assert!(analysis.records.iter().all(|r| r.write_file_details.is_empty()));
192/// # Ok::<(), anyhow::Error>(())
193/// ```
194pub 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/// Single-file parse with a content-safe partial-failure summary for the CLI.
202#[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    // Fallback for anything the streaming path could not peek (e.g. a
225    // hand-edited file whose first line is not valid JSON). This is also the
226    // normal path for Grok's pretty-printed `signals.json` object.
227    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
240/// Typed entry point when the caller already knows the provider.
241///
242/// Directory scanners should use this instead of [`parse_session_file_typed`]
243/// so that provider classification follows the source path — `~/.claude/projects`
244/// is always [`ExtensionType::ClaudeCode`], `~/.codex/sessions` is always
245/// [`ExtensionType::Codex`], and so on. This eliminates a whole class of bug
246/// where a metadata sentinel record at the top of a session (`permission-mode`,
247/// `file-history-snapshot`) leads the content-based detector to mis-file the
248/// log under another provider and silently drop its usage totals.
249///
250/// # Errors
251///
252/// Returns an error if the file cannot be opened or read, if no record in a
253/// nonempty source has a recognized schema for `provider`, or if every
254/// analyzer-relevant payload uses an unsupported schema. Empty input resolves
255/// to an empty [`CodeAnalysis`].
256///
257/// # Examples
258///
259/// ```no_run
260/// use vct_core::session::parse_session_file_typed_as;
261/// use vct_core::session::ParseMode;
262/// use vct_core::ExtensionType;
263///
264/// // A file walked out of `~/.claude/projects` is known to be Claude Code.
265/// let analysis = parse_session_file_typed_as(
266///     "session.jsonl",
267///     ExtensionType::ClaudeCode,
268///     ParseMode::Full,
269/// )?;
270/// # let _ = analysis;
271/// # Ok::<(), anyhow::Error>(())
272/// ```
273pub 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    // Fallback for empty files or anything the streaming peek could not
295    // parse on line one.
296    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
308/// Streaming path when the provider is known from the caller's source.
309///
310/// Peeks only the first non-empty line to split a JSONL file (where each line
311/// is a record) from a pretty-printed single-object JSON (which parses as a
312/// multi-line block and therefore fails `from_str` on line one). No detection
313/// happens here — the provider was decided by the path the file came from.
314///
315/// Returns `Ok(None)` for an empty file or one whose first line is not JSONL,
316/// signalling the caller to use the `read_json` fallback.
317///
318/// # Errors
319///
320/// Returns an error if the file cannot be opened or a line cannot be read, or
321/// if the chosen provider's dispatch step fails (the Gemini arm requires a
322/// parseable session-meta first line).
323fn 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
418/// Dynamic streaming path for providers whose event schema stays as `Value`.
419fn 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), // empty file — caller returns the empty shape
432    };
433
434    let first_value: Value = match serde_json::from_str(first_line.trim()) {
435        Ok(v) => v,
436        Err(_) => return Ok(None), // not JSONL → caller falls back to read_json
437    };
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
459/// Opens a JSONL source and parses its first record directly into `T`.
460///
461/// A raw `Value` is only materialized after typed deserialization fails, so
462/// successful records avoid building a second JSON tree solely for dispatch.
463fn 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            // A first line that is not a complete JSON value indicates a
493            // pretty-printed document. Let the caller use the whole-file
494            // fallback instead of treating it as malformed JSONL.
495            Err(_) => return Ok(None),
496        },
497    };
498
499    Ok(Some(TypedStream {
500        first,
501        reader,
502        diagnostics,
503        warnings,
504    }))
505}
506
507/// Streaming path when the provider is unknown.
508///
509/// Reads JSONL records one line at a time and feeds a stateful classifier that
510/// commits to a provider as soon as any record carries a distinctive marker.
511/// Because the classifier returns `None` when it has not seen a positive
512/// signal yet, we simply keep peeking until one appears (or EOF). There is
513/// no arbitrary upper bound on how long a Claude metadata preamble may
514/// be — previously a 7+ line preamble silently mis-classified the whole
515/// session as Codex because the 8-record peek window ran out before the
516/// first `parentUuid` record was read.
517///
518/// If the entire file is consumed without any marker firing, the default
519/// is Codex: Codex rollout logs usually contain one of the recognised
520/// `type` values (`session_meta`, `turn_context`, …) so a synthetic file
521/// with no markers is most likely a deliberately-empty Codex fixture
522/// rather than a silently-broken Claude log.
523///
524/// Returns `Ok(None)` when the file is empty or its first line is not JSON
525/// (a pretty-printed dump), signalling the caller to use the `read_json`
526/// fallback.
527///
528/// # Errors
529///
530/// Returns an error if the file cannot be opened or a line cannot be read, or
531/// if the resolved provider's dispatch step fails.
532fn 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                // Each record is inspected once by the stateful classifier.
558                // As soon as it has a confident verdict, hand the buffered
559                // prefix and remaining reader to the dispatcher.
560                if let Some(found) = classification {
561                    ext = Some(found);
562                    break;
563                }
564            }
565            Err(err) => {
566                // A non-JSON line on the very first record means the file
567                // is a pretty-printed single-object dump (Copilot legacy
568                // shape or similar); let the caller fall through to
569                // `read_json`. Otherwise we have buffered at least one
570                // valid record already — stop peeking and let the
571                // dispatcher decide.
572                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    // If the whole file was consumed without any distinctive marker, fall
591    // back to Codex — a JSONL file with no Claude / Gemini / Copilot / Grok
592    // markers is almost certainly a Codex log (or a synthetic fixture).
593    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
607/// Reads lines from `reader` until it finds a non-empty one. Returns `Ok(None)`
608/// at EOF.
609///
610/// # Errors
611///
612/// Returns an error if the underlying `read_line` fails (e.g. an I/O error or
613/// invalid UTF-8 in the stream).
614fn 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
630/// Reuses `line` while reading through blank lines to the next JSONL record.
631fn 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/// Streams the rest of the file, prepending any already-parsed records.
659///
660/// JSONL providers feed buffered records through their typed shape and chain
661/// the remaining reader. Grok reopens its single aggregate JSON object so its
662/// sibling session files remain available to the provider parser.
663#[allow(clippy::too_many_arguments)] // parse plumbing; a struct would obscure the seams
664fn 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            // Copilot CLI emits one event per line under
705            // `session-state/<uuid>/events.jsonl`. The streaming path sees
706            // this as a sequence of parseable `Value`s whose very first
707            // line is `type == "session.start"`.
708            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            // Gemini sessions are line-delimited event streams: the first
722            // line is a session-meta record carrying `sessionId` etc.,
723            // and every subsequent line is an individual event. Feed the
724            // already-buffered lines plus the rest of the reader into
725            // `parse_gemini_events`.
726            (|| {
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        // OpenCode stores sessions in a SQLite database, not a JSONL file, so
749        // it never flows through the file parser. See `session::opencode`.
750        ExtensionType::OpenCode => Ok(empty_parsed_analysis()),
751        // Cursor sessions live in per-conversation SQLite blob stores (analysis)
752        // and its billing tokens come from an API (usage), never a JSONL file.
753        // See `session::cursor`.
754        ExtensionType::Cursor => Ok(empty_parsed_analysis()),
755        // Hermes stores usage in a single SQLite database, not a JSONL file, so
756        // it never flows through the file parser. See `session::hermes`.
757        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
775/// Iterator that deserializes JSONL records directly into the provider model.
776///
777/// The line buffer is retained across iterations. A raw `Value` is only built
778/// when typed deserialization fails and diagnostics need to classify the
779/// unsupported record.
780fn 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
834/// Iterator that yields raw [`Value`]s, one per non-empty line in the reader.
835///
836/// Used by parsers (Gemini / Copilot) that need to dispatch per-event on a
837/// runtime-typed shape before committing to a strongly-typed struct, since
838/// different event types carry completely different payloads.
839fn 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    // Reuse one byte buffer via `read_until` instead of allocating a `String`
846    // per line (`reader.lines()`), mirroring `iter_jsonl_typed`.
847    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
1062/// Legacy dispatch used by the pretty-printed JSON fallback. Operates on an
1063/// already-materialised `Vec<Value>` — preferred to be avoided in the hot
1064/// path, but needed for Gemini/Copilot dumps that span multiple lines.
1065fn 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            // Copilot/Gemini only support the JSONL event stream, while OpenCode,
1098            // Cursor, and Hermes are read from SQLite (see `session::opencode` /
1099            // `session::cursor` / `session::hermes`), not a file. A file that
1100            // falls through to this branch (e.g. a stray pretty-printed export)
1101            // has no parser for its shape — return an empty analysis instead of
1102            // silently mis-parsing.
1103            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
1132/// Attaches runtime metadata (user, machine, version) expected in the output.
1133fn 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}