Skip to main content

vct_core/session/
state.rs

1//! Mutable accumulator shared by every per-provider session parser.
2//!
3//! Each provider parser ([`crate::session::claude`], [`crate::session::codex`],
4//! …) walks its own JSONL shape but funnels the file-operation facts it
5//! extracts through a single [`SessionParseState`], which tallies line /
6//! character counts and (in [`ParseMode::Full`]) accumulates the per-op detail
7//! records. Once the file is consumed the state is converted into one
8//! [`CodeAnalysisRecord`] via [`SessionParseState::into_record`].
9use crate::constants::{FastHashMap, FastHashSet};
10use crate::models::*;
11use crate::utils::count_lines;
12use serde_json::Value;
13
14/// Controls how much per-operation detail the session parser retains.
15///
16/// `Full` keeps everything that ends up in the public JSON output
17/// (file contents on `Write`, old/new strings on `Edit`, command text on
18/// `Bash`). `UsageOnly` skips those allocations — counts and totals are
19/// still accurate, but the per-detail `Vec`s stay empty. Callers that only
20/// consume `conversation_usage` / `tool_call_counts` / `total_*_lines`
21/// (the `usage` command, the aggregated `analysis` path) should pick
22/// `UsageOnly` to avoid pulling entire file bodies into memory on every
23/// session parse.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ParseMode {
26    /// Retain everything that ends up in the JSON output, including file
27    /// bodies and old/new edit strings.
28    Full,
29    /// Skip per-detail allocations; keep only counts and totals.
30    UsageOnly,
31}
32
33/// Common parse state shared by all per-provider session parsers.
34///
35/// Construct with [`SessionParseState::new`] (full detail) or
36/// [`SessionParseState::with_mode`], populate the metadata fields
37/// (`folder_path`, `git_remote`, `task_id`) directly as the provider header
38/// is read, feed file operations through the `add_*` helpers, then call
39/// [`SessionParseState::into_record`] to produce the final
40/// [`CodeAnalysisRecord`]. Not thread-safe; one instance parses one file on
41/// one thread.
42pub struct SessionParseState {
43    /// Detail-retention level chosen at construction.
44    pub mode: ParseMode,
45    /// Per-`Write` detail records (empty in [`ParseMode::UsageOnly`]).
46    pub write_details: Vec<CodeAnalysisWriteDetail>,
47    /// Per-`Read` detail records (empty in [`ParseMode::UsageOnly`]).
48    pub read_details: Vec<CodeAnalysisReadDetail>,
49    /// Per-`Edit`/diff detail records (empty in [`ParseMode::UsageOnly`]).
50    pub edit_details: Vec<CodeAnalysisApplyDiffDetail>,
51    /// Per-`Bash`/run-command detail records (empty in [`ParseMode::UsageOnly`]).
52    pub run_details: Vec<CodeAnalysisRunCommandDetail>,
53    /// Running per-tool call counts (always tallied, both modes).
54    pub tool_counts: CodeAnalysisToolCalls,
55    /// Distinct normalized file paths touched (populated in both parse modes).
56    pub unique_files: FastHashSet<String>,
57    /// Sum of lines written across all `Write` operations.
58    pub total_write_lines: usize,
59    /// Sum of lines read across all `Read` operations.
60    pub total_read_lines: usize,
61    /// Sum of new-content lines across all `Edit` operations.
62    pub total_edit_lines: usize,
63    /// Sum of characters written across all `Write` operations.
64    pub total_write_characters: usize,
65    /// Sum of characters read across all `Read` operations.
66    pub total_read_characters: usize,
67    /// Sum of new-content characters across all `Edit` operations.
68    pub total_edit_characters: usize,
69    /// Session working directory; used to resolve relative paths to absolute.
70    pub folder_path: String,
71    /// Git remote URL for the session's repository, when known.
72    pub git_remote: String,
73    /// Provider-specific session identifier.
74    pub task_id: String,
75    /// Latest event timestamp seen, in epoch milliseconds.
76    pub last_ts: i64,
77}
78
79impl SessionParseState {
80    /// Creates an empty parse state in [`ParseMode::Full`].
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use vct_core::session::state::SessionParseState;
86    ///
87    /// let mut state = SessionParseState::new();
88    /// state.folder_path = "/repo".to_string();
89    /// state.add_read_detail("src/main.rs", "fn main() {}\n", 0);
90    /// assert_eq!(state.total_read_lines, 1);
91    /// assert_eq!(state.tool_counts.read, 1);
92    /// ```
93    pub fn new() -> Self {
94        Self::with_mode(ParseMode::Full)
95    }
96
97    /// Creates an empty parse state with the given detail-retention `mode`.
98    ///
99    /// In [`ParseMode::Full`] the detail `Vec`s are pre-sized to typical session
100    /// sizes. `unique_files` is pre-sized in both modes because
101    /// `total_unique_files` is a scalar total that must stay exact.
102    pub fn with_mode(mode: ParseMode) -> Self {
103        // Pre-allocate Vecs with reasonable capacity estimates based on
104        // typical session sizes. In `UsageOnly` mode we skip the
105        // pre-allocation because the vecs stay empty.
106        let pre = matches!(mode, ParseMode::Full);
107        Self {
108            mode,
109            write_details: if pre {
110                Vec::with_capacity(10)
111            } else {
112                Vec::new()
113            },
114            read_details: if pre {
115                Vec::with_capacity(20)
116            } else {
117                Vec::new()
118            },
119            edit_details: if pre {
120                Vec::with_capacity(15)
121            } else {
122                Vec::new()
123            },
124            run_details: if pre {
125                Vec::with_capacity(10)
126            } else {
127                Vec::new()
128            },
129            tool_counts: CodeAnalysisToolCalls::default(),
130            unique_files: FastHashSet::with_capacity(20),
131            total_write_lines: 0,
132            total_read_lines: 0,
133            total_edit_lines: 0,
134            total_write_characters: 0,
135            total_read_characters: 0,
136            total_edit_characters: 0,
137            folder_path: String::new(),
138            git_remote: String::new(),
139            task_id: String::new(),
140            last_ts: 0,
141        }
142    }
143
144    /// Records a `Read` operation against `path` with the read `content`.
145    ///
146    /// Trailing newlines are stripped before counting. No-op (and no count
147    /// bump) when the trimmed content is empty or when `path` normalizes to
148    /// an empty string. The detail record is only stored in
149    /// [`ParseMode::Full`]; counts and unique-file tracking accrue in both modes.
150    pub fn add_read_detail(&mut self, path: &str, content: &str, ts: i64) {
151        let trimmed = content.trim_end_matches('\n');
152        let line_count = count_lines(trimmed);
153
154        if line_count == 0 {
155            return;
156        }
157
158        let char_count = trimmed.chars().count();
159        let resolved = self.normalize_path(path);
160
161        if resolved.is_empty() {
162            return;
163        }
164
165        if matches!(self.mode, ParseMode::Full) {
166            self.read_details.push(CodeAnalysisReadDetail {
167                base: CodeAnalysisDetailBase {
168                    file_path: resolved.clone(),
169                    line_count,
170                    character_count: char_count,
171                    timestamp: ts,
172                },
173            });
174        }
175        self.unique_files.insert(resolved);
176
177        self.total_read_lines += line_count;
178        self.total_read_characters += char_count;
179        self.tool_counts.read += 1;
180    }
181
182    /// Records the path of a successful non-text read without inventing lines.
183    pub(crate) fn add_non_text_read_path(&mut self, path: &str) {
184        let resolved = self.normalize_path(path);
185        if !resolved.is_empty() {
186            self.unique_files.insert(resolved);
187        }
188    }
189
190    /// Records a `Write` operation against `path` with the written `content`.
191    ///
192    /// Trailing newlines are stripped before counting. No-op when `path`
193    /// normalizes to an empty string (an empty body is still counted as a
194    /// zero-line write). The full detail (including `content`) is stored only in
195    /// [`ParseMode::Full`]; unique-file tracking remains active in both modes.
196    pub fn add_write_detail(&mut self, path: &str, content: &str, ts: i64) {
197        let trimmed = content.trim_end_matches('\n');
198        let line_count = count_lines(trimmed);
199        let char_count = trimmed.chars().count();
200        let resolved = self.normalize_path(path);
201
202        if resolved.is_empty() {
203            return;
204        }
205
206        if matches!(self.mode, ParseMode::Full) {
207            self.write_details.push(CodeAnalysisWriteDetail {
208                base: CodeAnalysisDetailBase {
209                    file_path: resolved.clone(),
210                    line_count,
211                    character_count: char_count,
212                    timestamp: ts,
213                },
214                content: trimmed.to_string(),
215            });
216        }
217        self.unique_files.insert(resolved);
218
219        self.total_write_lines += line_count;
220        self.total_write_characters += char_count;
221        self.tool_counts.write += 1;
222    }
223
224    /// Records an `Edit` operation against `path`, replacing `old` with `new`.
225    ///
226    /// When `old` is empty but `new` is not, the edit is reclassified as a
227    /// `Write` (a new-file creation expressed as a diff) and forwarded to
228    /// [`SessionParseState::add_write_detail`]. Otherwise delegates to
229    /// [`SessionParseState::add_edit_detail_raw`].
230    pub fn add_edit_detail(&mut self, path: &str, old: &str, new: &str, ts: i64) {
231        // If old is empty and new has content, treat as write
232        if old.trim_end_matches('\n').is_empty() && !new.trim_end_matches('\n').is_empty() {
233            self.add_write_detail(path, new, ts);
234            return;
235        }
236
237        self.add_edit_detail_raw(path, old, new, ts);
238    }
239
240    /// Records an `Edit` operation against `path` without the new-file
241    /// reclassification in [`SessionParseState::add_edit_detail`].
242    ///
243    /// Used for patch hunks that update an existing file, where an empty `old`
244    /// just means an insert-only hunk. The line/character tally is taken from
245    /// the trimmed `new` content. No-op when `path` normalizes to an empty
246    /// string; full detail stored only in [`ParseMode::Full`].
247    pub fn add_edit_detail_raw(&mut self, path: &str, old: &str, new: &str, ts: i64) {
248        let trimmed_new = new.trim_end_matches('\n');
249        let trimmed_old = old.trim_end_matches('\n');
250
251        let line_count = count_lines(trimmed_new);
252        let char_count = trimmed_new.chars().count();
253        let resolved = self.normalize_path(path);
254
255        if resolved.is_empty() {
256            return;
257        }
258
259        if matches!(self.mode, ParseMode::Full) {
260            self.edit_details.push(CodeAnalysisApplyDiffDetail {
261                base: CodeAnalysisDetailBase {
262                    file_path: resolved.clone(),
263                    line_count,
264                    character_count: char_count,
265                    timestamp: ts,
266                },
267                old_string: trimmed_old.to_string(),
268                new_string: trimmed_new.to_string(),
269            });
270        }
271        self.unique_files.insert(resolved);
272
273        self.total_edit_lines += line_count;
274        self.total_edit_characters += char_count;
275        self.tool_counts.edit += 1;
276    }
277
278    /// Records a `Bash`/run-command invocation with its `command` text.
279    ///
280    /// No-op (and no count bump) when `command` is empty after trimming. The
281    /// detail record (attributed to `folder_path`) is stored only in
282    /// [`ParseMode::Full`]; the `bash` count accrues in both modes.
283    pub fn add_run_command(&mut self, command: &str, description: &str, ts: i64) {
284        let command = command.trim();
285        if command.is_empty() {
286            return;
287        }
288
289        let command_chars = command.chars().count();
290
291        if matches!(self.mode, ParseMode::Full) {
292            self.run_details.push(CodeAnalysisRunCommandDetail {
293                base: CodeAnalysisDetailBase {
294                    file_path: self.folder_path.clone(),
295                    line_count: 0,
296                    character_count: command_chars,
297                    timestamp: ts,
298                },
299                command: command.to_string(),
300                description: description.to_string(),
301            });
302        }
303
304        self.tool_counts.bash += 1;
305    }
306
307    /// Resolves `path` to an absolute path, joining it onto `folder_path`.
308    ///
309    /// Returns the input unchanged when it is already absolute, when it is
310    /// empty, or when `folder_path` is empty (nothing to join against).
311    pub fn normalize_path(&self, path: &str) -> String {
312        if path.is_empty() {
313            return String::new();
314        }
315
316        let path_buf = std::path::PathBuf::from(path);
317        if path_buf.is_absolute() {
318            return path.to_string();
319        }
320
321        if self.folder_path.is_empty() {
322            return path.to_string();
323        }
324
325        std::path::PathBuf::from(&self.folder_path)
326            .join(path)
327            .to_string_lossy()
328            .to_string()
329    }
330
331    /// Merges another accumulator created with the same parse mode.
332    ///
333    /// This is used when a provider must deduplicate message revisions before
334    /// producing one session record. Detail vectors remain empty in
335    /// [`ParseMode::UsageOnly`], while scalar totals and unique paths are
336    /// combined in both modes.
337    pub fn merge(&mut self, mut other: Self) {
338        debug_assert_eq!(self.mode, other.mode);
339        self.write_details.append(&mut other.write_details);
340        self.read_details.append(&mut other.read_details);
341        self.edit_details.append(&mut other.edit_details);
342        self.run_details.append(&mut other.run_details);
343
344        self.tool_counts.read += other.tool_counts.read;
345        self.tool_counts.write += other.tool_counts.write;
346        self.tool_counts.edit += other.tool_counts.edit;
347        self.tool_counts.todo_write += other.tool_counts.todo_write;
348        self.tool_counts.bash += other.tool_counts.bash;
349        self.unique_files.extend(other.unique_files);
350
351        self.total_write_lines += other.total_write_lines;
352        self.total_read_lines += other.total_read_lines;
353        self.total_edit_lines += other.total_edit_lines;
354        self.total_write_characters += other.total_write_characters;
355        self.total_read_characters += other.total_read_characters;
356        self.total_edit_characters += other.total_edit_characters;
357        self.last_ts = self.last_ts.max(other.last_ts);
358
359        if self.folder_path.is_empty() {
360            self.folder_path = other.folder_path;
361        }
362        if self.git_remote.is_empty() {
363            self.git_remote = other.git_remote;
364        }
365        if self.task_id.is_empty() {
366            self.task_id = other.task_id;
367        }
368    }
369
370    /// Consumes the state into a finished [`CodeAnalysisRecord`].
371    ///
372    /// `conversation_usage` is the per-model token map the provider parser
373    /// accumulated separately; it is folded into the record verbatim.
374    pub fn into_record(self, conversation_usage: FastHashMap<String, Value>) -> CodeAnalysisRecord {
375        CodeAnalysisRecord {
376            total_unique_files: self.unique_files.len(),
377            total_write_lines: self.total_write_lines,
378            total_read_lines: self.total_read_lines,
379            total_edit_lines: self.total_edit_lines,
380            total_write_characters: self.total_write_characters,
381            total_read_characters: self.total_read_characters,
382            total_edit_characters: self.total_edit_characters,
383            write_file_details: self.write_details,
384            read_file_details: self.read_details,
385            edit_file_details: self.edit_details,
386            run_command_details: self.run_details,
387            tool_call_counts: self.tool_counts,
388            conversation_usage,
389            advisor_usage: FastHashMap::default(),
390            task_id: self.task_id,
391            timestamp: self.last_ts,
392            folder_path: self.folder_path,
393            git_remote_url: self.git_remote,
394        }
395    }
396}
397
398impl Default for SessionParseState {
399    /// Equivalent to [`SessionParseState::new`] ([`ParseMode::Full`]).
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_session_parse_state_new() {
411        // Test creating a new SessionParseState
412        let state = SessionParseState::new();
413
414        assert_eq!(state.total_write_lines, 0);
415        assert_eq!(state.total_read_lines, 0);
416        assert_eq!(state.total_edit_lines, 0);
417        assert_eq!(state.write_details.len(), 0);
418        assert_eq!(state.read_details.len(), 0);
419        assert_eq!(state.edit_details.len(), 0);
420        assert_eq!(state.unique_files.len(), 0);
421        assert!(state.folder_path.is_empty());
422    }
423
424    #[test]
425    fn test_add_read_detail() {
426        // Test adding a read operation
427        let mut state = SessionParseState::new();
428        state.folder_path = "/test/folder".to_string();
429
430        state.add_read_detail("test.rs", "line1\nline2\nline3", 1234567890);
431
432        assert_eq!(state.read_details.len(), 1);
433        assert_eq!(state.total_read_lines, 3);
434        assert_eq!(state.tool_counts.read, 1);
435        assert!(state.unique_files.contains("/test/folder/test.rs"));
436    }
437
438    #[test]
439    fn test_add_read_detail_ignores_empty() {
440        // Test that empty content is ignored
441        let mut state = SessionParseState::new();
442
443        state.add_read_detail("test.rs", "", 1234567890);
444
445        assert_eq!(state.read_details.len(), 0);
446        assert_eq!(state.total_read_lines, 0);
447        assert_eq!(state.tool_counts.read, 0);
448    }
449
450    #[test]
451    fn test_add_write_detail() {
452        // Test adding a write operation
453        let mut state = SessionParseState::new();
454        state.folder_path = "/test/folder".to_string();
455
456        state.add_write_detail("output.txt", "content line 1\ncontent line 2", 1234567890);
457
458        assert_eq!(state.write_details.len(), 1);
459        assert_eq!(state.total_write_lines, 2);
460        assert_eq!(state.tool_counts.write, 1);
461        assert!(state.unique_files.contains("/test/folder/output.txt"));
462    }
463
464    #[test]
465    fn test_add_edit_detail() {
466        // Test adding an edit operation
467        let mut state = SessionParseState::new();
468        state.folder_path = "/test".to_string();
469
470        state.add_edit_detail(
471            "file.rs",
472            "old content\nold line 2",
473            "new content\nnew line 2\nnew line 3",
474            1234567890,
475        );
476
477        assert_eq!(state.edit_details.len(), 1);
478        assert_eq!(state.total_edit_lines, 3);
479        assert_eq!(state.tool_counts.edit, 1);
480        assert!(state.unique_files.contains("/test/file.rs"));
481    }
482
483    #[test]
484    fn test_add_edit_detail_empty_old_becomes_write() {
485        // Test that edit with empty old content becomes a write
486        let mut state = SessionParseState::new();
487        state.folder_path = "/test".to_string();
488
489        state.add_edit_detail("new_file.rs", "", "new content", 1234567890);
490
491        // Should be recorded as write, not edit
492        assert_eq!(state.write_details.len(), 1);
493        assert_eq!(state.edit_details.len(), 0);
494        assert_eq!(state.tool_counts.write, 1);
495        assert_eq!(state.tool_counts.edit, 0);
496    }
497
498    #[test]
499    fn test_add_run_command() {
500        // Test adding a run command
501        let mut state = SessionParseState::new();
502        state.folder_path = "/workspace".to_string();
503
504        state.add_run_command("cargo test", "Running tests", 1234567890);
505
506        assert_eq!(state.run_details.len(), 1);
507        assert_eq!(state.tool_counts.bash, 1);
508        assert_eq!(state.run_details[0].command, "cargo test");
509    }
510
511    #[test]
512    fn test_add_run_command_ignores_empty() {
513        // Test that empty commands are ignored
514        let mut state = SessionParseState::new();
515
516        state.add_run_command("", "description", 1234567890);
517        state.add_run_command("   ", "description", 1234567890);
518
519        assert_eq!(state.run_details.len(), 0);
520        assert_eq!(state.tool_counts.bash, 0);
521    }
522
523    #[test]
524    fn test_normalize_path_absolute() {
525        // Test normalizing absolute paths
526        let mut state = SessionParseState::new();
527        state.folder_path = "/workspace".to_string();
528
529        let result = state.normalize_path("/absolute/path/file.rs");
530        assert_eq!(result, "/absolute/path/file.rs");
531    }
532
533    #[test]
534    fn test_normalize_path_relative() {
535        // Test normalizing relative paths
536        let mut state = SessionParseState::new();
537        state.folder_path = "/workspace".to_string();
538
539        let result = state.normalize_path("relative/file.rs");
540        assert_eq!(result, "/workspace/relative/file.rs");
541    }
542
543    #[test]
544    fn test_normalize_path_empty_folder() {
545        // Test normalizing when folder_path is empty
546        let state = SessionParseState::new();
547
548        let result = state.normalize_path("file.rs");
549        assert_eq!(result, "file.rs");
550    }
551
552    #[test]
553    fn test_normalize_path_empty_input() {
554        // Test normalizing empty path
555        let mut state = SessionParseState::new();
556        state.folder_path = "/workspace".to_string();
557
558        let result = state.normalize_path("");
559        assert_eq!(result, "");
560    }
561
562    #[test]
563    fn test_unique_files_tracking() {
564        // Test that unique files are tracked correctly
565        let mut state = SessionParseState::new();
566        state.folder_path = "/project".to_string();
567
568        // Add operations on same file
569        state.add_read_detail("file1.rs", "content", 1);
570        state.add_write_detail("file1.rs", "content", 2);
571        state.add_edit_detail("file1.rs", "old", "new", 3);
572
573        // Add operations on different file
574        state.add_read_detail("file2.rs", "content", 4);
575
576        assert_eq!(state.unique_files.len(), 2);
577        assert!(state.unique_files.contains("/project/file1.rs"));
578        assert!(state.unique_files.contains("/project/file2.rs"));
579    }
580
581    #[test]
582    fn test_character_counting() {
583        // Test that character counts are correct
584        let mut state = SessionParseState::new();
585
586        state.add_read_detail("file.txt", "hello", 1);
587        assert_eq!(state.total_read_characters, 5);
588
589        state.add_write_detail("file2.txt", "world!", 2);
590        assert_eq!(state.total_write_characters, 6);
591
592        state.add_edit_detail("file3.txt", "old", "new content", 3);
593        assert_eq!(state.total_edit_characters, 11);
594    }
595
596    #[test]
597    fn test_into_record() {
598        // Test converting state into a record
599        let mut state = SessionParseState::new();
600        state.folder_path = "/test".to_string();
601        state.git_remote = "https://github.com/test/repo".to_string();
602        state.task_id = "task-123".to_string();
603        state.last_ts = 9999999999;
604
605        state.add_read_detail("file.rs", "line1\nline2", 1);
606        state.add_write_detail("output.rs", "content", 2);
607
608        let usage = FastHashMap::default();
609        let record = state.into_record(usage);
610
611        assert_eq!(record.total_unique_files, 2);
612        assert_eq!(record.total_read_lines, 2);
613        assert_eq!(record.total_write_lines, 1);
614        assert_eq!(record.folder_path, "/test");
615        assert_eq!(record.git_remote_url, "https://github.com/test/repo");
616        assert_eq!(record.task_id, "task-123");
617        assert_eq!(record.timestamp, 9999999999);
618    }
619
620    #[test]
621    fn test_default_trait() {
622        // Test Default trait implementation
623        let state = SessionParseState::default();
624
625        assert_eq!(state.total_write_lines, 0);
626        assert_eq!(state.total_read_lines, 0);
627        assert_eq!(state.total_edit_lines, 0);
628    }
629
630    #[test]
631    fn test_multiple_operations() {
632        // Test handling multiple operations
633        let mut state = SessionParseState::new();
634        state.folder_path = "/workspace".to_string();
635
636        // Multiple reads
637        state.add_read_detail("a.rs", "line1", 1);
638        state.add_read_detail("b.rs", "line1\nline2", 2);
639        state.add_read_detail("c.rs", "line1\nline2\nline3", 3);
640
641        // Multiple writes
642        state.add_write_detail("out1.txt", "content1", 4);
643        state.add_write_detail("out2.txt", "content2", 5);
644
645        // Multiple edits
646        state.add_edit_detail("edit1.rs", "old", "new", 6);
647
648        // Multiple commands
649        state.add_run_command("ls", "list files", 7);
650        state.add_run_command("pwd", "print dir", 8);
651
652        assert_eq!(state.read_details.len(), 3);
653        assert_eq!(state.write_details.len(), 2);
654        assert_eq!(state.edit_details.len(), 1);
655        assert_eq!(state.run_details.len(), 2);
656        assert_eq!(state.total_read_lines, 6); // 1 + 2 + 3
657        assert_eq!(state.tool_counts.read, 3);
658        assert_eq!(state.tool_counts.write, 2);
659        assert_eq!(state.tool_counts.edit, 1);
660        assert_eq!(state.tool_counts.bash, 2);
661    }
662}