Skip to main content

ftui_runtime/undo/
transaction.rs

1#![forbid(unsafe_code)]
2
3//! Transaction support for grouping multiple commands atomically.
4//!
5//! Transactions allow multiple operations to be grouped together and
6//! treated as a single undoable unit. If any operation fails, all
7//! previous operations in the transaction are rolled back.
8//!
9//! # Usage
10//!
11//! ```ignore
12//! use ftui_runtime::undo::{HistoryManager, Transaction};
13//!
14//! let mut history = HistoryManager::default();
15//!
16//! // Begin a transaction
17//! let mut txn = Transaction::begin("Format Document");
18//!
19//! // Add commands to the transaction
20//! txn.push(normalize_whitespace_cmd)?;
21//! txn.push(fix_indentation_cmd)?;
22//! txn.push(sort_imports_cmd)?;
23//!
24//! // Commit the transaction to history
25//! history.push(txn.commit());
26//! ```
27//!
28//! # Nested Transactions
29//!
30//! Transactions can be nested using `TransactionScope`:
31//!
32//! ```ignore
33//! let mut scope = TransactionScope::new(&mut history);
34//!
35//! // Outer transaction
36//! scope.begin("Refactor");
37//!
38//! // Inner transaction
39//! scope.begin("Rename Variable");
40//! scope.execute(rename_cmd)?;
41//! scope.commit()?;
42//!
43//! // More outer work
44//! scope.execute(move_function_cmd)?;
45//! scope.commit()?;
46//! ```
47//!
48//! # Invariants
49//!
50//! 1. A committed transaction acts as a single command in history
51//! 2. Rollback undoes all executed commands in reverse order
52//! 3. Nested transactions must be committed/rolled back in order
53//! 4. Empty transactions produce no history entry
54
55use std::fmt;
56
57use super::command::{CommandBatch, CommandError, CommandResult, UndoableCmd};
58use super::history::HistoryManager;
59
60/// Builder for creating a group of commands as a single transaction.
61///
62/// Commands added to a transaction are executed immediately. If any
63/// command fails, all previously executed commands are rolled back.
64///
65/// When committed, the transaction becomes a single entry in history
66/// that can be undone/redone atomically.
67pub struct Transaction {
68    /// The underlying command batch.
69    batch: CommandBatch,
70    /// Number of commands that have been successfully executed.
71    executed_count: usize,
72    /// Whether the transaction has been committed or rolled back.
73    finalized: bool,
74}
75
76impl fmt::Debug for Transaction {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.debug_struct("Transaction")
79            .field("description", &self.batch.description())
80            .field("command_count", &self.batch.len())
81            .field("executed_count", &self.executed_count)
82            .field("finalized", &self.finalized)
83            .finish()
84    }
85}
86
87impl Transaction {
88    /// Begin a new transaction with the given description.
89    #[must_use]
90    pub fn begin(description: impl Into<String>) -> Self {
91        Self {
92            batch: CommandBatch::new(description),
93            executed_count: 0,
94            finalized: false,
95        }
96    }
97
98    /// Execute a command and add it to the transaction.
99    ///
100    /// The command is executed immediately. If it fails, all previously
101    /// executed commands are rolled back and the error is returned.
102    ///
103    /// # Errors
104    ///
105    /// Returns error if the command fails to execute.
106    pub fn execute(&mut self, mut cmd: Box<dyn UndoableCmd>) -> CommandResult {
107        if self.finalized {
108            return Err(CommandError::InvalidState(
109                "transaction already finalized".to_string(),
110            ));
111        }
112
113        // Execute the command
114        if let Err(e) = cmd.execute() {
115            // Rollback on failure
116            self.rollback();
117            return Err(e);
118        }
119
120        // Add to batch (already executed)
121        self.executed_count += 1;
122        self.batch.push_executed(cmd);
123        Ok(())
124    }
125
126    /// Add a pre-executed command to the transaction.
127    ///
128    /// Use this when the command has already been executed externally.
129    /// The command will be undone on rollback and redone on redo.
130    ///
131    /// # Errors
132    ///
133    /// Returns error if the transaction is already finalized.
134    pub fn add_executed(&mut self, cmd: Box<dyn UndoableCmd>) -> CommandResult {
135        if self.finalized {
136            return Err(CommandError::InvalidState(
137                "transaction already finalized".to_string(),
138            ));
139        }
140
141        self.executed_count += 1;
142        self.batch.push_executed(cmd);
143        Ok(())
144    }
145
146    /// Commit the transaction, returning it as a single undoable command.
147    ///
148    /// Returns `None` if the transaction is empty.
149    #[must_use]
150    pub fn commit(mut self) -> Option<Box<dyn UndoableCmd>> {
151        self.finalized = true;
152
153        if self.batch.is_empty() {
154            None
155        } else {
156            // Take ownership of the batch, replacing with an empty one.
157            // This works because Drop only rolls back if not finalized,
158            // and we just set finalized = true.
159            let batch = std::mem::replace(&mut self.batch, CommandBatch::new(""));
160            Some(Box::new(batch))
161        }
162    }
163
164    /// Roll back all executed commands in the transaction.
165    ///
166    /// This undoes all commands in reverse order. After rollback,
167    /// the transaction is finalized and cannot be used further.
168    pub fn rollback(&mut self) {
169        if self.finalized {
170            return;
171        }
172
173        // Rollback already happens in batch.undo(), but we need to
174        // manually track that we're rolling back here
175        // Since commands are in the batch but haven't been "undone" via
176        // the batch's undo mechanism, we need to undo them directly.
177
178        // The batch stores commands but doesn't track execution state
179        // the same way we do. We need to undo the executed commands.
180        // Since we can't easily access individual commands in the batch,
181        // we rely on the batch's undo mechanism.
182
183        // Mark as finalized before undo to prevent re-entry
184        self.finalized = true;
185
186        // If we have executed commands, undo them via the batch
187        if self.executed_count > 0 {
188            // The batch's undo will undo all commands
189            let _ = self.batch.undo();
190        }
191    }
192
193    /// Check if the transaction is empty.
194    #[must_use]
195    pub fn is_empty(&self) -> bool {
196        self.batch.is_empty()
197    }
198
199    /// Get the number of commands in the transaction.
200    #[must_use]
201    pub fn len(&self) -> usize {
202        self.batch.len()
203    }
204
205    /// Get the transaction description.
206    #[must_use]
207    pub fn description(&self) -> &str {
208        self.batch.description()
209    }
210}
211
212impl Drop for Transaction {
213    fn drop(&mut self) {
214        // If transaction wasn't finalized, auto-rollback
215        if !self.finalized {
216            self.rollback();
217        }
218    }
219}
220
221/// Scope-based transaction manager for nested transactions.
222///
223/// Provides a stack-based interface for managing nested transactions.
224/// Each `begin()` pushes a new transaction, and `commit()` or `rollback()`
225/// pops and finalizes it.
226pub struct TransactionScope<'a> {
227    /// Reference to the history manager.
228    history: &'a mut HistoryManager,
229    /// Stack of active transactions.
230    stack: Vec<Transaction>,
231}
232
233impl<'a> TransactionScope<'a> {
234    /// Create a new transaction scope.
235    #[must_use]
236    pub fn new(history: &'a mut HistoryManager) -> Self {
237        Self {
238            history,
239            stack: Vec::new(),
240        }
241    }
242
243    /// Begin a new nested transaction.
244    pub fn begin(&mut self, description: impl Into<String>) {
245        self.stack.push(Transaction::begin(description));
246    }
247
248    /// Execute a command in the current transaction.
249    ///
250    /// If no transaction is active, the command is executed and added
251    /// directly to history.
252    ///
253    /// # Errors
254    ///
255    /// Returns error if the command fails.
256    pub fn execute(&mut self, cmd: Box<dyn UndoableCmd>) -> CommandResult {
257        if let Some(txn) = self.stack.last_mut() {
258            txn.execute(cmd)
259        } else {
260            // No active transaction, execute directly
261            let mut cmd = cmd;
262            cmd.execute()?;
263            self.history.push(cmd);
264            Ok(())
265        }
266    }
267
268    /// Commit the current transaction.
269    ///
270    /// If nested, the committed transaction is added to the parent.
271    /// If at top level, it's added to history.
272    ///
273    /// # Errors
274    ///
275    /// Returns error if no transaction is active.
276    pub fn commit(&mut self) -> CommandResult {
277        let txn = self
278            .stack
279            .pop()
280            .ok_or_else(|| CommandError::InvalidState("no active transaction".to_string()))?;
281
282        if let Some(cmd) = txn.commit() {
283            if let Some(parent) = self.stack.last_mut() {
284                // Add to parent transaction as pre-executed
285                parent.add_executed(cmd)?;
286            } else {
287                // Add to history
288                self.history.push(cmd);
289            }
290        }
291
292        Ok(())
293    }
294
295    /// Roll back the current transaction.
296    ///
297    /// # Errors
298    ///
299    /// Returns error if no transaction is active.
300    pub fn rollback(&mut self) -> CommandResult {
301        let mut txn = self
302            .stack
303            .pop()
304            .ok_or_else(|| CommandError::InvalidState("no active transaction".to_string()))?;
305
306        txn.rollback();
307        Ok(())
308    }
309
310    /// Check if there are active transactions.
311    #[must_use]
312    pub fn is_active(&self) -> bool {
313        !self.stack.is_empty()
314    }
315
316    /// Get the current nesting depth.
317    #[must_use]
318    pub fn depth(&self) -> usize {
319        self.stack.len()
320    }
321}
322
323impl Drop for TransactionScope<'_> {
324    fn drop(&mut self) {
325        // Auto-rollback any uncommitted transactions
326        while let Some(mut txn) = self.stack.pop() {
327            txn.rollback();
328        }
329    }
330}
331
332// ============================================================================
333// Tests
334// ============================================================================
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::undo::command::{TextInsertCmd, WidgetId};
340    use crate::undo::history::HistoryConfig;
341    use std::sync::Arc;
342    use std::sync::Mutex;
343
344    /// Helper to create a test command with a shared buffer.
345    fn make_cmd(buffer: Arc<Mutex<String>>, text: &str) -> Box<dyn UndoableCmd> {
346        let b1 = buffer.clone();
347        let b2 = buffer.clone();
348        let text = text.to_string();
349        let text_clone = text.clone();
350
351        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, text)
352            .with_apply(move |_, _, txt| {
353                let mut buf = b1.lock().unwrap();
354                buf.push_str(txt);
355                Ok(())
356            })
357            .with_remove(move |_, _, _| {
358                let mut buf = b2.lock().unwrap();
359                buf.drain(..text_clone.len());
360                Ok(())
361            });
362
363        cmd.execute().unwrap();
364        Box::new(cmd)
365    }
366
367    #[test]
368    fn test_empty_transaction() {
369        let txn = Transaction::begin("Empty");
370        assert!(txn.is_empty());
371        assert_eq!(txn.len(), 0);
372        assert!(txn.commit().is_none());
373    }
374
375    #[test]
376    fn test_single_command_transaction() {
377        let buffer = Arc::new(Mutex::new(String::new()));
378
379        let mut txn = Transaction::begin("Single");
380        txn.add_executed(make_cmd(buffer.clone(), "hello")).unwrap();
381
382        assert_eq!(txn.len(), 1);
383
384        let cmd = txn.commit();
385        assert!(cmd.is_some());
386    }
387
388    #[test]
389    fn test_transaction_rollback() {
390        let buffer = Arc::new(Mutex::new(String::new()));
391
392        let mut txn = Transaction::begin("Rollback Test");
393        txn.add_executed(make_cmd(buffer.clone(), "hello")).unwrap();
394        txn.add_executed(make_cmd(buffer.clone(), " world"))
395            .unwrap();
396
397        assert_eq!(*buffer.lock().unwrap(), "hello world");
398
399        txn.rollback();
400
401        // Buffer should be back to empty after rollback
402        assert_eq!(*buffer.lock().unwrap(), "");
403    }
404
405    #[test]
406    fn test_transaction_commit_to_history() {
407        let buffer = Arc::new(Mutex::new(String::new()));
408        let mut history = HistoryManager::new(HistoryConfig::unlimited());
409
410        let mut txn = Transaction::begin("Commit Test");
411        txn.add_executed(make_cmd(buffer.clone(), "a")).unwrap();
412        txn.add_executed(make_cmd(buffer.clone(), "b")).unwrap();
413
414        if let Some(cmd) = txn.commit() {
415            history.push(cmd);
416        }
417
418        assert_eq!(history.undo_depth(), 1);
419        assert!(history.can_undo());
420    }
421
422    #[test]
423    fn test_transaction_undo_redo() {
424        let buffer = Arc::new(Mutex::new(String::new()));
425        let mut history = HistoryManager::new(HistoryConfig::unlimited());
426
427        let mut txn = Transaction::begin("Undo/Redo Test");
428        txn.add_executed(make_cmd(buffer.clone(), "hello")).unwrap();
429        txn.add_executed(make_cmd(buffer.clone(), " world"))
430            .unwrap();
431
432        if let Some(cmd) = txn.commit() {
433            history.push(cmd);
434        }
435
436        assert_eq!(*buffer.lock().unwrap(), "hello world");
437
438        // Undo the entire transaction
439        history.undo();
440        assert_eq!(*buffer.lock().unwrap(), "");
441
442        // Redo the entire transaction
443        history.redo();
444        assert_eq!(*buffer.lock().unwrap(), "hello world");
445    }
446
447    #[test]
448    fn test_scope_basic() {
449        let buffer = Arc::new(Mutex::new(String::new()));
450        let mut history = HistoryManager::new(HistoryConfig::unlimited());
451
452        {
453            let mut scope = TransactionScope::new(&mut history);
454            scope.begin("Scope Test");
455
456            scope.execute(make_cmd(buffer.clone(), "a")).unwrap();
457            scope.execute(make_cmd(buffer.clone(), "b")).unwrap();
458
459            scope.commit().unwrap();
460        }
461
462        assert_eq!(history.undo_depth(), 1);
463    }
464
465    #[test]
466    fn test_scope_nested() {
467        let buffer = Arc::new(Mutex::new(String::new()));
468        let mut history = HistoryManager::new(HistoryConfig::unlimited());
469
470        {
471            let mut scope = TransactionScope::new(&mut history);
472
473            // Outer transaction
474            scope.begin("Outer");
475            scope.execute(make_cmd(buffer.clone(), "outer1")).unwrap();
476
477            // Inner transaction
478            scope.begin("Inner");
479            scope.execute(make_cmd(buffer.clone(), "inner")).unwrap();
480            scope.commit().unwrap();
481
482            scope.execute(make_cmd(buffer.clone(), "outer2")).unwrap();
483            scope.commit().unwrap();
484        }
485
486        // Both transactions committed as one (nested was added to parent)
487        assert_eq!(history.undo_depth(), 1);
488    }
489
490    #[test]
491    fn test_scope_rollback() {
492        let buffer = Arc::new(Mutex::new(String::new()));
493        let mut history = HistoryManager::new(HistoryConfig::unlimited());
494
495        {
496            let mut scope = TransactionScope::new(&mut history);
497            scope.begin("Rollback");
498
499            scope.execute(make_cmd(buffer.clone(), "a")).unwrap();
500            scope.execute(make_cmd(buffer.clone(), "b")).unwrap();
501
502            scope.rollback().unwrap();
503        }
504
505        // Nothing should be in history
506        assert_eq!(history.undo_depth(), 0);
507    }
508
509    #[test]
510    fn test_scope_auto_rollback_on_drop() {
511        let buffer = Arc::new(Mutex::new(String::new()));
512        let mut history = HistoryManager::new(HistoryConfig::unlimited());
513
514        {
515            let mut scope = TransactionScope::new(&mut history);
516            scope.begin("Will be dropped");
517            scope.execute(make_cmd(buffer.clone(), "test")).unwrap();
518            // scope drops without commit
519        }
520
521        // Should have auto-rolled back
522        assert_eq!(history.undo_depth(), 0);
523    }
524
525    #[test]
526    fn test_scope_depth() {
527        let mut history = HistoryManager::new(HistoryConfig::unlimited());
528
529        let mut scope = TransactionScope::new(&mut history);
530        assert_eq!(scope.depth(), 0);
531        assert!(!scope.is_active());
532
533        scope.begin("Level 1");
534        assert_eq!(scope.depth(), 1);
535        assert!(scope.is_active());
536
537        scope.begin("Level 2");
538        assert_eq!(scope.depth(), 2);
539
540        scope.commit().unwrap();
541        assert_eq!(scope.depth(), 1);
542
543        scope.commit().unwrap();
544        assert_eq!(scope.depth(), 0);
545        assert!(!scope.is_active());
546    }
547
548    #[test]
549    fn test_transaction_description() {
550        let txn = Transaction::begin("My Transaction");
551        assert_eq!(txn.description(), "My Transaction");
552    }
553
554    #[test]
555    fn test_finalized_transaction_rejects_commands() {
556        let buffer = Arc::new(Mutex::new(String::new()));
557
558        let mut txn = Transaction::begin("Finalized");
559        txn.rollback();
560
561        let result = txn.add_executed(make_cmd(buffer, "test"));
562        assert!(result.is_err());
563    }
564}