Skip to main content

tracevault_core/
session.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5
6use crate::hooks::HookEvent;
7
8/// Tracks file states during an active AI session
9#[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    /// file path -> content hash before AI modification
17    pub pre_edit_hashes: HashMap<String, String>,
18    /// files modified by AI during this session
19    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        // Track AI-modified files
54        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    /// Path to session data directory: .tracevault/sessions/<session_id>/
70    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}