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}