1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::hooks::HookEvent;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SessionState {
11 pub session_id: String,
12 pub transcript_path: String,
13 pub cwd: String,
14 pub started_at: DateTime<Utc>,
15 pub events: Vec<SessionEvent>,
16 pub pre_edit_hashes: HashMap<String, String>,
18 pub ai_modified_files: Vec<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct SessionEvent {
24 pub timestamp: DateTime<Utc>,
25 pub event_type: String,
26 pub tool_name: Option<String>,
27 pub file_path: Option<String>,
28 pub details: Option<serde_json::Value>,
29}
30
31impl SessionState {
32 pub fn new(event: &HookEvent) -> Self {
33 Self {
34 session_id: event.session_id.clone(),
35 transcript_path: event.transcript_path.clone(),
36 cwd: event.cwd.clone(),
37 started_at: Utc::now(),
38 events: vec![],
39 pre_edit_hashes: HashMap::new(),
40 ai_modified_files: vec![],
41 }
42 }
43
44 pub fn record_event(&mut self, event: &HookEvent) {
45 self.events.push(SessionEvent {
46 timestamp: Utc::now(),
47 event_type: event.hook_event_name.clone(),
48 tool_name: event.tool_name.clone(),
49 file_path: event.file_path(),
50 details: event.tool_input.clone(),
51 });
52
53 if event.hook_event_name == "PostToolUse" && event.is_file_modification() {
55 if let Some(path) = event.file_path() {
56 if !self.ai_modified_files.contains(&path) {
57 self.ai_modified_files.push(path);
58 }
59 }
60 }
61 }
62
63 pub fn record_pre_edit_hash(&mut self, file_path: &str, hash: &str) {
64 self.pre_edit_hashes
65 .entry(file_path.to_string())
66 .or_insert_with(|| hash.to_string());
67 }
68
69 pub fn session_dir(&self) -> PathBuf {
71 PathBuf::from(&self.cwd)
72 .join(".tracevault")
73 .join("sessions")
74 .join(&self.session_id)
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use serde_json::json;
82
83 fn make_hook_event(
84 hook_event_name: &str,
85 tool_name: Option<&str>,
86 tool_input: Option<serde_json::Value>,
87 ) -> HookEvent {
88 HookEvent {
89 session_id: "sess-123".to_string(),
90 transcript_path: "/tmp/transcript.json".to_string(),
91 cwd: "/home/user/project".to_string(),
92 permission_mode: None,
93 hook_event_name: hook_event_name.to_string(),
94 tool_name: tool_name.map(String::from),
95 tool_input,
96 tool_response: None,
97 tool_use_id: None,
98 }
99 }
100
101 #[test]
102 fn new_initializes_correctly() {
103 let event = make_hook_event("Init", None, None);
104 let state = SessionState::new(&event);
105
106 assert_eq!(state.session_id, "sess-123");
107 assert_eq!(state.transcript_path, "/tmp/transcript.json");
108 assert_eq!(state.cwd, "/home/user/project");
109 assert!(state.events.is_empty());
110 assert!(state.pre_edit_hashes.is_empty());
111 assert!(state.ai_modified_files.is_empty());
112 }
113
114 #[test]
115 fn record_event_appends() {
116 let init = make_hook_event("Init", None, None);
117 let mut state = SessionState::new(&init);
118
119 let evt = make_hook_event("PreToolUse", Some("Read"), None);
120 state.record_event(&evt);
121
122 assert_eq!(state.events.len(), 1);
123 assert_eq!(state.events[0].event_type, "PreToolUse");
124 assert_eq!(state.events[0].tool_name.as_deref(), Some("Read"));
125 }
126
127 #[test]
128 fn record_event_post_tool_use_file_mod_adds_to_ai_modified() {
129 let init = make_hook_event("Init", None, None);
130 let mut state = SessionState::new(&init);
131
132 let evt = make_hook_event(
133 "PostToolUse",
134 Some("Write"),
135 Some(json!({"file_path": "/tmp/test.rs"})),
136 );
137 state.record_event(&evt);
138
139 assert_eq!(state.ai_modified_files, vec!["/tmp/test.rs"]);
140 }
141
142 #[test]
143 fn record_event_pre_tool_use_file_mod_does_not_add() {
144 let init = make_hook_event("Init", None, None);
145 let mut state = SessionState::new(&init);
146
147 let evt = make_hook_event(
148 "PreToolUse",
149 Some("Write"),
150 Some(json!({"file_path": "/tmp/test.rs"})),
151 );
152 state.record_event(&evt);
153
154 assert!(state.ai_modified_files.is_empty());
155 }
156
157 #[test]
158 fn record_event_post_tool_use_non_file_does_not_add() {
159 let init = make_hook_event("Init", None, None);
160 let mut state = SessionState::new(&init);
161
162 let evt = make_hook_event(
163 "PostToolUse",
164 Some("Read"),
165 Some(json!({"file_path": "/tmp/test.rs"})),
166 );
167 state.record_event(&evt);
168
169 assert!(state.ai_modified_files.is_empty());
170 }
171
172 #[test]
173 fn record_pre_edit_hash_inserts() {
174 let init = make_hook_event("Init", None, None);
175 let mut state = SessionState::new(&init);
176
177 state.record_pre_edit_hash("/tmp/test.rs", "abc123");
178
179 assert_eq!(
180 state.pre_edit_hashes.get("/tmp/test.rs"),
181 Some(&"abc123".to_string())
182 );
183 }
184
185 #[test]
186 fn record_pre_edit_hash_does_not_overwrite() {
187 let init = make_hook_event("Init", None, None);
188 let mut state = SessionState::new(&init);
189
190 state.record_pre_edit_hash("/tmp/test.rs", "first");
191 state.record_pre_edit_hash("/tmp/test.rs", "second");
192
193 assert_eq!(
194 state.pre_edit_hashes.get("/tmp/test.rs"),
195 Some(&"first".to_string())
196 );
197 }
198
199 #[test]
200 fn session_dir_returns_correct_path() {
201 let init = make_hook_event("Init", None, None);
202 let state = SessionState::new(&init);
203
204 let dir = state.session_dir();
205 assert!(dir.ends_with("sessions/sess-123"));
206 assert_eq!(
207 dir,
208 PathBuf::from("/home/user/project/.tracevault/sessions/sess-123")
209 );
210 }
211}