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        // Rollback/drop already finalized this transaction; never emit history.
152        if self.finalized {
153            return None;
154        }
155        self.finalized = true;
156
157        if self.batch.is_empty() {
158            None
159        } else {
160            // Take ownership of the batch, replacing with an empty one.
161            // This works because Drop only rolls back if not finalized,
162            // and we just set finalized = true.
163            let batch = std::mem::replace(&mut self.batch, CommandBatch::new(""));
164            Some(Box::new(batch))
165        }
166    }
167
168    /// Roll back all executed commands in the transaction.
169    ///
170    /// This undoes all commands in reverse order. After rollback,
171    /// the transaction is finalized and cannot be used further.
172    pub fn rollback(&mut self) {
173        if self.finalized {
174            return;
175        }
176
177        // Rollback already happens in batch.undo(), but we need to
178        // manually track that we're rolling back here
179        // Since commands are in the batch but haven't been "undone" via
180        // the batch's undo mechanism, we need to undo them directly.
181
182        // The batch stores commands but doesn't track execution state
183        // the same way we do. We need to undo the executed commands.
184        // Since we can't easily access individual commands in the batch,
185        // we rely on the batch's undo mechanism.
186
187        // Mark as finalized before undo to prevent re-entry
188        self.finalized = true;
189
190        // If we have executed commands, undo them via the batch
191        if self.executed_count > 0 {
192            // The batch's undo will undo all commands
193            let _ = self.batch.undo();
194            self.executed_count = 0;
195        }
196    }
197
198    /// Check if the transaction is empty.
199    #[must_use]
200    pub fn is_empty(&self) -> bool {
201        self.batch.is_empty()
202    }
203
204    /// Get the number of commands in the transaction.
205    #[must_use]
206    pub fn len(&self) -> usize {
207        self.batch.len()
208    }
209
210    /// Get the transaction description.
211    #[must_use]
212    pub fn description(&self) -> &str {
213        self.batch.description()
214    }
215}
216
217impl Drop for Transaction {
218    fn drop(&mut self) {
219        // If transaction wasn't finalized, auto-rollback
220        if !self.finalized {
221            self.rollback();
222        }
223    }
224}
225
226/// Scope-based transaction manager for nested transactions.
227///
228/// Provides a stack-based interface for managing nested transactions.
229/// Each `begin()` pushes a new transaction, and `commit()` or `rollback()`
230/// pops and finalizes it.
231pub struct TransactionScope<'a> {
232    /// Reference to the history manager.
233    history: &'a mut HistoryManager,
234    /// Stack of active transactions.
235    stack: Vec<Transaction>,
236}
237
238impl<'a> TransactionScope<'a> {
239    /// Create a new transaction scope.
240    #[must_use]
241    pub fn new(history: &'a mut HistoryManager) -> Self {
242        Self {
243            history,
244            stack: Vec::new(),
245        }
246    }
247
248    /// Begin a new nested transaction.
249    pub fn begin(&mut self, description: impl Into<String>) {
250        self.stack.push(Transaction::begin(description));
251    }
252
253    /// Execute a command in the current transaction.
254    ///
255    /// If no transaction is active, the command is executed and added
256    /// directly to history.
257    ///
258    /// # Errors
259    ///
260    /// Returns error if the command fails.
261    pub fn execute(&mut self, cmd: Box<dyn UndoableCmd>) -> CommandResult {
262        if let Some(txn) = self.stack.last_mut() {
263            txn.execute(cmd)
264        } else {
265            // No active transaction, execute directly
266            let mut cmd = cmd;
267            cmd.execute()?;
268            self.history.push(cmd);
269            Ok(())
270        }
271    }
272
273    /// Commit the current transaction.
274    ///
275    /// If nested, the committed transaction is added to the parent.
276    /// If at top level, it's added to history.
277    ///
278    /// # Errors
279    ///
280    /// Returns error if no transaction is active.
281    pub fn commit(&mut self) -> CommandResult {
282        let txn = self
283            .stack
284            .pop()
285            .ok_or_else(|| CommandError::InvalidState("no active transaction".to_string()))?;
286
287        if let Some(cmd) = txn.commit() {
288            if let Some(parent) = self.stack.last_mut() {
289                // Add to parent transaction as pre-executed
290                parent.add_executed(cmd)?;
291            } else {
292                // Add to history
293                self.history.push(cmd);
294            }
295        }
296
297        Ok(())
298    }
299
300    /// Roll back the current transaction.
301    ///
302    /// # Errors
303    ///
304    /// Returns error if no transaction is active.
305    pub fn rollback(&mut self) -> CommandResult {
306        let mut txn = self
307            .stack
308            .pop()
309            .ok_or_else(|| CommandError::InvalidState("no active transaction".to_string()))?;
310
311        txn.rollback();
312        Ok(())
313    }
314
315    /// Check if there are active transactions.
316    #[must_use]
317    pub fn is_active(&self) -> bool {
318        !self.stack.is_empty()
319    }
320
321    /// Get the current nesting depth.
322    #[must_use]
323    pub fn depth(&self) -> usize {
324        self.stack.len()
325    }
326}
327
328impl Drop for TransactionScope<'_> {
329    fn drop(&mut self) {
330        // Auto-rollback any uncommitted transactions
331        while let Some(mut txn) = self.stack.pop() {
332            txn.rollback();
333        }
334    }
335}
336
337// ============================================================================
338// Tests
339// ============================================================================
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::undo::command::{TextInsertCmd, WidgetId};
345    use crate::undo::history::HistoryConfig;
346    use std::sync::Arc;
347    use std::sync::Mutex;
348
349    /// Helper to create a test command that is **pre-executed**.
350    ///
351    /// Use this with APIs that expect an executed command (e.g. `add_executed`).
352    fn make_cmd(buffer: Arc<Mutex<String>>, text: &str) -> Box<dyn UndoableCmd> {
353        let b1 = buffer.clone();
354        let b2 = buffer.clone();
355        let text = text.to_string();
356        let text_clone = text.clone();
357
358        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, text)
359            .with_apply(move |_, _, txt| {
360                let mut buf = b1.lock().unwrap();
361                buf.push_str(txt);
362                Ok(())
363            })
364            .with_remove(move |_, _, _| {
365                let mut buf = b2.lock().unwrap();
366                let new_len = buf.len().saturating_sub(text_clone.len());
367                buf.truncate(new_len);
368                Ok(())
369            });
370
371        cmd.execute().unwrap();
372        Box::new(cmd)
373    }
374
375    #[test]
376    fn test_empty_transaction() {
377        let txn = Transaction::begin("Empty");
378        assert!(txn.is_empty());
379        assert_eq!(txn.len(), 0);
380        assert!(txn.commit().is_none());
381    }
382
383    #[test]
384    fn test_single_command_transaction() {
385        let buffer = Arc::new(Mutex::new(String::new()));
386
387        let mut txn = Transaction::begin("Single");
388        txn.add_executed(make_cmd(buffer.clone(), "hello")).unwrap();
389
390        assert_eq!(txn.len(), 1);
391
392        let cmd = txn.commit();
393        assert!(cmd.is_some());
394    }
395
396    #[test]
397    fn test_transaction_rollback() {
398        let buffer = Arc::new(Mutex::new(String::new()));
399
400        let mut txn = Transaction::begin("Rollback Test");
401        txn.add_executed(make_cmd(buffer.clone(), "hello")).unwrap();
402        txn.add_executed(make_cmd(buffer.clone(), " world"))
403            .unwrap();
404
405        assert_eq!(*buffer.lock().unwrap(), "hello world");
406
407        txn.rollback();
408
409        // Buffer should be back to empty after rollback
410        assert_eq!(*buffer.lock().unwrap(), "");
411    }
412
413    #[test]
414    fn test_transaction_commit_to_history() {
415        let buffer = Arc::new(Mutex::new(String::new()));
416        let mut history = HistoryManager::new(HistoryConfig::unlimited());
417
418        let mut txn = Transaction::begin("Commit Test");
419        txn.add_executed(make_cmd(buffer.clone(), "a")).unwrap();
420        txn.add_executed(make_cmd(buffer.clone(), "b")).unwrap();
421
422        if let Some(cmd) = txn.commit() {
423            history.push(cmd);
424        }
425
426        assert_eq!(history.undo_depth(), 1);
427        assert!(history.can_undo());
428    }
429
430    #[test]
431    fn test_transaction_undo_redo() {
432        let buffer = Arc::new(Mutex::new(String::new()));
433        let mut history = HistoryManager::new(HistoryConfig::unlimited());
434
435        let mut txn = Transaction::begin("Undo/Redo Test");
436        txn.add_executed(make_cmd(buffer.clone(), "hello")).unwrap();
437        txn.add_executed(make_cmd(buffer.clone(), " world"))
438            .unwrap();
439
440        if let Some(cmd) = txn.commit() {
441            history.push(cmd);
442        }
443
444        assert_eq!(*buffer.lock().unwrap(), "hello world");
445
446        // Undo the entire transaction
447        history.undo();
448        assert_eq!(*buffer.lock().unwrap(), "");
449
450        // Redo the entire transaction
451        history.redo();
452        assert_eq!(*buffer.lock().unwrap(), "hello world");
453    }
454
455    #[test]
456    fn test_scope_basic() {
457        let buffer = Arc::new(Mutex::new(String::new()));
458        let mut history = HistoryManager::new(HistoryConfig::unlimited());
459
460        {
461            let mut scope = TransactionScope::new(&mut history);
462            scope.begin("Scope Test");
463
464            scope.execute(make_scope_cmd(buffer.clone(), "a")).unwrap();
465            scope.execute(make_scope_cmd(buffer.clone(), "b")).unwrap();
466
467            scope.commit().unwrap();
468        }
469
470        assert_eq!(history.undo_depth(), 1);
471        assert_eq!(*buffer.lock().unwrap(), "ab");
472    }
473
474    #[test]
475    fn test_scope_nested() {
476        let buffer = Arc::new(Mutex::new(String::new()));
477        let mut history = HistoryManager::new(HistoryConfig::unlimited());
478
479        {
480            let mut scope = TransactionScope::new(&mut history);
481
482            // Outer transaction
483            scope.begin("Outer");
484            scope
485                .execute(make_scope_cmd(buffer.clone(), "outer1"))
486                .unwrap();
487
488            // Inner transaction
489            scope.begin("Inner");
490            scope
491                .execute(make_scope_cmd(buffer.clone(), "inner"))
492                .unwrap();
493            scope.commit().unwrap();
494
495            scope
496                .execute(make_scope_cmd(buffer.clone(), "outer2"))
497                .unwrap();
498            scope.commit().unwrap();
499        }
500
501        // Both transactions committed as one (nested was added to parent)
502        assert_eq!(history.undo_depth(), 1);
503        assert_eq!(*buffer.lock().unwrap(), "outer1innerouter2");
504    }
505
506    #[test]
507    fn test_scope_rollback() {
508        let buffer = Arc::new(Mutex::new(String::new()));
509        let mut history = HistoryManager::new(HistoryConfig::unlimited());
510
511        {
512            let mut scope = TransactionScope::new(&mut history);
513            scope.begin("Rollback");
514
515            scope.execute(make_scope_cmd(buffer.clone(), "a")).unwrap();
516            scope.execute(make_scope_cmd(buffer.clone(), "b")).unwrap();
517
518            scope.rollback().unwrap();
519        }
520
521        // Nothing should be in history
522        assert_eq!(history.undo_depth(), 0);
523        assert_eq!(*buffer.lock().unwrap(), "");
524    }
525
526    #[test]
527    fn test_scope_auto_rollback_on_drop() {
528        let buffer = Arc::new(Mutex::new(String::new()));
529        let mut history = HistoryManager::new(HistoryConfig::unlimited());
530
531        {
532            let mut scope = TransactionScope::new(&mut history);
533            scope.begin("Will be dropped");
534            scope
535                .execute(make_scope_cmd(buffer.clone(), "test"))
536                .unwrap();
537            // scope drops without commit
538        }
539
540        // Should have auto-rolled back
541        assert_eq!(history.undo_depth(), 0);
542        assert_eq!(*buffer.lock().unwrap(), "");
543    }
544
545    #[test]
546    fn test_scope_depth() {
547        let mut history = HistoryManager::new(HistoryConfig::unlimited());
548
549        let mut scope = TransactionScope::new(&mut history);
550        assert_eq!(scope.depth(), 0);
551        assert!(!scope.is_active());
552
553        scope.begin("Level 1");
554        assert_eq!(scope.depth(), 1);
555        assert!(scope.is_active());
556
557        scope.begin("Level 2");
558        assert_eq!(scope.depth(), 2);
559
560        scope.commit().unwrap();
561        assert_eq!(scope.depth(), 1);
562
563        scope.commit().unwrap();
564        assert_eq!(scope.depth(), 0);
565        assert!(!scope.is_active());
566    }
567
568    #[test]
569    fn test_transaction_description() {
570        let txn = Transaction::begin("My Transaction");
571        assert_eq!(txn.description(), "My Transaction");
572    }
573
574    #[test]
575    fn test_finalized_transaction_rejects_commands() {
576        let buffer = Arc::new(Mutex::new(String::new()));
577
578        let mut txn = Transaction::begin("Finalized");
579        txn.rollback();
580
581        let result = txn.add_executed(make_cmd(buffer, "test"));
582        assert!(result.is_err());
583    }
584
585    #[test]
586    fn test_transaction_execute_method() {
587        let buffer = Arc::new(Mutex::new(String::new()));
588        let b1 = buffer.clone();
589        let b2 = buffer.clone();
590
591        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "exec")
592            .with_apply(move |_, _, txt| {
593                let mut buf = b1.lock().unwrap();
594                buf.push_str(txt);
595                Ok(())
596            })
597            .with_remove(move |_, _, _| {
598                let mut buf = b2.lock().unwrap();
599                buf.drain(..4);
600                Ok(())
601            });
602
603        let mut txn = Transaction::begin("Execute Test");
604        txn.execute(Box::new(cmd)).unwrap();
605        assert_eq!(txn.len(), 1);
606        assert_eq!(*buffer.lock().unwrap(), "exec");
607    }
608
609    #[test]
610    fn test_transaction_finalized_rejects_execute() {
611        let buffer = Arc::new(Mutex::new(String::new()));
612
613        let mut txn = Transaction::begin("Finalized");
614        txn.rollback();
615
616        let result = txn.execute(make_scope_cmd(buffer, "test"));
617        assert!(result.is_err());
618    }
619
620    #[test]
621    fn test_commit_after_rollback_returns_none() {
622        let buffer = Arc::new(Mutex::new(String::new()));
623
624        let mut txn = Transaction::begin("Rollback then commit");
625        txn.add_executed(make_cmd(buffer.clone(), "a")).unwrap();
626        txn.rollback();
627
628        assert!(txn.commit().is_none());
629        assert_eq!(*buffer.lock().unwrap(), "");
630    }
631
632    #[test]
633    fn test_scope_commit_after_execute_failure_does_not_push_rolled_back_batch() {
634        let buffer = Arc::new(Mutex::new(String::new()));
635        let mut history = HistoryManager::new(HistoryConfig::unlimited());
636
637        let failing_cmd = TextInsertCmd::new(WidgetId::new(1), 0, "boom")
638            .with_apply(move |_, _, _| Err(CommandError::Other("boom".to_string())))
639            .with_remove(move |_, _, _| Ok(()));
640
641        {
642            let mut scope = TransactionScope::new(&mut history);
643            scope.begin("Failure path");
644            let b_ok_apply = buffer.clone();
645            let b_ok_remove = buffer.clone();
646            let ok_cmd = TextInsertCmd::new(WidgetId::new(1), 0, "ok")
647                .with_apply(move |_, _, txt| {
648                    let mut buf = b_ok_apply.lock().unwrap();
649                    buf.push_str(txt);
650                    Ok(())
651                })
652                .with_remove(move |_, _, _| {
653                    let mut buf = b_ok_remove.lock().unwrap();
654                    buf.drain(..2);
655                    Ok(())
656                });
657            scope.execute(Box::new(ok_cmd)).unwrap();
658            assert!(scope.execute(Box::new(failing_cmd)).is_err());
659            // This used to leak the rolled-back batch into history.
660            scope.commit().unwrap();
661        }
662
663        assert_eq!(history.undo_depth(), 0);
664        assert_eq!(*buffer.lock().unwrap(), "");
665    }
666
667    #[test]
668    fn test_scope_execute_without_transaction() {
669        let buffer = Arc::new(Mutex::new(String::new()));
670        let mut history = HistoryManager::new(HistoryConfig::unlimited());
671
672        {
673            let mut scope = TransactionScope::new(&mut history);
674            // Execute without begin() - should go directly to history
675            scope
676                .execute(make_scope_cmd(buffer.clone(), "direct"))
677                .unwrap();
678        }
679
680        assert_eq!(history.undo_depth(), 1);
681        assert_eq!(*buffer.lock().unwrap(), "direct");
682    }
683
684    #[test]
685    fn test_scope_commit_without_begin_errors() {
686        let mut history = HistoryManager::new(HistoryConfig::unlimited());
687
688        let mut scope = TransactionScope::new(&mut history);
689        let result = scope.commit();
690        assert!(result.is_err());
691    }
692
693    #[test]
694    fn test_scope_rollback_without_begin_errors() {
695        let mut history = HistoryManager::new(HistoryConfig::unlimited());
696
697        let mut scope = TransactionScope::new(&mut history);
698        let result = scope.rollback();
699        assert!(result.is_err());
700    }
701
702    #[test]
703    fn test_transaction_multi_command_rollback_order() {
704        let buffer = Arc::new(Mutex::new(String::new()));
705
706        let mut txn = Transaction::begin("Multi Rollback");
707        txn.add_executed(make_cmd(buffer.clone(), "a")).unwrap();
708        txn.add_executed(make_cmd(buffer.clone(), "b")).unwrap();
709        txn.add_executed(make_cmd(buffer.clone(), "c")).unwrap();
710
711        assert_eq!(*buffer.lock().unwrap(), "abc");
712        txn.rollback();
713        assert_eq!(*buffer.lock().unwrap(), "");
714    }
715
716    #[test]
717    fn test_transaction_debug_impl() {
718        let txn = Transaction::begin("Debug Test");
719        let s = format!("{txn:?}");
720        assert!(s.contains("Transaction"));
721        assert!(s.contains("Debug Test"));
722    }
723
724    // ====================================================================
725    // Additional coverage: double rollback, scope edge cases
726    // ====================================================================
727
728    #[test]
729    fn test_rollback_is_idempotent() {
730        let buffer = Arc::new(Mutex::new(String::new()));
731        let mut txn = Transaction::begin("Double Rollback");
732        txn.add_executed(make_cmd(buffer.clone(), "x")).unwrap();
733
734        txn.rollback();
735        assert_eq!(*buffer.lock().unwrap(), "");
736
737        // Second rollback is a no-op (finalized)
738        txn.rollback();
739        assert_eq!(*buffer.lock().unwrap(), "");
740    }
741
742    #[test]
743    fn test_rollback_empty_transaction() {
744        let mut txn = Transaction::begin("Empty Rollback");
745        txn.rollback();
746        // Should not panic, nothing to undo
747        assert!(txn.commit().is_none());
748    }
749
750    #[test]
751    fn test_scope_drop_with_multiple_uncommitted() {
752        let mut history = HistoryManager::new(HistoryConfig::unlimited());
753        let buf_a = Arc::new(Mutex::new(String::new()));
754        let buf_b = Arc::new(Mutex::new(String::new()));
755
756        {
757            let mut scope = TransactionScope::new(&mut history);
758            scope.begin("Outer");
759            // Use separate buffers to avoid cross-transaction interference
760            scope.execute(make_scope_cmd(buf_a.clone(), "a")).unwrap();
761
762            scope.begin("Inner");
763            scope.execute(make_scope_cmd(buf_b.clone(), "b")).unwrap();
764
765            // Drop without committing either transaction
766        }
767
768        // Both should have been rolled back — nothing in history
769        assert_eq!(history.undo_depth(), 0);
770        assert_eq!(*buf_a.lock().unwrap(), "");
771        assert_eq!(*buf_b.lock().unwrap(), "");
772    }
773
774    #[test]
775    fn test_scope_inner_rollback_outer_continues() {
776        let buffer = Arc::new(Mutex::new(String::new()));
777        let mut history = HistoryManager::new(HistoryConfig::unlimited());
778
779        {
780            let mut scope = TransactionScope::new(&mut history);
781
782            // Outer transaction
783            scope.begin("Outer");
784            scope
785                .execute(make_scope_cmd(buffer.clone(), "outer"))
786                .unwrap();
787
788            // Inner transaction
789            scope.begin("Inner");
790            scope
791                .execute(make_scope_cmd(buffer.clone(), "inner"))
792                .unwrap();
793            scope.rollback().unwrap(); // Roll back inner only
794
795            assert_eq!(scope.depth(), 1); // Outer still active
796            assert_eq!(*buffer.lock().unwrap(), "outer");
797
798            // Commit outer
799            scope.commit().unwrap();
800        }
801
802        assert_eq!(history.undo_depth(), 1);
803    }
804
805    #[test]
806    fn test_scope_commit_empty_inner_txn() {
807        let mut history = HistoryManager::new(HistoryConfig::unlimited());
808
809        {
810            let mut scope = TransactionScope::new(&mut history);
811            scope.begin("Outer");
812            scope.begin("Empty Inner");
813            scope.commit().unwrap(); // Empty inner commits as None
814            scope.commit().unwrap(); // Outer commits as empty too
815        }
816
817        // Empty transactions produce no history entry
818        assert_eq!(history.undo_depth(), 0);
819    }
820
821    #[test]
822    fn test_transaction_execute_failure_rolls_back_prior() {
823        let buffer = Arc::new(Mutex::new(String::new()));
824        let b1 = buffer.clone();
825        let b2 = buffer.clone();
826
827        let ok_cmd = TextInsertCmd::new(WidgetId::new(1), 0, "ok")
828            .with_apply(move |_, _, txt| {
829                let mut buf = b1.lock().unwrap();
830                buf.push_str(txt);
831                Ok(())
832            })
833            .with_remove(move |_, _, _| {
834                let mut buf = b2.lock().unwrap();
835                buf.drain(..2);
836                Ok(())
837            });
838
839        // A command that fails on execute (no apply callback)
840        let fail_cmd = TextInsertCmd::new(WidgetId::new(1), 2, "fail");
841
842        let mut txn = Transaction::begin("Execute Failure");
843        txn.execute(Box::new(ok_cmd)).unwrap();
844        assert_eq!(*buffer.lock().unwrap(), "ok");
845
846        // This should fail and rollback the "ok" command
847        let result = txn.execute(Box::new(fail_cmd));
848        assert!(result.is_err());
849        assert_eq!(*buffer.lock().unwrap(), "");
850    }
851
852    #[test]
853    fn test_transaction_is_empty_after_add() {
854        let buffer = Arc::new(Mutex::new(String::new()));
855        let mut txn = Transaction::begin("Not Empty");
856        assert!(txn.is_empty());
857
858        txn.add_executed(make_cmd(buffer, "x")).unwrap();
859        assert!(!txn.is_empty());
860    }
861
862    #[test]
863    fn test_scope_execute_failure_without_txn() {
864        let mut history = HistoryManager::new(HistoryConfig::unlimited());
865
866        // Execute without begin() with a failing command
867        let fail_cmd = TextInsertCmd::new(WidgetId::new(1), 0, "fail");
868        // No callbacks, so execute will fail
869
870        {
871            let mut scope = TransactionScope::new(&mut history);
872            let result = scope.execute(Box::new(fail_cmd));
873            assert!(result.is_err());
874        }
875        assert_eq!(history.undo_depth(), 0);
876    }
877
878    // ====================================================================
879    // Additional coverage: sequential scopes, deep nesting, edge cases
880    // (bd-1o6q1)
881    // ====================================================================
882
883    #[test]
884    fn test_scope_sequential_transactions() {
885        let buffer = Arc::new(Mutex::new(String::new()));
886        let mut history = HistoryManager::new(HistoryConfig::unlimited());
887
888        {
889            let mut scope = TransactionScope::new(&mut history);
890
891            // First transaction
892            scope.begin("First");
893            scope.execute(make_scope_cmd(buffer.clone(), "a")).unwrap();
894            scope.commit().unwrap();
895
896            // Second transaction in same scope
897            scope.begin("Second");
898            scope.execute(make_scope_cmd(buffer.clone(), "b")).unwrap();
899            scope.commit().unwrap();
900        }
901
902        // Both should be separate history entries
903        assert_eq!(history.undo_depth(), 2);
904    }
905
906    #[test]
907    fn test_scope_three_level_nesting() {
908        let buffer = Arc::new(Mutex::new(String::new()));
909        let mut history = HistoryManager::new(HistoryConfig::unlimited());
910
911        {
912            let mut scope = TransactionScope::new(&mut history);
913
914            scope.begin("Level 1");
915            scope.execute(make_scope_cmd(buffer.clone(), "a")).unwrap();
916
917            scope.begin("Level 2");
918            scope.execute(make_scope_cmd(buffer.clone(), "b")).unwrap();
919
920            scope.begin("Level 3");
921            scope.execute(make_scope_cmd(buffer.clone(), "c")).unwrap();
922            assert_eq!(scope.depth(), 3);
923
924            scope.commit().unwrap();
925            scope.commit().unwrap();
926            scope.commit().unwrap();
927        }
928
929        assert_eq!(history.undo_depth(), 1);
930        assert_eq!(*buffer.lock().unwrap(), "abc");
931
932        history.undo();
933        assert_eq!(*buffer.lock().unwrap(), "");
934    }
935
936    #[test]
937    fn test_scope_alternating_commit_rollback() {
938        let mut history = HistoryManager::new(HistoryConfig::unlimited());
939
940        {
941            let mut scope = TransactionScope::new(&mut history);
942
943            // First: commit
944            scope.begin("Committed");
945            let buf1 = Arc::new(Mutex::new(String::new()));
946            scope.execute(make_scope_cmd(buf1, "ok")).unwrap();
947            scope.commit().unwrap();
948
949            // Second: rollback
950            scope.begin("Rolled back");
951            let buf2 = Arc::new(Mutex::new(String::new()));
952            scope.execute(make_scope_cmd(buf2, "no")).unwrap();
953            scope.rollback().unwrap();
954
955            // Third: commit
956            scope.begin("Also committed");
957            let buf3 = Arc::new(Mutex::new(String::new()));
958            scope.execute(make_scope_cmd(buf3, "yes")).unwrap();
959            scope.commit().unwrap();
960        }
961
962        // Two committed, one rolled back
963        assert_eq!(history.undo_depth(), 2);
964    }
965
966    #[test]
967    fn test_scope_rollback_then_new_transaction() {
968        let buf_bad = Arc::new(Mutex::new(String::new()));
969        let buf_good = Arc::new(Mutex::new(String::new()));
970        let mut history = HistoryManager::new(HistoryConfig::unlimited());
971
972        {
973            let mut scope = TransactionScope::new(&mut history);
974
975            scope.begin("Failed attempt");
976            scope
977                .execute(make_scope_cmd(buf_bad.clone(), "bad"))
978                .unwrap();
979            scope.rollback().unwrap();
980
981            scope.begin("Retry");
982            scope
983                .execute(make_scope_cmd(buf_good.clone(), "good"))
984                .unwrap();
985            scope.commit().unwrap();
986        }
987
988        assert_eq!(history.undo_depth(), 1);
989        assert_eq!(*buf_bad.lock().unwrap(), "");
990        assert_eq!(*buf_good.lock().unwrap(), "good");
991    }
992
993    #[test]
994    fn test_transaction_len_after_execute() {
995        let buffer = Arc::new(Mutex::new(String::new()));
996        let b1 = buffer.clone();
997        let b2 = buffer.clone();
998
999        let cmd = TextInsertCmd::new(WidgetId::new(1), 0, "x")
1000            .with_apply(move |_, _, txt| {
1001                b1.lock().unwrap().push_str(txt);
1002                Ok(())
1003            })
1004            .with_remove(move |_, _, _| {
1005                b2.lock().unwrap().drain(..1);
1006                Ok(())
1007            });
1008
1009        let mut txn = Transaction::begin("Len Test");
1010        assert_eq!(txn.len(), 0);
1011
1012        txn.execute(Box::new(cmd)).unwrap();
1013        assert_eq!(txn.len(), 1);
1014    }
1015
1016    #[test]
1017    fn test_transaction_many_commands() {
1018        let buffer = Arc::new(Mutex::new(String::new()));
1019        let mut txn = Transaction::begin("Many Commands");
1020
1021        for _ in 0..20 {
1022            txn.add_executed(make_cmd(buffer.clone(), ".")).unwrap();
1023        }
1024
1025        assert_eq!(txn.len(), 20);
1026        assert_eq!(buffer.lock().unwrap().len(), 20);
1027
1028        txn.rollback();
1029        assert_eq!(*buffer.lock().unwrap(), "");
1030    }
1031
1032    #[test]
1033    fn test_scope_execute_after_all_committed() {
1034        let buffer = Arc::new(Mutex::new(String::new()));
1035        let mut history = HistoryManager::new(HistoryConfig::unlimited());
1036
1037        {
1038            let mut scope = TransactionScope::new(&mut history);
1039
1040            // Transaction
1041            scope.begin("Txn");
1042            scope.execute(make_scope_cmd(buffer.clone(), "a")).unwrap();
1043            scope.commit().unwrap();
1044
1045            // Direct execute (no active transaction) goes to history
1046            scope.execute(make_scope_cmd(buffer.clone(), "b")).unwrap();
1047        }
1048
1049        // One from transaction, one from direct execute
1050        assert_eq!(history.undo_depth(), 2);
1051        assert_eq!(*buffer.lock().unwrap(), "ab");
1052    }
1053
1054    #[test]
1055    fn test_scope_inner_commit_empty_outer_has_content() {
1056        let buffer = Arc::new(Mutex::new(String::new()));
1057        let mut history = HistoryManager::new(HistoryConfig::unlimited());
1058
1059        {
1060            let mut scope = TransactionScope::new(&mut history);
1061
1062            scope.begin("Outer with content");
1063            scope
1064                .execute(make_scope_cmd(buffer.clone(), "outer"))
1065                .unwrap();
1066
1067            scope.begin("Empty inner");
1068            scope.commit().unwrap();
1069
1070            scope.commit().unwrap();
1071        }
1072
1073        assert_eq!(history.undo_depth(), 1);
1074        assert_eq!(*buffer.lock().unwrap(), "outer");
1075    }
1076
1077    #[test]
1078    fn test_drop_transaction_without_finalize_rolls_back() {
1079        let buffer = Arc::new(Mutex::new(String::new()));
1080
1081        {
1082            let mut txn = Transaction::begin("Will be dropped");
1083            txn.add_executed(make_cmd(buffer.clone(), "dropped"))
1084                .unwrap();
1085            assert_eq!(*buffer.lock().unwrap(), "dropped");
1086            // txn dropped here without commit or rollback
1087        }
1088
1089        // Drop should auto-rollback
1090        assert_eq!(*buffer.lock().unwrap(), "");
1091    }
1092
1093    /// Helper creating a command that is NOT pre-executed (for scope.execute).
1094    fn make_scope_cmd(buffer: Arc<Mutex<String>>, text: &str) -> Box<dyn UndoableCmd> {
1095        let b1 = buffer.clone();
1096        let b2 = buffer.clone();
1097        let text = text.to_string();
1098        let text_clone = text.clone();
1099
1100        Box::new(
1101            TextInsertCmd::new(WidgetId::new(1), 0, text)
1102                .with_apply(move |_, _, txt| {
1103                    let mut buf = b1.lock().unwrap();
1104                    buf.push_str(txt);
1105                    Ok(())
1106                })
1107                .with_remove(move |_, _, _| {
1108                    let mut buf = b2.lock().unwrap();
1109                    let new_len = buf.len().saturating_sub(text_clone.len());
1110                    buf.truncate(new_len);
1111                    Ok(())
1112                }),
1113        )
1114    }
1115
1116    #[test]
1117    fn test_scope_nested_rollback_preserves_outer() {
1118        let buf_outer = Arc::new(Mutex::new(String::new()));
1119        let buf_inner = Arc::new(Mutex::new(String::new()));
1120        let mut history = HistoryManager::new(HistoryConfig::unlimited());
1121
1122        {
1123            let mut scope = TransactionScope::new(&mut history);
1124
1125            scope.begin("Outer");
1126            scope
1127                .execute(make_scope_cmd(buf_outer.clone(), "A"))
1128                .unwrap();
1129            assert_eq!(*buf_outer.lock().unwrap(), "A");
1130
1131            scope.begin("Inner (will rollback)");
1132            scope
1133                .execute(make_scope_cmd(buf_inner.clone(), "B"))
1134                .unwrap();
1135            assert_eq!(*buf_inner.lock().unwrap(), "B");
1136
1137            scope.rollback().unwrap();
1138            assert_eq!(*buf_inner.lock().unwrap(), "");
1139            assert_eq!(*buf_outer.lock().unwrap(), "A");
1140
1141            scope.commit().unwrap();
1142        }
1143
1144        assert_eq!(history.undo_depth(), 1);
1145        assert_eq!(*buf_outer.lock().unwrap(), "A");
1146    }
1147}