Skip to main content

ftui_runtime/undo/
history.rs

1#![forbid(unsafe_code)]
2
3//! History stack for undo/redo operations.
4//!
5//! This module provides the [`HistoryManager`] which maintains dual stacks
6//! for undo and redo operations with support for:
7//!
8//! - **Memory limits**: Oldest commands evicted when budget exceeded
9//! - **Depth limits**: Maximum number of commands in history
10//! - **Branch handling**: New actions clear the redo stack
11//! - **Command merging**: Consecutive similar commands batched together
12//!
13//! # Invariants
14//!
15//! 1. `total_bytes` always equals sum of `size_bytes()` for all commands
16//! 2. `undo_stack.len() <= config.max_depth` (after any operation)
17//! 3. `total_bytes <= config.max_bytes` (after any operation, if enforced)
18//! 4. Redo stack is cleared whenever a new command is pushed
19//!
20//! # Memory Model
21//!
22//! Commands are stored in `VecDeque` for O(1) eviction from the front.
23//! Memory accounting uses each command's `size_bytes()` method.
24//!
25//! ```text
26//! push(cmd5)
27//! ┌───────────────────────────────────────────────┐
28//! │ Undo Stack: [cmd1, cmd2, cmd3, cmd4, cmd5]    │
29//! │ Redo Stack: []                                 │
30//! └───────────────────────────────────────────────┘
31//!
32//! undo() x2
33//! ┌───────────────────────────────────────────────┐
34//! │ Undo Stack: [cmd1, cmd2, cmd3]                │
35//! │ Redo Stack: [cmd4, cmd5]                       │
36//! └───────────────────────────────────────────────┘
37//!
38//! push(cmd6)  <-- new branch, clears redo
39//! ┌───────────────────────────────────────────────┐
40//! │ Undo Stack: [cmd1, cmd2, cmd3, cmd6]          │
41//! │ Redo Stack: []                                 │
42//! └───────────────────────────────────────────────┘
43//! ```
44
45use std::collections::VecDeque;
46use std::fmt;
47
48use super::command::{MergeConfig, UndoableCmd};
49
50/// Configuration for the history manager.
51#[derive(Debug, Clone)]
52pub struct HistoryConfig {
53    /// Maximum number of commands to keep in undo history.
54    pub max_depth: usize,
55    /// Maximum total bytes for all commands (0 = unlimited).
56    pub max_bytes: usize,
57    /// Configuration for command merging.
58    pub merge_config: MergeConfig,
59}
60
61impl Default for HistoryConfig {
62    fn default() -> Self {
63        Self {
64            max_depth: 100,
65            max_bytes: 10 * 1024 * 1024, // 10 MB
66            merge_config: MergeConfig::default(),
67        }
68    }
69}
70
71impl HistoryConfig {
72    /// Create a new configuration with custom limits.
73    #[must_use]
74    pub fn new(max_depth: usize, max_bytes: usize) -> Self {
75        Self {
76            max_depth,
77            max_bytes,
78            merge_config: MergeConfig::default(),
79        }
80    }
81
82    /// Set the merge configuration.
83    #[must_use]
84    pub fn with_merge_config(mut self, config: MergeConfig) -> Self {
85        self.merge_config = config;
86        self
87    }
88
89    /// Create unlimited configuration (for testing).
90    #[must_use]
91    pub fn unlimited() -> Self {
92        Self {
93            max_depth: usize::MAX,
94            max_bytes: 0,
95            merge_config: MergeConfig::default(),
96        }
97    }
98}
99
100/// Manager for undo/redo history.
101///
102/// Maintains dual stacks for undo and redo operations with
103/// configurable memory and depth limits.
104pub struct HistoryManager {
105    /// Commands available for undo (newest at back).
106    undo_stack: VecDeque<Box<dyn UndoableCmd>>,
107    /// Commands available for redo (newest at back).
108    redo_stack: VecDeque<Box<dyn UndoableCmd>>,
109    /// Configuration for limits and merging.
110    config: HistoryConfig,
111    /// Total bytes used by all commands.
112    total_bytes: usize,
113}
114
115impl fmt::Debug for HistoryManager {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.debug_struct("HistoryManager")
118            .field("undo_depth", &self.undo_stack.len())
119            .field("redo_depth", &self.redo_stack.len())
120            .field("total_bytes", &self.total_bytes)
121            .field("config", &self.config)
122            .finish()
123    }
124}
125
126impl Default for HistoryManager {
127    fn default() -> Self {
128        Self::new(HistoryConfig::default())
129    }
130}
131
132impl HistoryManager {
133    /// Create a new history manager with the given configuration.
134    #[must_use]
135    pub fn new(config: HistoryConfig) -> Self {
136        Self {
137            undo_stack: VecDeque::new(),
138            redo_stack: VecDeque::new(),
139            config,
140            total_bytes: 0,
141        }
142    }
143
144    // ========================================================================
145    // Core Operations
146    // ========================================================================
147
148    /// Push a command onto the undo stack.
149    ///
150    /// This clears the redo stack (new branch) and enforces limits.
151    /// The command is NOT executed - it's assumed to have already been executed.
152    pub fn push(&mut self, cmd: Box<dyn UndoableCmd>) {
153        // Clear redo stack (new branch)
154        self.clear_redo();
155
156        // Try to merge with the last command
157        let cmd = match self.try_merge(cmd) {
158            Ok(()) => {
159                // Merged successfully, just enforce limits
160                self.enforce_limits();
161                return;
162            }
163            Err(cmd) => cmd,
164        };
165
166        // Add to undo stack
167        self.total_bytes += cmd.size_bytes();
168        self.undo_stack.push_back(cmd);
169
170        // Enforce limits
171        self.enforce_limits();
172    }
173
174    /// Undo the last command.
175    ///
176    /// Moves the command from undo stack to redo stack and calls undo().
177    ///
178    /// # Returns
179    ///
180    /// - `Ok(description)` if undo succeeded
181    /// - `Err(error)` if undo failed (command remains on undo stack)
182    /// - `None` if no commands to undo
183    pub fn undo(&mut self) -> Option<Result<String, super::command::CommandError>> {
184        let mut cmd = self.undo_stack.pop_back()?;
185        let description = cmd.description().to_string();
186
187        match cmd.undo() {
188            Ok(()) => {
189                // Move to redo stack
190                self.redo_stack.push_back(cmd);
191                Some(Ok(description))
192            }
193            Err(e) => {
194                // Put back on undo stack
195                self.undo_stack.push_back(cmd);
196                Some(Err(e))
197            }
198        }
199    }
200
201    /// Redo the last undone command.
202    ///
203    /// Moves the command from redo stack to undo stack and calls redo().
204    ///
205    /// # Returns
206    ///
207    /// - `Ok(description)` if redo succeeded
208    /// - `Err(error)` if redo failed (command remains on redo stack)
209    /// - `None` if no commands to redo
210    pub fn redo(&mut self) -> Option<Result<String, super::command::CommandError>> {
211        let mut cmd = self.redo_stack.pop_back()?;
212        let description = cmd.description().to_string();
213
214        match cmd.redo() {
215            Ok(()) => {
216                // Move to undo stack
217                self.undo_stack.push_back(cmd);
218                Some(Ok(description))
219            }
220            Err(e) => {
221                // Put back on redo stack
222                self.redo_stack.push_back(cmd);
223                Some(Err(e))
224            }
225        }
226    }
227
228    /// Check if undo is available.
229    #[must_use]
230    pub fn can_undo(&self) -> bool {
231        !self.undo_stack.is_empty()
232    }
233
234    /// Check if redo is available.
235    #[must_use]
236    pub fn can_redo(&self) -> bool {
237        !self.redo_stack.is_empty()
238    }
239
240    // ========================================================================
241    // Info
242    // ========================================================================
243
244    /// Get the undo stack depth.
245    #[must_use]
246    pub fn undo_depth(&self) -> usize {
247        self.undo_stack.len()
248    }
249
250    /// Get the redo stack depth.
251    #[must_use]
252    pub fn redo_depth(&self) -> usize {
253        self.redo_stack.len()
254    }
255
256    /// Get descriptions for undo commands (most recent first).
257    pub fn undo_descriptions(&self, limit: usize) -> Vec<&str> {
258        self.undo_stack
259            .iter()
260            .rev()
261            .take(limit)
262            .map(|c| c.description())
263            .collect()
264    }
265
266    /// Get descriptions for redo commands (most recent first).
267    pub fn redo_descriptions(&self, limit: usize) -> Vec<&str> {
268        self.redo_stack
269            .iter()
270            .rev()
271            .take(limit)
272            .map(|c| c.description())
273            .collect()
274    }
275
276    /// Get the description of the next undo command.
277    #[must_use]
278    pub fn next_undo_description(&self) -> Option<&str> {
279        self.undo_stack.back().map(|c| c.description())
280    }
281
282    /// Get the description of the next redo command.
283    #[must_use]
284    pub fn next_redo_description(&self) -> Option<&str> {
285        self.redo_stack.back().map(|c| c.description())
286    }
287
288    /// Get total memory usage in bytes.
289    #[must_use]
290    pub fn memory_usage(&self) -> usize {
291        self.total_bytes
292    }
293
294    /// Get the current configuration.
295    #[must_use]
296    pub fn config(&self) -> &HistoryConfig {
297        &self.config
298    }
299
300    // ========================================================================
301    // Maintenance
302    // ========================================================================
303
304    /// Clear all history (both undo and redo).
305    pub fn clear(&mut self) {
306        self.undo_stack.clear();
307        self.redo_stack.clear();
308        self.total_bytes = 0;
309    }
310
311    /// Clear only the redo stack.
312    fn clear_redo(&mut self) {
313        for cmd in self.redo_stack.drain(..) {
314            self.total_bytes = self.total_bytes.saturating_sub(cmd.size_bytes());
315        }
316    }
317
318    /// Enforce depth and memory limits by evicting oldest commands.
319    fn enforce_limits(&mut self) {
320        // Enforce depth limit (only applies to undo stack)
321        while self.undo_stack.len() > self.config.max_depth {
322            if let Some(cmd) = self.undo_stack.pop_front() {
323                self.total_bytes = self.total_bytes.saturating_sub(cmd.size_bytes());
324            }
325        }
326
327        // Enforce memory limit (if set) - applies to TOTAL history
328        if self.config.max_bytes > 0 {
329            while self.total_bytes > self.config.max_bytes {
330                // First try to drop from redo stack (future/speculative history)
331                if let Some(cmd) = self.redo_stack.pop_front() {
332                    self.total_bytes = self.total_bytes.saturating_sub(cmd.size_bytes());
333                    continue;
334                }
335
336                // Then drop from undo stack (oldest history)
337                if let Some(cmd) = self.undo_stack.pop_front() {
338                    self.total_bytes = self.total_bytes.saturating_sub(cmd.size_bytes());
339                } else {
340                    // Both stacks empty, nothing to drop
341                    break;
342                }
343            }
344        }
345    }
346
347    /// Try to merge a command with the last one on the undo stack.
348    ///
349    /// Returns `Ok(())` if merged, `Err(cmd)` if not merged.
350    fn try_merge(&mut self, cmd: Box<dyn UndoableCmd>) -> Result<(), Box<dyn UndoableCmd>> {
351        let Some(last) = self.undo_stack.back_mut() else {
352            return Err(cmd);
353        };
354
355        // Check if merge is possible
356        if !last.can_merge(cmd.as_ref(), &self.config.merge_config) {
357            return Err(cmd);
358        }
359
360        // Verify the command has merge text available
361        if cmd.merge_text().is_none() {
362            return Err(cmd);
363        }
364
365        // Adjust memory accounting (old size will change)
366        let old_size = last.size_bytes();
367
368        // Perform the merge (pass full command for position context)
369        if !last.accept_merge(cmd.as_ref()) {
370            return Err(cmd);
371        }
372
373        // Update memory accounting
374        let new_size = last.size_bytes();
375        self.total_bytes = self.total_bytes.saturating_sub(old_size) + new_size;
376
377        // The incoming command is consumed (merged into last)
378        Ok(())
379    }
380}
381
382// ============================================================================
383// Tests
384// ============================================================================
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::undo::command::{TextInsertCmd, WidgetId};
390    use std::sync::Arc;
391    use std::sync::Mutex;
392
393    /// Helper to create a simple insert command for testing.
394    /// The command is pre-executed so undo() will work correctly.
395    fn make_insert_cmd(text: &str) -> Box<dyn UndoableCmd> {
396        let buffer = Arc::new(Mutex::new(String::new()));
397        let b1 = buffer.clone();
398        let b2 = buffer.clone();
399
400        let mut cmd = TextInsertCmd::new(WidgetId::new(1), 0, text)
401            .with_apply(move |_, pos, txt| {
402                let mut buf = b1.lock().unwrap();
403                buf.insert_str(pos, txt);
404                Ok(())
405            })
406            .with_remove(move |_, pos, len| {
407                let mut buf = b2.lock().unwrap();
408                buf.drain(pos..pos + len);
409                Ok(())
410            });
411
412        // Execute the command first so undo() will work
413        cmd.execute().expect("test command should execute");
414
415        Box::new(cmd)
416    }
417
418    #[test]
419    fn test_new_manager() {
420        let mgr = HistoryManager::default();
421        assert!(!mgr.can_undo());
422        assert!(!mgr.can_redo());
423        assert_eq!(mgr.undo_depth(), 0);
424        assert_eq!(mgr.redo_depth(), 0);
425    }
426
427    #[test]
428    fn test_push_enables_undo() {
429        let mut mgr = HistoryManager::default();
430        mgr.push(make_insert_cmd("hello"));
431
432        assert!(mgr.can_undo());
433        assert!(!mgr.can_redo());
434        assert_eq!(mgr.undo_depth(), 1);
435    }
436
437    #[test]
438    fn test_undo_enables_redo() {
439        let mut mgr = HistoryManager::default();
440        mgr.push(make_insert_cmd("hello"));
441
442        let result = mgr.undo();
443        assert!(result.is_some());
444        assert!(result.unwrap().is_ok());
445
446        assert!(!mgr.can_undo());
447        assert!(mgr.can_redo());
448        assert_eq!(mgr.redo_depth(), 1);
449    }
450
451    #[test]
452    fn test_redo_moves_back_to_undo() {
453        let mut mgr = HistoryManager::default();
454        mgr.push(make_insert_cmd("hello"));
455        mgr.undo();
456
457        let result = mgr.redo();
458        assert!(result.is_some());
459        assert!(result.unwrap().is_ok());
460
461        assert!(mgr.can_undo());
462        assert!(!mgr.can_redo());
463    }
464
465    #[test]
466    fn test_push_clears_redo() {
467        let mut mgr = HistoryManager::default();
468        mgr.push(make_insert_cmd("hello"));
469        mgr.undo();
470
471        assert!(mgr.can_redo());
472
473        // Push new command
474        mgr.push(make_insert_cmd("world"));
475
476        // Redo should be cleared
477        assert!(!mgr.can_redo());
478        assert_eq!(mgr.redo_depth(), 0);
479    }
480
481    #[test]
482    fn test_max_depth_enforced() {
483        let config = HistoryConfig::new(3, 0);
484        let mut mgr = HistoryManager::new(config);
485
486        for i in 0..5 {
487            mgr.push(make_insert_cmd(&format!("cmd{}", i)));
488        }
489
490        // Should only keep 3 commands
491        assert_eq!(mgr.undo_depth(), 3);
492    }
493
494    #[test]
495    fn test_descriptions() {
496        let mut mgr = HistoryManager::default();
497        mgr.push(make_insert_cmd("a"));
498        mgr.push(make_insert_cmd("b"));
499        mgr.push(make_insert_cmd("c"));
500
501        let descs = mgr.undo_descriptions(5);
502        assert_eq!(descs.len(), 3);
503        // All should be "Insert text"
504        assert!(descs.iter().all(|d| *d == "Insert text"));
505    }
506
507    #[test]
508    fn test_next_descriptions() {
509        let mut mgr = HistoryManager::default();
510        mgr.push(make_insert_cmd("hello"));
511
512        assert_eq!(mgr.next_undo_description(), Some("Insert text"));
513        assert_eq!(mgr.next_redo_description(), None);
514
515        mgr.undo();
516
517        assert_eq!(mgr.next_undo_description(), None);
518        assert_eq!(mgr.next_redo_description(), Some("Insert text"));
519    }
520
521    #[test]
522    fn test_clear() {
523        let mut mgr = HistoryManager::default();
524        mgr.push(make_insert_cmd("a"));
525        mgr.push(make_insert_cmd("b"));
526        mgr.undo();
527
528        assert!(mgr.can_undo());
529        assert!(mgr.can_redo());
530
531        mgr.clear();
532
533        assert!(!mgr.can_undo());
534        assert!(!mgr.can_redo());
535        assert_eq!(mgr.memory_usage(), 0);
536    }
537
538    #[test]
539    fn test_memory_tracking() {
540        let mut mgr = HistoryManager::new(HistoryConfig::unlimited());
541
542        let initial = mgr.memory_usage();
543        assert_eq!(initial, 0);
544
545        mgr.push(make_insert_cmd("hello"));
546        let after_push = mgr.memory_usage();
547        assert!(after_push > 0);
548
549        mgr.push(make_insert_cmd("world"));
550        let after_second = mgr.memory_usage();
551        assert!(after_second > after_push);
552    }
553
554    #[test]
555    fn test_undo_without_commands() {
556        let mut mgr = HistoryManager::default();
557        assert!(mgr.undo().is_none());
558    }
559
560    #[test]
561    fn test_redo_without_commands() {
562        let mut mgr = HistoryManager::default();
563        assert!(mgr.redo().is_none());
564    }
565
566    #[test]
567    fn test_multiple_undo_redo_cycle() {
568        let mut mgr = HistoryManager::default();
569
570        // Push 3 commands
571        mgr.push(make_insert_cmd("a"));
572        mgr.push(make_insert_cmd("b"));
573        mgr.push(make_insert_cmd("c"));
574
575        assert_eq!(mgr.undo_depth(), 3);
576        assert_eq!(mgr.redo_depth(), 0);
577
578        // Undo all
579        mgr.undo();
580        mgr.undo();
581        mgr.undo();
582
583        assert_eq!(mgr.undo_depth(), 0);
584        assert_eq!(mgr.redo_depth(), 3);
585
586        // Redo all
587        mgr.redo();
588        mgr.redo();
589        mgr.redo();
590
591        assert_eq!(mgr.undo_depth(), 3);
592        assert_eq!(mgr.redo_depth(), 0);
593    }
594
595    #[test]
596    fn test_config_default() {
597        let config = HistoryConfig::default();
598        assert_eq!(config.max_depth, 100);
599        assert_eq!(config.max_bytes, 10 * 1024 * 1024);
600    }
601
602    #[test]
603    fn test_config_unlimited() {
604        let config = HistoryConfig::unlimited();
605        assert_eq!(config.max_depth, usize::MAX);
606        assert_eq!(config.max_bytes, 0);
607    }
608
609    #[test]
610    fn test_debug_impl() {
611        let mgr = HistoryManager::default();
612        let debug_str = format!("{:?}", mgr);
613        assert!(debug_str.contains("HistoryManager"));
614        assert!(debug_str.contains("undo_depth"));
615    }
616}