Skip to main content

ryo_storage/txlog/
log.rs

1//! TxLog: In-memory transaction log with replay capability
2//!
3//! Provides:
4//! - In-memory storage of all actions
5//! - Serialization to JSON/bincode
6//! - Replay iterator
7//! - Undo/Redo support via checkpoints
8
9use super::entry::{TxAction, TxEntry};
10use ryo_analysis::SymbolPath;
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13use std::time::Instant;
14
15/// Summary of a transaction log
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct TxSummary {
18    /// Total number of entries in the log.
19    pub total_entries: usize,
20    /// Subset of entries classified as mutations (`is_mutation()`).
21    pub total_mutations: usize,
22    /// Sum of `changes` across all mutation entries.
23    pub total_changes: usize,
24    /// Number of distinct files modified across the log.
25    pub files_modified: usize,
26    /// Wall-clock duration of the session in milliseconds.
27    pub duration_ms: u64,
28    /// Names of every `Checkpoint` entry encountered, in log order.
29    pub checkpoints: Vec<String>,
30}
31
32/// Transaction log container
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct TxLog {
35    /// All entries in order
36    entries: Vec<TxEntry>,
37
38    /// Session metadata
39    pub session_id: String,
40    /// Absolute (or workspace-relative) project root path the session
41    /// operates on.
42    pub project_path: String,
43    /// Session start time, ISO 8601 string.
44    pub started_at: String, // ISO 8601
45    /// Session end time, ISO 8601 string; `None` while the session is
46    /// still active.
47    pub ended_at: Option<String>,
48
49    /// Runtime state (not serialized)
50    #[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    /// Create a new empty log
62    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    /// Create with project path
74    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    /// Add an entry to the log
81    pub fn push(&mut self, entry: TxEntry) {
82        self.entries.push(entry);
83    }
84
85    /// Create and add a new entry
86    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    /// Get all entries
98    pub fn entries(&self) -> &[TxEntry] {
99        &self.entries
100    }
101
102    /// Get entry by ID
103    pub fn get(&self, id: u64) -> Option<&TxEntry> {
104        self.entries.get(id as usize)
105    }
106
107    /// Number of entries
108    pub fn len(&self) -> usize {
109        self.entries.len()
110    }
111
112    /// Check if empty
113    pub fn is_empty(&self) -> bool {
114        self.entries.is_empty()
115    }
116
117    /// Iterate over entries
118    pub fn iter(&self) -> impl Iterator<Item = &TxEntry> {
119        self.entries.iter()
120    }
121
122    /// Iterate over replayable entries only
123    pub fn iter_replayable(&self) -> impl Iterator<Item = &TxEntry> {
124        self.entries.iter().filter(|e| e.action.is_replayable())
125    }
126
127    /// Iterate over mutation entries only
128    pub fn iter_mutations(&self) -> impl Iterator<Item = &TxEntry> {
129        self.entries.iter().filter(|e| e.action.is_mutation())
130    }
131
132    /// Get mutations affecting a specific symbol
133    ///
134    /// Returns all mutation entries where `affected_symbols` contains
135    /// the given symbol or one of its ancestors.
136    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    /// Get mutations affecting a symbol and all its descendants
151    ///
152    /// Returns all mutation entries where `affected_symbols` contains
153    /// any symbol in the subtree rooted at the given symbol.
154    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    /// Get entries since a checkpoint
169    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    /// Get the last N entries
181    pub fn last_n(&self, n: usize) -> &[TxEntry] {
182        let start = self.entries.len().saturating_sub(n);
183        &self.entries[start..]
184    }
185
186    /// Mark session as ended
187    pub fn end_session(&mut self) {
188        self.ended_at = Some(chrono_now());
189    }
190
191    /// Generate summary
192    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    // =========================================================================
243    // Serialization
244    // =========================================================================
245
246    /// Dump to JSON file
247    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    /// Dump to compact JSON (single line)
254    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    /// Load from JSON file
261    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    /// Convert to JSON string
268    pub fn to_json(&self) -> Result<String, serde_json::Error> {
269        serde_json::to_string_pretty(self)
270    }
271
272    /// Parse from JSON string
273    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
274        serde_json::from_str(json)
275    }
276}
277
278// ============================================================================
279// Replay Support (test-only)
280// ============================================================================
281
282/// Replay state for stepping through a log (used in tests)
283#[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
320// ============================================================================
321// Helper functions
322// ============================================================================
323
324/// Generate a simple UUID v4
325fn 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
340/// Get current time as ISO 8601 string
341fn 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    // Simple ISO 8601 format (not perfect but good enough)
347    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); // 5 + 3
459        assert_eq!(summary.checkpoints, vec!["mid"]);
460    }
461}