Skip to main content

ryo_storage/txlog/
entry.rs

1//! TxEntry and TxAction types for transaction logging.
2//!
3//! Each entry represents a single action that can be replayed.
4//!
5//! # Replay Support
6//!
7//! The [`TxAction::MutationApplied`] variant now includes optional `pre_state`
8//! and `post_state` fields for determinism verification during replay.
9//! See [`crate::storage`] module documentation for the full architecture.
10
11use crate::storage::StateRef;
12use ryo_analysis::SymbolPath;
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16/// A single transaction log entry
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct TxEntry {
19    /// Unique ID within the session
20    pub id: u64,
21
22    /// Milliseconds since session start
23    pub timestamp_ms: u64,
24
25    /// The action performed
26    pub action: TxAction,
27
28    /// Execution duration in microseconds (if measured)
29    pub duration_us: Option<u64>,
30}
31
32impl TxEntry {
33    /// Create a new entry with the given action
34    pub fn new(id: u64, timestamp_ms: u64, action: TxAction) -> Self {
35        Self {
36            id,
37            timestamp_ms,
38            action,
39            duration_us: None,
40        }
41    }
42
43    /// Set the execution duration
44    pub fn with_duration(mut self, duration_us: u64) -> Self {
45        self.duration_us = Some(duration_us);
46        self
47    }
48}
49
50/// Actions that can be logged and replayed
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum TxAction {
53    // =========================================================================
54    // Session Lifecycle
55    // =========================================================================
56    /// Session started
57    SessionStart {
58        /// Root path of the project being analyzed.
59        project_path: PathBuf,
60        /// Number of source files loaded at session start.
61        file_count: usize,
62    },
63
64    /// Session ended
65    SessionEnd {
66        /// Total number of logical changes recorded in the session.
67        total_changes: usize,
68        /// Number of distinct files that were modified during the session.
69        files_modified: usize,
70    },
71
72    // =========================================================================
73    // Goal/Intent (high-level user intent)
74    // =========================================================================
75    /// A goal was set from user query
76    GoalSet {
77        /// Raw user query text that introduced the goal.
78        query: String,
79        /// Classifier output for the goal (e.g. `"RenameIdent"`).
80        intent_type: String,
81        /// Classifier confidence in `[0.0, 1.0]`.
82        confidence: f64,
83    },
84
85    // =========================================================================
86    // Mutation Operations (replayable actions)
87    // =========================================================================
88    /// A mutation was applied.
89    ///
90    /// This variant supports determinism verification via optional `pre_state`
91    /// and `post_state` fields. During replay:
92    /// - `pre_state`: Verify current state matches before applying
93    /// - `post_state`: Verify result matches after applying
94    MutationApplied {
95        /// Type of mutation (e.g., "Rename", "AddField")
96        mutation_type: String,
97        /// Target of the mutation (symbol name, file path, etc.)
98        target: String,
99        /// Number of changes made
100        changes: usize,
101        /// Serialized mutation data for replay (optional)
102        mutation_data: Option<serde_json::Value>,
103        /// File path this mutation was applied to (for multi-file support)
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        file_path: Option<PathBuf>,
106        /// State reference before mutation (for verification)
107        #[serde(default, skip_serializing_if = "Option::is_none")]
108        pre_state: Option<StateRef>,
109        /// State reference after mutation (for verification)
110        #[serde(default, skip_serializing_if = "Option::is_none")]
111        post_state: Option<StateRef>,
112        /// Symbols affected by this mutation (for history tracking)
113        #[serde(default, skip_serializing_if = "Vec::is_empty")]
114        affected_symbols: Vec<SymbolPath>,
115    },
116
117    /// Batch of mutations applied atomically
118    MutationBatch {
119        /// Individual mutations contained in this batch.
120        mutations: Vec<MutationRecord>,
121        /// Sum of `changes` across all batch entries.
122        total_changes: usize,
123    },
124
125    // =========================================================================
126    // File Operations
127    // =========================================================================
128    /// File was loaded into memory
129    FileLoaded {
130        /// Path of the file that was loaded.
131        path: PathBuf,
132        /// On-disk size of the file in bytes at load time.
133        size_bytes: usize,
134    },
135
136    /// File was modified in memory
137    FileModified {
138        /// Path of the file that was modified.
139        path: PathBuf,
140        /// Number of logical changes applied to the file.
141        changes: usize,
142    },
143
144    /// File was written to disk
145    FileWritten {
146        /// Path of the file that was flushed to disk.
147        path: PathBuf,
148    },
149
150    // =========================================================================
151    // Verification
152    // =========================================================================
153    /// Compile check was performed
154    CompileCheck {
155        /// `true` iff the compile check completed with no errors.
156        success: bool,
157        /// Number of error diagnostics emitted.
158        error_count: usize,
159        /// Rendered error messages (may be truncated by the caller).
160        errors: Vec<String>,
161    },
162
163    // =========================================================================
164    // Undo/Redo markers
165    // =========================================================================
166    /// Checkpoint for undo (can revert to this point)
167    Checkpoint {
168        /// Human-readable label identifying this checkpoint.
169        name: String,
170    },
171
172    /// Undo was performed
173    Undo {
174        /// ID of the entry that was undone
175        target_id: u64,
176    },
177
178    /// Redo was performed
179    Redo {
180        /// ID of the entry that was redone
181        target_id: u64,
182    },
183
184    // =========================================================================
185    // Custom/Extension
186    // =========================================================================
187    /// Custom action for extensions
188    Custom {
189        /// Custom action identifier defined by the extension.
190        name: String,
191        /// Opaque JSON payload interpreted by the extension.
192        data: serde_json::Value,
193    },
194}
195
196/// Record of a single mutation within a batch
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct MutationRecord {
199    /// Mutation classifier (e.g. `"Rename"`, `"AddField"`).
200    pub mutation_type: String,
201    /// Mutation target identifier (symbol path, file path, ...).
202    pub target: String,
203    /// Number of logical changes produced by this mutation.
204    pub changes: usize,
205    /// File the mutation was applied to, if scoped to a single file.
206    pub file_path: Option<PathBuf>,
207    /// Symbols affected by this mutation (for history tracking)
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub affected_symbols: Vec<SymbolPath>,
210}
211
212impl TxAction {
213    /// Get a short description of the action
214    pub fn describe(&self) -> String {
215        match self {
216            TxAction::SessionStart {
217                project_path,
218                file_count,
219            } => {
220                format!(
221                    "Session started: {} ({} files)",
222                    project_path.display(),
223                    file_count
224                )
225            }
226            TxAction::SessionEnd {
227                total_changes,
228                files_modified,
229            } => {
230                format!(
231                    "Session ended: {} changes in {} files",
232                    total_changes, files_modified
233                )
234            }
235            TxAction::GoalSet {
236                query, intent_type, ..
237            } => {
238                format!("Goal: {} ({})", query, intent_type)
239            }
240            TxAction::MutationApplied {
241                mutation_type,
242                target,
243                changes,
244                ..
245            } => {
246                format!("{}: {} ({} changes)", mutation_type, target, changes)
247            }
248            TxAction::MutationBatch {
249                mutations,
250                total_changes,
251            } => {
252                format!(
253                    "Batch: {} mutations ({} changes)",
254                    mutations.len(),
255                    total_changes
256                )
257            }
258            TxAction::FileLoaded { path, .. } => {
259                format!("Loaded: {}", path.display())
260            }
261            TxAction::FileModified { path, changes } => {
262                format!("Modified: {} ({} changes)", path.display(), changes)
263            }
264            TxAction::FileWritten { path } => {
265                format!("Written: {}", path.display())
266            }
267            TxAction::CompileCheck {
268                success,
269                error_count,
270                ..
271            } => {
272                if *success {
273                    "Compile check: OK".to_string()
274                } else {
275                    format!("Compile check: {} errors", error_count)
276                }
277            }
278            TxAction::Checkpoint { name } => {
279                format!("Checkpoint: {}", name)
280            }
281            TxAction::Undo { target_id } => {
282                format!("Undo: entry #{}", target_id)
283            }
284            TxAction::Redo { target_id } => {
285                format!("Redo: entry #{}", target_id)
286            }
287            TxAction::Custom { name, .. } => {
288                format!("Custom: {}", name)
289            }
290        }
291    }
292
293    /// Check if this action is replayable
294    pub fn is_replayable(&self) -> bool {
295        matches!(
296            self,
297            TxAction::MutationApplied { .. }
298                | TxAction::MutationBatch { .. }
299                | TxAction::FileWritten { .. }
300        )
301    }
302
303    /// Check if this is a mutation action
304    pub fn is_mutation(&self) -> bool {
305        matches!(
306            self,
307            TxAction::MutationApplied { .. } | TxAction::MutationBatch { .. }
308        )
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_entry_serialization() {
318        let entry = TxEntry::new(
319            1,
320            100,
321            TxAction::MutationApplied {
322                mutation_type: "Rename".to_string(),
323                target: "old_name -> new_name".to_string(),
324                changes: 5,
325                mutation_data: None,
326                file_path: None,
327                pre_state: None,
328                post_state: None,
329                affected_symbols: vec![],
330            },
331        );
332
333        let json = serde_json::to_string(&entry).unwrap();
334        let deserialized: TxEntry = serde_json::from_str(&json).unwrap();
335
336        assert_eq!(deserialized.id, 1);
337        assert_eq!(deserialized.timestamp_ms, 100);
338    }
339
340    #[test]
341    fn test_action_describe() {
342        let action = TxAction::GoalSet {
343            query: "rename foo to bar".to_string(),
344            intent_type: "RenameIdent".to_string(),
345            confidence: 0.95,
346        };
347
348        assert!(action.describe().contains("rename foo to bar"));
349    }
350}