1use crate::constants::{FastHashMap, FastHashSet};
10use crate::models::*;
11use crate::utils::count_lines;
12use serde_json::Value;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ParseMode {
26 Full,
29 UsageOnly,
31}
32
33pub struct SessionParseState {
43 pub mode: ParseMode,
45 pub write_details: Vec<CodeAnalysisWriteDetail>,
47 pub read_details: Vec<CodeAnalysisReadDetail>,
49 pub edit_details: Vec<CodeAnalysisApplyDiffDetail>,
51 pub run_details: Vec<CodeAnalysisRunCommandDetail>,
53 pub tool_counts: CodeAnalysisToolCalls,
55 pub unique_files: FastHashSet<String>,
57 pub total_write_lines: usize,
59 pub total_read_lines: usize,
61 pub total_edit_lines: usize,
63 pub total_write_characters: usize,
65 pub total_read_characters: usize,
67 pub total_edit_characters: usize,
69 pub folder_path: String,
71 pub git_remote: String,
73 pub task_id: String,
75 pub last_ts: i64,
77}
78
79impl SessionParseState {
80 pub fn new() -> Self {
94 Self::with_mode(ParseMode::Full)
95 }
96
97 pub fn with_mode(mode: ParseMode) -> Self {
103 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 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 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 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 pub fn add_edit_detail(&mut self, path: &str, old: &str, new: &str, ts: i64) {
231 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mut state = SessionParseState::new();
566 state.folder_path = "/project".to_string();
567
568 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 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 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 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 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 let mut state = SessionParseState::new();
634 state.folder_path = "/workspace".to_string();
635
636 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 state.add_write_detail("out1.txt", "content1", 4);
643 state.add_write_detail("out2.txt", "content2", 5);
644
645 state.add_edit_detail("edit1.rs", "old", "new", 6);
647
648 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); 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}