use crate::constants::{FastHashMap, FastHashSet};
use crate::models::*;
use crate::utils::count_lines;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseMode {
Full,
UsageOnly,
}
pub struct SessionParseState {
pub mode: ParseMode,
pub write_details: Vec<CodeAnalysisWriteDetail>,
pub read_details: Vec<CodeAnalysisReadDetail>,
pub edit_details: Vec<CodeAnalysisApplyDiffDetail>,
pub run_details: Vec<CodeAnalysisRunCommandDetail>,
pub tool_counts: CodeAnalysisToolCalls,
pub unique_files: FastHashSet<String>,
pub total_write_lines: usize,
pub total_read_lines: usize,
pub total_edit_lines: usize,
pub total_write_characters: usize,
pub total_read_characters: usize,
pub total_edit_characters: usize,
pub folder_path: String,
pub git_remote: String,
pub task_id: String,
pub last_ts: i64,
}
impl SessionParseState {
pub fn new() -> Self {
Self::with_mode(ParseMode::Full)
}
pub fn with_mode(mode: ParseMode) -> Self {
let pre = matches!(mode, ParseMode::Full);
Self {
mode,
write_details: if pre {
Vec::with_capacity(10)
} else {
Vec::new()
},
read_details: if pre {
Vec::with_capacity(20)
} else {
Vec::new()
},
edit_details: if pre {
Vec::with_capacity(15)
} else {
Vec::new()
},
run_details: if pre {
Vec::with_capacity(10)
} else {
Vec::new()
},
tool_counts: CodeAnalysisToolCalls::default(),
unique_files: FastHashSet::with_capacity(20),
total_write_lines: 0,
total_read_lines: 0,
total_edit_lines: 0,
total_write_characters: 0,
total_read_characters: 0,
total_edit_characters: 0,
folder_path: String::new(),
git_remote: String::new(),
task_id: String::new(),
last_ts: 0,
}
}
pub fn add_read_detail(&mut self, path: &str, content: &str, ts: i64) {
let trimmed = content.trim_end_matches('\n');
let line_count = count_lines(trimmed);
if line_count == 0 {
return;
}
let char_count = trimmed.chars().count();
let resolved = self.normalize_path(path);
if resolved.is_empty() {
return;
}
if matches!(self.mode, ParseMode::Full) {
self.read_details.push(CodeAnalysisReadDetail {
base: CodeAnalysisDetailBase {
file_path: resolved.clone(),
line_count,
character_count: char_count,
timestamp: ts,
},
});
}
self.unique_files.insert(resolved);
self.total_read_lines += line_count;
self.total_read_characters += char_count;
self.tool_counts.read += 1;
}
pub(crate) fn add_non_text_read_path(&mut self, path: &str) {
let resolved = self.normalize_path(path);
if !resolved.is_empty() {
self.unique_files.insert(resolved);
}
}
pub fn add_write_detail(&mut self, path: &str, content: &str, ts: i64) {
let trimmed = content.trim_end_matches('\n');
let line_count = count_lines(trimmed);
let char_count = trimmed.chars().count();
let resolved = self.normalize_path(path);
if resolved.is_empty() {
return;
}
if matches!(self.mode, ParseMode::Full) {
self.write_details.push(CodeAnalysisWriteDetail {
base: CodeAnalysisDetailBase {
file_path: resolved.clone(),
line_count,
character_count: char_count,
timestamp: ts,
},
content: trimmed.to_string(),
});
}
self.unique_files.insert(resolved);
self.total_write_lines += line_count;
self.total_write_characters += char_count;
self.tool_counts.write += 1;
}
pub fn add_edit_detail(&mut self, path: &str, old: &str, new: &str, ts: i64) {
if old.trim_end_matches('\n').is_empty() && !new.trim_end_matches('\n').is_empty() {
self.add_write_detail(path, new, ts);
return;
}
self.add_edit_detail_raw(path, old, new, ts);
}
pub fn add_edit_detail_raw(&mut self, path: &str, old: &str, new: &str, ts: i64) {
let trimmed_new = new.trim_end_matches('\n');
let trimmed_old = old.trim_end_matches('\n');
let line_count = count_lines(trimmed_new);
let char_count = trimmed_new.chars().count();
let resolved = self.normalize_path(path);
if resolved.is_empty() {
return;
}
if matches!(self.mode, ParseMode::Full) {
self.edit_details.push(CodeAnalysisApplyDiffDetail {
base: CodeAnalysisDetailBase {
file_path: resolved.clone(),
line_count,
character_count: char_count,
timestamp: ts,
},
old_string: trimmed_old.to_string(),
new_string: trimmed_new.to_string(),
});
}
self.unique_files.insert(resolved);
self.total_edit_lines += line_count;
self.total_edit_characters += char_count;
self.tool_counts.edit += 1;
}
pub fn add_run_command(&mut self, command: &str, description: &str, ts: i64) {
let command = command.trim();
if command.is_empty() {
return;
}
let command_chars = command.chars().count();
if matches!(self.mode, ParseMode::Full) {
self.run_details.push(CodeAnalysisRunCommandDetail {
base: CodeAnalysisDetailBase {
file_path: self.folder_path.clone(),
line_count: 0,
character_count: command_chars,
timestamp: ts,
},
command: command.to_string(),
description: description.to_string(),
});
}
self.tool_counts.bash += 1;
}
pub fn normalize_path(&self, path: &str) -> String {
if path.is_empty() {
return String::new();
}
let path_buf = std::path::PathBuf::from(path);
if path_buf.is_absolute() {
return path.to_string();
}
if self.folder_path.is_empty() {
return path.to_string();
}
std::path::PathBuf::from(&self.folder_path)
.join(path)
.to_string_lossy()
.to_string()
}
pub fn merge(&mut self, mut other: Self) {
debug_assert_eq!(self.mode, other.mode);
self.write_details.append(&mut other.write_details);
self.read_details.append(&mut other.read_details);
self.edit_details.append(&mut other.edit_details);
self.run_details.append(&mut other.run_details);
self.tool_counts.read += other.tool_counts.read;
self.tool_counts.write += other.tool_counts.write;
self.tool_counts.edit += other.tool_counts.edit;
self.tool_counts.todo_write += other.tool_counts.todo_write;
self.tool_counts.bash += other.tool_counts.bash;
self.unique_files.extend(other.unique_files);
self.total_write_lines += other.total_write_lines;
self.total_read_lines += other.total_read_lines;
self.total_edit_lines += other.total_edit_lines;
self.total_write_characters += other.total_write_characters;
self.total_read_characters += other.total_read_characters;
self.total_edit_characters += other.total_edit_characters;
self.last_ts = self.last_ts.max(other.last_ts);
if self.folder_path.is_empty() {
self.folder_path = other.folder_path;
}
if self.git_remote.is_empty() {
self.git_remote = other.git_remote;
}
if self.task_id.is_empty() {
self.task_id = other.task_id;
}
}
pub fn into_record(self, conversation_usage: FastHashMap<String, Value>) -> CodeAnalysisRecord {
CodeAnalysisRecord {
total_unique_files: self.unique_files.len(),
total_write_lines: self.total_write_lines,
total_read_lines: self.total_read_lines,
total_edit_lines: self.total_edit_lines,
total_write_characters: self.total_write_characters,
total_read_characters: self.total_read_characters,
total_edit_characters: self.total_edit_characters,
write_file_details: self.write_details,
read_file_details: self.read_details,
edit_file_details: self.edit_details,
run_command_details: self.run_details,
tool_call_counts: self.tool_counts,
conversation_usage,
advisor_usage: FastHashMap::default(),
task_id: self.task_id,
timestamp: self.last_ts,
folder_path: self.folder_path,
git_remote_url: self.git_remote,
}
}
}
impl Default for SessionParseState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_parse_state_new() {
let state = SessionParseState::new();
assert_eq!(state.total_write_lines, 0);
assert_eq!(state.total_read_lines, 0);
assert_eq!(state.total_edit_lines, 0);
assert_eq!(state.write_details.len(), 0);
assert_eq!(state.read_details.len(), 0);
assert_eq!(state.edit_details.len(), 0);
assert_eq!(state.unique_files.len(), 0);
assert!(state.folder_path.is_empty());
}
#[test]
fn test_add_read_detail() {
let mut state = SessionParseState::new();
state.folder_path = "/test/folder".to_string();
state.add_read_detail("test.rs", "line1\nline2\nline3", 1234567890);
assert_eq!(state.read_details.len(), 1);
assert_eq!(state.total_read_lines, 3);
assert_eq!(state.tool_counts.read, 1);
assert!(state.unique_files.contains("/test/folder/test.rs"));
}
#[test]
fn test_add_read_detail_ignores_empty() {
let mut state = SessionParseState::new();
state.add_read_detail("test.rs", "", 1234567890);
assert_eq!(state.read_details.len(), 0);
assert_eq!(state.total_read_lines, 0);
assert_eq!(state.tool_counts.read, 0);
}
#[test]
fn test_add_write_detail() {
let mut state = SessionParseState::new();
state.folder_path = "/test/folder".to_string();
state.add_write_detail("output.txt", "content line 1\ncontent line 2", 1234567890);
assert_eq!(state.write_details.len(), 1);
assert_eq!(state.total_write_lines, 2);
assert_eq!(state.tool_counts.write, 1);
assert!(state.unique_files.contains("/test/folder/output.txt"));
}
#[test]
fn test_add_edit_detail() {
let mut state = SessionParseState::new();
state.folder_path = "/test".to_string();
state.add_edit_detail(
"file.rs",
"old content\nold line 2",
"new content\nnew line 2\nnew line 3",
1234567890,
);
assert_eq!(state.edit_details.len(), 1);
assert_eq!(state.total_edit_lines, 3);
assert_eq!(state.tool_counts.edit, 1);
assert!(state.unique_files.contains("/test/file.rs"));
}
#[test]
fn test_add_edit_detail_empty_old_becomes_write() {
let mut state = SessionParseState::new();
state.folder_path = "/test".to_string();
state.add_edit_detail("new_file.rs", "", "new content", 1234567890);
assert_eq!(state.write_details.len(), 1);
assert_eq!(state.edit_details.len(), 0);
assert_eq!(state.tool_counts.write, 1);
assert_eq!(state.tool_counts.edit, 0);
}
#[test]
fn test_add_run_command() {
let mut state = SessionParseState::new();
state.folder_path = "/workspace".to_string();
state.add_run_command("cargo test", "Running tests", 1234567890);
assert_eq!(state.run_details.len(), 1);
assert_eq!(state.tool_counts.bash, 1);
assert_eq!(state.run_details[0].command, "cargo test");
}
#[test]
fn test_add_run_command_ignores_empty() {
let mut state = SessionParseState::new();
state.add_run_command("", "description", 1234567890);
state.add_run_command(" ", "description", 1234567890);
assert_eq!(state.run_details.len(), 0);
assert_eq!(state.tool_counts.bash, 0);
}
#[test]
fn test_normalize_path_absolute() {
let mut state = SessionParseState::new();
state.folder_path = "/workspace".to_string();
let result = state.normalize_path("/absolute/path/file.rs");
assert_eq!(result, "/absolute/path/file.rs");
}
#[test]
fn test_normalize_path_relative() {
let mut state = SessionParseState::new();
state.folder_path = "/workspace".to_string();
let result = state.normalize_path("relative/file.rs");
assert_eq!(result, "/workspace/relative/file.rs");
}
#[test]
fn test_normalize_path_empty_folder() {
let state = SessionParseState::new();
let result = state.normalize_path("file.rs");
assert_eq!(result, "file.rs");
}
#[test]
fn test_normalize_path_empty_input() {
let mut state = SessionParseState::new();
state.folder_path = "/workspace".to_string();
let result = state.normalize_path("");
assert_eq!(result, "");
}
#[test]
fn test_unique_files_tracking() {
let mut state = SessionParseState::new();
state.folder_path = "/project".to_string();
state.add_read_detail("file1.rs", "content", 1);
state.add_write_detail("file1.rs", "content", 2);
state.add_edit_detail("file1.rs", "old", "new", 3);
state.add_read_detail("file2.rs", "content", 4);
assert_eq!(state.unique_files.len(), 2);
assert!(state.unique_files.contains("/project/file1.rs"));
assert!(state.unique_files.contains("/project/file2.rs"));
}
#[test]
fn test_character_counting() {
let mut state = SessionParseState::new();
state.add_read_detail("file.txt", "hello", 1);
assert_eq!(state.total_read_characters, 5);
state.add_write_detail("file2.txt", "world!", 2);
assert_eq!(state.total_write_characters, 6);
state.add_edit_detail("file3.txt", "old", "new content", 3);
assert_eq!(state.total_edit_characters, 11);
}
#[test]
fn test_into_record() {
let mut state = SessionParseState::new();
state.folder_path = "/test".to_string();
state.git_remote = "https://github.com/test/repo".to_string();
state.task_id = "task-123".to_string();
state.last_ts = 9999999999;
state.add_read_detail("file.rs", "line1\nline2", 1);
state.add_write_detail("output.rs", "content", 2);
let usage = FastHashMap::default();
let record = state.into_record(usage);
assert_eq!(record.total_unique_files, 2);
assert_eq!(record.total_read_lines, 2);
assert_eq!(record.total_write_lines, 1);
assert_eq!(record.folder_path, "/test");
assert_eq!(record.git_remote_url, "https://github.com/test/repo");
assert_eq!(record.task_id, "task-123");
assert_eq!(record.timestamp, 9999999999);
}
#[test]
fn test_default_trait() {
let state = SessionParseState::default();
assert_eq!(state.total_write_lines, 0);
assert_eq!(state.total_read_lines, 0);
assert_eq!(state.total_edit_lines, 0);
}
#[test]
fn test_multiple_operations() {
let mut state = SessionParseState::new();
state.folder_path = "/workspace".to_string();
state.add_read_detail("a.rs", "line1", 1);
state.add_read_detail("b.rs", "line1\nline2", 2);
state.add_read_detail("c.rs", "line1\nline2\nline3", 3);
state.add_write_detail("out1.txt", "content1", 4);
state.add_write_detail("out2.txt", "content2", 5);
state.add_edit_detail("edit1.rs", "old", "new", 6);
state.add_run_command("ls", "list files", 7);
state.add_run_command("pwd", "print dir", 8);
assert_eq!(state.read_details.len(), 3);
assert_eq!(state.write_details.len(), 2);
assert_eq!(state.edit_details.len(), 1);
assert_eq!(state.run_details.len(), 2);
assert_eq!(state.total_read_lines, 6); assert_eq!(state.tool_counts.read, 3);
assert_eq!(state.tool_counts.write, 2);
assert_eq!(state.tool_counts.edit, 1);
assert_eq!(state.tool_counts.bash, 2);
}
}