1use super::entry::{TxAction, TxEntry};
10use ryo_analysis::SymbolPath;
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13use std::time::Instant;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TxSummary {
18 pub total_entries: usize,
20 pub total_mutations: usize,
22 pub total_changes: usize,
24 pub files_modified: usize,
26 pub duration_ms: u64,
28 pub checkpoints: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TxLog {
35 entries: Vec<TxEntry>,
37
38 pub session_id: String,
40 pub project_path: String,
43 pub started_at: String, pub ended_at: Option<String>,
48
49 #[serde(skip)]
51 session_start: Option<Instant>,
52}
53
54impl Default for TxLog {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl TxLog {
61 pub fn new() -> Self {
63 Self {
64 entries: Vec::new(),
65 session_id: uuid_v4(),
66 project_path: String::new(),
67 started_at: chrono_now(),
68 ended_at: None,
69 session_start: Some(Instant::now()),
70 }
71 }
72
73 pub fn with_project(project_path: impl Into<String>) -> Self {
75 let mut log = Self::new();
76 log.project_path = project_path.into();
77 log
78 }
79
80 pub fn push(&mut self, entry: TxEntry) {
82 self.entries.push(entry);
83 }
84
85 pub fn log(&mut self, action: TxAction) -> u64 {
87 let id = self.entries.len() as u64;
88 let timestamp_ms = self
89 .session_start
90 .map(|s| s.elapsed().as_millis() as u64)
91 .unwrap_or(0);
92
93 self.entries.push(TxEntry::new(id, timestamp_ms, action));
94 id
95 }
96
97 pub fn entries(&self) -> &[TxEntry] {
99 &self.entries
100 }
101
102 pub fn get(&self, id: u64) -> Option<&TxEntry> {
104 self.entries.get(id as usize)
105 }
106
107 pub fn len(&self) -> usize {
109 self.entries.len()
110 }
111
112 pub fn is_empty(&self) -> bool {
114 self.entries.is_empty()
115 }
116
117 pub fn iter(&self) -> impl Iterator<Item = &TxEntry> {
119 self.entries.iter()
120 }
121
122 pub fn iter_replayable(&self) -> impl Iterator<Item = &TxEntry> {
124 self.entries.iter().filter(|e| e.action.is_replayable())
125 }
126
127 pub fn iter_mutations(&self) -> impl Iterator<Item = &TxEntry> {
129 self.entries.iter().filter(|e| e.action.is_mutation())
130 }
131
132 pub fn mutations_affecting(&self, symbol: &SymbolPath) -> Vec<&TxEntry> {
137 self.entries
138 .iter()
139 .filter(|e| match &e.action {
140 TxAction::MutationApplied {
141 affected_symbols, ..
142 } => affected_symbols
143 .iter()
144 .any(|s| s == symbol || s.is_ancestor_of(symbol)),
145 _ => false,
146 })
147 .collect()
148 }
149
150 pub fn mutations_affecting_subtree(&self, symbol: &SymbolPath) -> Vec<&TxEntry> {
155 self.entries
156 .iter()
157 .filter(|e| match &e.action {
158 TxAction::MutationApplied {
159 affected_symbols, ..
160 } => affected_symbols
161 .iter()
162 .any(|s| s == symbol || s.is_ancestor_of(symbol) || s.is_descendant_of(symbol)),
163 _ => false,
164 })
165 .collect()
166 }
167
168 pub fn entries_since_checkpoint(&self, checkpoint_name: &str) -> Vec<&TxEntry> {
170 let checkpoint_idx = self.entries.iter().rposition(
171 |e| matches!(&e.action, TxAction::Checkpoint { name } if name == checkpoint_name),
172 );
173
174 match checkpoint_idx {
175 Some(idx) => self.entries[idx + 1..].iter().collect(),
176 None => Vec::new(),
177 }
178 }
179
180 pub fn last_n(&self, n: usize) -> &[TxEntry] {
182 let start = self.entries.len().saturating_sub(n);
183 &self.entries[start..]
184 }
185
186 pub fn end_session(&mut self) {
188 self.ended_at = Some(chrono_now());
189 }
190
191 pub fn summary(&self) -> TxSummary {
193 let total_mutations = self
194 .entries
195 .iter()
196 .filter(|e| e.action.is_mutation())
197 .count();
198
199 let total_changes: usize = self
200 .entries
201 .iter()
202 .map(|e| match &e.action {
203 TxAction::MutationApplied { changes, .. } => *changes,
204 TxAction::MutationBatch { total_changes, .. } => *total_changes,
205 TxAction::FileModified { changes, .. } => *changes,
206 _ => 0,
207 })
208 .sum();
209
210 let files_modified: usize = self
211 .entries
212 .iter()
213 .filter(|e| {
214 matches!(
215 &e.action,
216 TxAction::FileModified { .. } | TxAction::FileWritten { .. }
217 )
218 })
219 .count();
220
221 let checkpoints: Vec<String> = self
222 .entries
223 .iter()
224 .filter_map(|e| match &e.action {
225 TxAction::Checkpoint { name } => Some(name.clone()),
226 _ => None,
227 })
228 .collect();
229
230 let duration_ms = self.entries.last().map(|e| e.timestamp_ms).unwrap_or(0);
231
232 TxSummary {
233 total_entries: self.entries.len(),
234 total_mutations,
235 total_changes,
236 files_modified,
237 duration_ms,
238 checkpoints,
239 }
240 }
241
242 pub fn dump_json(&self, path: &Path) -> std::io::Result<()> {
248 let json = serde_json::to_string_pretty(self)
249 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
250 std::fs::write(path, json)
251 }
252
253 pub fn dump_json_compact(&self, path: &Path) -> std::io::Result<()> {
255 let json = serde_json::to_string(self)
256 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
257 std::fs::write(path, json)
258 }
259
260 pub fn load_json(path: &Path) -> std::io::Result<Self> {
262 let json = std::fs::read_to_string(path)?;
263 serde_json::from_str(&json)
264 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
265 }
266
267 pub fn to_json(&self) -> Result<String, serde_json::Error> {
269 serde_json::to_string_pretty(self)
270 }
271
272 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
274 serde_json::from_str(json)
275 }
276}
277
278#[cfg(test)]
284pub struct TxReplay<'a> {
285 log: &'a TxLog,
286 position: usize,
287}
288
289#[cfg(test)]
290impl<'a> TxReplay<'a> {
291 pub fn new(log: &'a TxLog) -> Self {
292 Self { log, position: 0 }
293 }
294
295 pub fn position(&self) -> usize {
296 self.position
297 }
298
299 pub fn step(&mut self) -> Option<&'a TxEntry> {
300 if self.position < self.log.len() {
301 let entry = &self.log.entries[self.position];
302 self.position += 1;
303 Some(entry)
304 } else {
305 None
306 }
307 }
308
309 pub fn seek_checkpoint(&mut self, name: &str) -> bool {
310 for (i, entry) in self.log.entries.iter().enumerate() {
311 if matches!(&entry.action, TxAction::Checkpoint { name: n } if n == name) {
312 self.position = i;
313 return true;
314 }
315 }
316 false
317 }
318}
319
320fn uuid_v4() -> String {
326 use std::time::{SystemTime, UNIX_EPOCH};
327 let now = SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .unwrap_or_default();
330 format!(
331 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
332 now.as_secs() as u32,
333 ((now.as_nanos() >> 16) as u16),
334 (now.as_nanos() >> 32) as u16 & 0x0FFF,
335 ((now.as_nanos() >> 48) as u16 & 0x3FFF) | 0x8000,
336 now.as_nanos() as u64 & 0xFFFFFFFFFFFF,
337 )
338}
339
340fn chrono_now() -> String {
342 use std::time::{SystemTime, UNIX_EPOCH};
343 let now = SystemTime::now()
344 .duration_since(UNIX_EPOCH)
345 .unwrap_or_default();
346 format!("{}Z", now.as_secs())
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
355 fn test_log_basic() {
356 let mut log = TxLog::new();
357
358 log.log(TxAction::SessionStart {
359 project_path: "/test".into(),
360 file_count: 10,
361 });
362
363 log.log(TxAction::MutationApplied {
364 mutation_type: "Rename".to_string(),
365 target: "foo -> bar".to_string(),
366 changes: 5,
367 mutation_data: None,
368 file_path: None,
369 pre_state: None,
370 post_state: None,
371 affected_symbols: vec![],
372 });
373
374 assert_eq!(log.len(), 2);
375 assert_eq!(log.iter_mutations().count(), 1);
376 }
377
378 #[test]
379 fn test_log_serialization() {
380 let mut log = TxLog::with_project("/test/project");
381
382 log.log(TxAction::GoalSet {
383 query: "rename test".to_string(),
384 intent_type: "RenameIdent".to_string(),
385 confidence: 0.9,
386 });
387
388 let json = log.to_json().unwrap();
389 let loaded = TxLog::from_json(&json).unwrap();
390
391 assert_eq!(loaded.len(), 1);
392 assert_eq!(loaded.project_path, "/test/project");
393 }
394
395 #[test]
396 fn test_replay() {
397 let mut log = TxLog::new();
398
399 log.log(TxAction::Checkpoint {
400 name: "start".to_string(),
401 });
402 log.log(TxAction::MutationApplied {
403 mutation_type: "Rename".to_string(),
404 target: "a".to_string(),
405 changes: 1,
406 mutation_data: None,
407 file_path: None,
408 pre_state: None,
409 post_state: None,
410 affected_symbols: vec![],
411 });
412 log.log(TxAction::MutationApplied {
413 mutation_type: "Rename".to_string(),
414 target: "b".to_string(),
415 changes: 2,
416 mutation_data: None,
417 file_path: None,
418 pre_state: None,
419 post_state: None,
420 affected_symbols: vec![],
421 });
422
423 let mut replay = TxReplay::new(&log);
424 assert_eq!(replay.position(), 0);
425
426 replay.step();
427 assert_eq!(replay.position(), 1);
428
429 replay.seek_checkpoint("start");
430 assert_eq!(replay.position(), 0);
431 }
432
433 #[test]
434 fn test_summary() {
435 let mut log = TxLog::new();
436
437 log.log(TxAction::MutationApplied {
438 mutation_type: "Rename".to_string(),
439 target: "a".to_string(),
440 changes: 5,
441 mutation_data: None,
442 file_path: None,
443 pre_state: None,
444 post_state: None,
445 affected_symbols: vec![],
446 });
447 log.log(TxAction::FileModified {
448 path: "/test.rs".into(),
449 changes: 3,
450 });
451 log.log(TxAction::Checkpoint {
452 name: "mid".to_string(),
453 });
454
455 let summary = log.summary();
456 assert_eq!(summary.total_entries, 3);
457 assert_eq!(summary.total_mutations, 1);
458 assert_eq!(summary.total_changes, 8); assert_eq!(summary.checkpoints, vec!["mid"]);
460 }
461}