Skip to main content

ftui_runtime/undo/
snapshot_store.rs

1#![forbid(unsafe_code)]
2
3//! Persistent-snapshot undo/redo store.
4//!
5//! [`SnapshotStore`] provides O(1) snapshot capture and restore using
6//! [`Arc`]-based structural sharing. Each call to [`push`](SnapshotStore::push)
7//! clones the `Arc` (not the state), so 1000 snapshots of a large state
8//! cost barely more than a single copy when the state uses persistent
9//! data structures (e.g., `im::HashMap`, `im::Vector`).
10//!
11//! # Architecture
12//!
13//! ```text
14//! push(s3)
15//! ┌──────────────────────────────────────────────────┐
16//! │ Undo Stack:  [Arc(s0), Arc(s1), Arc(s2), Arc(s3)]│
17//! │ Redo Stack:  []                                   │
18//! │ Current:     Arc(s3)                              │
19//! └──────────────────────────────────────────────────┘
20//!
21//! undo() x2
22//! ┌──────────────────────────────────────────────────┐
23//! │ Undo Stack:  [Arc(s0), Arc(s1)]                  │
24//! │ Redo Stack:  [Arc(s2), Arc(s3)]                  │
25//! │ Current:     Arc(s1)                              │
26//! └──────────────────────────────────────────────────┘
27//!
28//! push(s4) — new branch, clears redo
29//! ┌──────────────────────────────────────────────────┐
30//! │ Undo Stack:  [Arc(s0), Arc(s1), Arc(s4)]         │
31//! │ Redo Stack:  []                                   │
32//! │ Current:     Arc(s4)                              │
33//! └──────────────────────────────────────────────────┘
34//! ```
35//!
36//! # When to Use
37//!
38//! Use `SnapshotStore` when your state type `T` uses persistent collections
39//! (e.g., `im::HashMap`, `im::Vector`) so that `Arc::new(state.clone())`
40//! is cheap thanks to structural sharing. For command-pattern undo
41//! (reversible mutations), use [`HistoryManager`](super::HistoryManager).
42//!
43//! # Memory Model
44//!
45//! Each snapshot is an `Arc<T>`. When `T` uses persistent data structures,
46//! cloning `T` shares most of the underlying memory. The store enforces
47//! configurable depth limits. Memory is reclaimed when the last `Arc`
48//! referencing a snapshot is dropped.
49
50use std::collections::VecDeque;
51use std::fmt;
52use std::sync::Arc;
53
54/// Configuration for the snapshot store.
55#[derive(Debug, Clone)]
56pub struct SnapshotConfig {
57    /// Maximum number of snapshots to retain in the undo stack.
58    /// Oldest snapshots are evicted when this limit is exceeded.
59    pub max_depth: usize,
60}
61
62impl Default for SnapshotConfig {
63    fn default() -> Self {
64        Self { max_depth: 100 }
65    }
66}
67
68impl SnapshotConfig {
69    /// Create a new configuration with the given depth limit.
70    #[must_use]
71    pub fn new(max_depth: usize) -> Self {
72        Self { max_depth }
73    }
74
75    /// Create an unlimited configuration (for testing).
76    #[must_use]
77    pub fn unlimited() -> Self {
78        Self {
79            max_depth: usize::MAX,
80        }
81    }
82}
83
84/// A snapshot-based undo/redo store using `Arc<T>` for structural sharing.
85///
86/// `T` should ideally use persistent data structures internally (e.g.,
87/// `im::HashMap`) so that cloning is O(1) and snapshots share memory.
88///
89/// # Invariants
90///
91/// 1. `undo_stack` is never empty after the first `push`.
92/// 2. `undo_stack.len() <= config.max_depth` (after any operation).
93/// 3. Redo stack is cleared on every `push`.
94/// 4. `current()` always returns the most recently pushed or restored snapshot.
95pub struct SnapshotStore<T> {
96    /// Snapshots available for undo (current state is at the back).
97    undo_stack: VecDeque<Arc<T>>,
98    /// Snapshots available for redo (most recently undone at back).
99    redo_stack: VecDeque<Arc<T>>,
100    /// Configuration.
101    config: SnapshotConfig,
102}
103
104impl<T: fmt::Debug> fmt::Debug for SnapshotStore<T> {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        f.debug_struct("SnapshotStore")
107            .field("undo_depth", &self.undo_stack.len())
108            .field("redo_depth", &self.redo_stack.len())
109            .field("config", &self.config)
110            .finish()
111    }
112}
113
114impl<T> SnapshotStore<T> {
115    /// Create a new snapshot store with the given configuration.
116    #[must_use]
117    pub fn new(config: SnapshotConfig) -> Self {
118        Self {
119            undo_stack: VecDeque::new(),
120            redo_stack: VecDeque::new(),
121            config,
122        }
123    }
124
125    /// Create a new snapshot store with default configuration.
126    #[must_use]
127    pub fn with_default_config() -> Self {
128        Self::new(SnapshotConfig::default())
129    }
130
131    // ====================================================================
132    // Core Operations
133    // ====================================================================
134
135    /// Push a new snapshot, clearing the redo stack (new branch).
136    ///
137    /// The snapshot is wrapped in `Arc` for structural sharing.
138    /// If the undo stack exceeds `max_depth`, the oldest snapshot is evicted.
139    pub fn push(&mut self, state: T) {
140        self.redo_stack.clear();
141        self.undo_stack.push_back(Arc::new(state));
142        self.enforce_depth();
143    }
144
145    /// Push a pre-wrapped `Arc<T>` snapshot.
146    ///
147    /// Use this when you already have an `Arc<T>` and want to avoid
148    /// double-wrapping.
149    pub fn push_arc(&mut self, state: Arc<T>) {
150        self.redo_stack.clear();
151        self.undo_stack.push_back(state);
152        self.enforce_depth();
153    }
154
155    /// Undo: move the current snapshot to the redo stack and return
156    /// the previous snapshot.
157    ///
158    /// Returns `None` if there is only one snapshot (the initial state)
159    /// or the store is empty.
160    pub fn undo(&mut self) -> Option<Arc<T>> {
161        // Need at least 2 items: the one we pop goes to redo, the new back is current
162        if self.undo_stack.len() < 2 {
163            return None;
164        }
165        let current = self.undo_stack.pop_back()?;
166        self.redo_stack.push_back(current);
167        self.undo_stack.back().cloned()
168    }
169
170    /// Redo: move the most recently undone snapshot back to the undo stack.
171    ///
172    /// Returns `None` if there is nothing to redo.
173    pub fn redo(&mut self) -> Option<Arc<T>> {
174        let snapshot = self.redo_stack.pop_back()?;
175        self.undo_stack.push_back(snapshot);
176        self.undo_stack.back().cloned()
177    }
178
179    /// Get the current snapshot (the most recent on the undo stack).
180    ///
181    /// Returns `None` if the store is empty.
182    #[must_use]
183    pub fn current(&self) -> Option<&Arc<T>> {
184        self.undo_stack.back()
185    }
186
187    // ====================================================================
188    // Query
189    // ====================================================================
190
191    /// Check if undo is available.
192    #[must_use]
193    pub fn can_undo(&self) -> bool {
194        self.undo_stack.len() >= 2
195    }
196
197    /// Check if redo is available.
198    #[must_use]
199    pub fn can_redo(&self) -> bool {
200        !self.redo_stack.is_empty()
201    }
202
203    /// Number of snapshots on the undo stack (including current).
204    #[must_use]
205    pub fn undo_depth(&self) -> usize {
206        self.undo_stack.len()
207    }
208
209    /// Number of snapshots on the redo stack.
210    #[must_use]
211    pub fn redo_depth(&self) -> usize {
212        self.redo_stack.len()
213    }
214
215    /// Total number of snapshots across both stacks.
216    #[must_use]
217    pub fn total_snapshots(&self) -> usize {
218        self.undo_stack.len() + self.redo_stack.len()
219    }
220
221    /// Get the configuration.
222    #[must_use]
223    pub fn config(&self) -> &SnapshotConfig {
224        &self.config
225    }
226
227    /// Check if the store is empty (no snapshots at all).
228    #[must_use]
229    pub fn is_empty(&self) -> bool {
230        self.undo_stack.is_empty()
231    }
232
233    // ====================================================================
234    // Maintenance
235    // ====================================================================
236
237    /// Clear all snapshots.
238    pub fn clear(&mut self) {
239        self.undo_stack.clear();
240        self.redo_stack.clear();
241    }
242
243    /// Enforce the depth limit by evicting the oldest snapshots.
244    fn enforce_depth(&mut self) {
245        while self.undo_stack.len() > self.config.max_depth {
246            self.undo_stack.pop_front();
247        }
248    }
249}
250
251// ============================================================================
252// Re-export persistent data structure types when the `hamt` feature is enabled
253// ============================================================================
254
255/// Persistent collection types for snapshot-friendly state.
256///
257/// When the `hamt` feature is enabled, this module re-exports types from
258/// the [`im`] crate. These collections use hash-array-mapped tries (HAMT)
259/// and relaxed-radix-balanced trees (RRB) for O(log n) structural sharing
260/// on clone.
261///
262/// # Example
263///
264/// ```ignore
265/// use ftui_runtime::undo::snapshot_store::persistent;
266///
267/// let mut map = persistent::HashMap::new();
268/// map.insert("key", 42);
269/// let snapshot = map.clone(); // O(log n) — shares structure
270/// map.insert("key2", 99);
271/// // `snapshot` still has only "key" → 42
272/// ```
273#[cfg(feature = "hamt")]
274pub mod persistent {
275    pub use im::{HashMap, HashSet, OrdMap, OrdSet, Vector};
276}
277
278// ============================================================================
279// Tests
280// ============================================================================
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn new_store_is_empty() {
288        let store = SnapshotStore::<i32>::with_default_config();
289        assert!(store.is_empty());
290        assert!(!store.can_undo());
291        assert!(!store.can_redo());
292        assert_eq!(store.undo_depth(), 0);
293        assert_eq!(store.redo_depth(), 0);
294        assert_eq!(store.total_snapshots(), 0);
295        assert!(store.current().is_none());
296    }
297
298    #[test]
299    fn push_makes_current_available() {
300        let mut store = SnapshotStore::with_default_config();
301        store.push(42);
302        assert!(!store.is_empty());
303        assert_eq!(**store.current().unwrap(), 42);
304        assert_eq!(store.undo_depth(), 1);
305        assert!(!store.can_undo()); // Need at least 2 to undo
306    }
307
308    #[test]
309    fn push_two_enables_undo() {
310        let mut store = SnapshotStore::with_default_config();
311        store.push(1);
312        store.push(2);
313        assert!(store.can_undo());
314        assert!(!store.can_redo());
315        assert_eq!(store.undo_depth(), 2);
316    }
317
318    #[test]
319    fn undo_restores_previous() {
320        let mut store = SnapshotStore::with_default_config();
321        store.push(1);
322        store.push(2);
323        store.push(3);
324
325        let prev = store.undo().unwrap();
326        assert_eq!(*prev, 2);
327        assert_eq!(**store.current().unwrap(), 2);
328        assert!(store.can_redo());
329    }
330
331    #[test]
332    fn undo_all_stops_at_initial() {
333        let mut store = SnapshotStore::with_default_config();
334        store.push(1);
335        store.push(2);
336        store.push(3);
337
338        assert!(store.undo().is_some()); // 3 → 2
339        assert!(store.undo().is_some()); // 2 → 1
340        assert!(store.undo().is_none()); // can't undo past initial
341        assert_eq!(**store.current().unwrap(), 1);
342    }
343
344    #[test]
345    fn redo_restores_undone() {
346        let mut store = SnapshotStore::with_default_config();
347        store.push(1);
348        store.push(2);
349        store.undo();
350
351        let restored = store.redo().unwrap();
352        assert_eq!(*restored, 2);
353        assert_eq!(**store.current().unwrap(), 2);
354        assert!(!store.can_redo());
355    }
356
357    #[test]
358    fn push_clears_redo() {
359        let mut store = SnapshotStore::with_default_config();
360        store.push(1);
361        store.push(2);
362        store.undo();
363        assert!(store.can_redo());
364
365        store.push(3); // New branch
366        assert!(!store.can_redo());
367        assert_eq!(store.redo_depth(), 0);
368        assert_eq!(**store.current().unwrap(), 3);
369    }
370
371    #[test]
372    fn redo_on_empty_returns_none() {
373        let mut store = SnapshotStore::<i32>::with_default_config();
374        assert!(store.redo().is_none());
375    }
376
377    #[test]
378    fn undo_on_empty_returns_none() {
379        let mut store = SnapshotStore::<i32>::with_default_config();
380        assert!(store.undo().is_none());
381    }
382
383    #[test]
384    fn undo_on_single_returns_none() {
385        let mut store = SnapshotStore::with_default_config();
386        store.push(42);
387        assert!(store.undo().is_none());
388    }
389
390    #[test]
391    fn depth_limit_evicts_oldest() {
392        let mut store = SnapshotStore::new(SnapshotConfig::new(3));
393        store.push(1);
394        store.push(2);
395        store.push(3);
396        store.push(4);
397
398        assert_eq!(store.undo_depth(), 3);
399        // Oldest (1) was evicted. Current is 4, can undo to 3 then 2.
400        assert_eq!(**store.current().unwrap(), 4);
401        let prev = store.undo().unwrap();
402        assert_eq!(*prev, 3);
403        let prev = store.undo().unwrap();
404        assert_eq!(*prev, 2);
405        assert!(store.undo().is_none());
406    }
407
408    #[test]
409    fn depth_limit_one_keeps_only_latest() {
410        let mut store = SnapshotStore::new(SnapshotConfig::new(1));
411        store.push(1);
412        store.push(2);
413        store.push(3);
414
415        assert_eq!(store.undo_depth(), 1);
416        assert_eq!(**store.current().unwrap(), 3);
417        assert!(!store.can_undo());
418    }
419
420    #[test]
421    fn clear_removes_all() {
422        let mut store = SnapshotStore::with_default_config();
423        store.push(1);
424        store.push(2);
425        store.undo();
426
427        store.clear();
428
429        assert!(store.is_empty());
430        assert!(!store.can_undo());
431        assert!(!store.can_redo());
432        assert_eq!(store.total_snapshots(), 0);
433    }
434
435    #[test]
436    fn multiple_undo_redo_cycle() {
437        let mut store = SnapshotStore::with_default_config();
438        store.push(1);
439        store.push(2);
440        store.push(3);
441
442        // Undo all
443        store.undo(); // → 2
444        store.undo(); // → 1
445
446        assert_eq!(store.undo_depth(), 1);
447        assert_eq!(store.redo_depth(), 2);
448
449        // Redo all
450        store.redo(); // → 2
451        store.redo(); // → 3
452
453        assert_eq!(store.undo_depth(), 3);
454        assert_eq!(store.redo_depth(), 0);
455        assert_eq!(**store.current().unwrap(), 3);
456    }
457
458    #[test]
459    fn push_arc_avoids_double_wrap() {
460        let mut store = SnapshotStore::with_default_config();
461        let arc = Arc::new(42);
462        store.push_arc(arc.clone());
463
464        assert_eq!(**store.current().unwrap(), 42);
465        // The Arc in the store should be the same as our original
466        assert!(Arc::ptr_eq(store.current().unwrap(), &arc));
467    }
468
469    #[test]
470    fn structural_sharing_verified() {
471        // Verify that multiple snapshots share the same Arc
472        let mut store = SnapshotStore::with_default_config();
473        let state = Arc::new(vec![1, 2, 3, 4, 5]);
474        store.push_arc(state.clone());
475
476        // Push same Arc again (simulating clone of persistent state)
477        store.push_arc(state.clone());
478
479        // Both snapshots should point to the same allocation
480        let s1 = store.undo().unwrap();
481        let s2 = store.current().unwrap();
482        // After undo, s1 is the undone (2nd push), s2 is current (1st push)
483        // They should both be the same Arc
484        assert!(Arc::ptr_eq(&s1, s2));
485    }
486
487    #[test]
488    fn many_snapshots_within_memory() {
489        // Verify that 1000 snapshots of an Arc<Vec> don't blow up memory
490        let mut store = SnapshotStore::new(SnapshotConfig::new(1000));
491        let data = Arc::new(vec![0u8; 1024]); // 1KB payload
492
493        for _ in 0..1000 {
494            store.push_arc(data.clone());
495        }
496
497        // All 1000 snapshots share the same underlying Vec
498        assert_eq!(store.undo_depth(), 1000);
499        assert_eq!(Arc::strong_count(&data), 1001); // 1 original + 1000 in store
500    }
501
502    #[test]
503    fn config_default() {
504        let config = SnapshotConfig::default();
505        assert_eq!(config.max_depth, 100);
506    }
507
508    #[test]
509    fn config_unlimited() {
510        let config = SnapshotConfig::unlimited();
511        assert_eq!(config.max_depth, usize::MAX);
512    }
513
514    #[test]
515    fn config_clone() {
516        let config = SnapshotConfig::new(50);
517        let cloned = config.clone();
518        assert_eq!(cloned.max_depth, 50);
519    }
520
521    #[test]
522    fn config_debug() {
523        let config = SnapshotConfig::new(42);
524        let s = format!("{config:?}");
525        assert!(s.contains("42"));
526    }
527
528    #[test]
529    fn store_debug() {
530        let mut store = SnapshotStore::with_default_config();
531        store.push(1);
532        let s = format!("{store:?}");
533        assert!(s.contains("SnapshotStore"));
534        assert!(s.contains("undo_depth"));
535    }
536
537    #[test]
538    fn config_accessor() {
539        let store = SnapshotStore::<i32>::new(SnapshotConfig::new(42));
540        assert_eq!(store.config().max_depth, 42);
541    }
542
543    #[test]
544    fn total_snapshots_accounts_for_both_stacks() {
545        let mut store = SnapshotStore::with_default_config();
546        store.push(1);
547        store.push(2);
548        store.push(3);
549        assert_eq!(store.total_snapshots(), 3);
550
551        store.undo();
552        assert_eq!(store.total_snapshots(), 3); // 2 undo + 1 redo
553
554        store.undo();
555        assert_eq!(store.total_snapshots(), 3); // 1 undo + 2 redo
556    }
557
558    // ====================================================================
559    // im crate integration tests (always available via dev-dependency)
560    // ====================================================================
561
562    #[test]
563    fn im_hashmap_structural_sharing() {
564        use im::HashMap;
565
566        let mut map = HashMap::new();
567        for i in 0..1000 {
568            map.insert(format!("key_{i}"), i);
569        }
570
571        let mut store = SnapshotStore::with_default_config();
572
573        // Push initial state
574        store.push(map.clone());
575
576        // Mutate and push — clone is O(log n) due to structural sharing
577        let mut map2 = map.clone();
578        map2.insert("new_key".to_string(), 9999);
579        store.push(map2);
580
581        // Undo should restore the original map
582        let prev = store.undo().unwrap();
583        assert_eq!(prev.len(), 1000);
584        assert!(!prev.contains_key("new_key"));
585
586        // Redo should restore the mutated map
587        let restored = store.redo().unwrap();
588        assert_eq!(restored.len(), 1001);
589        assert_eq!(restored.get("new_key"), Some(&9999));
590    }
591
592    #[test]
593    fn im_vector_structural_sharing() {
594        use im::Vector;
595
596        let mut vec: Vector<u32> = (0..1000).collect();
597
598        let mut store = SnapshotStore::with_default_config();
599        store.push(vec.clone());
600
601        // Mutate (append)
602        vec.push_back(9999);
603        store.push(vec);
604
605        // Undo
606        let prev = store.undo().unwrap();
607        assert_eq!(prev.len(), 1000);
608
609        // Redo
610        let restored = store.redo().unwrap();
611        assert_eq!(restored.len(), 1001);
612        assert_eq!(restored.back(), Some(&9999));
613    }
614
615    #[test]
616    fn im_hashmap_many_snapshots_memory_efficiency() {
617        use im::HashMap;
618
619        // Create a "large" state
620        let mut state: HashMap<String, Vec<u8>> = HashMap::new();
621        for i in 0..100 {
622            state.insert(format!("entry_{i}"), vec![0u8; 100]);
623        }
624
625        let mut store = SnapshotStore::new(SnapshotConfig::new(1000));
626
627        // Take 50 snapshots with small mutations each
628        for i in 0..50 {
629            store.push(state.clone());
630            // Small mutation — only 1 key changes
631            state.insert(format!("entry_{}", i % 100), vec![i as u8; 100]);
632        }
633
634        assert_eq!(store.undo_depth(), 50);
635
636        // All snapshots should be valid and distinct
637        for _ in 0..49 {
638            let prev = store.undo().unwrap();
639            assert_eq!(prev.len(), 100);
640        }
641        assert!(store.undo().is_none());
642    }
643
644    #[test]
645    fn depth_limit_zero_evicts_everything() {
646        let mut store = SnapshotStore::new(SnapshotConfig::new(0));
647        store.push(42);
648        assert!(store.is_empty());
649    }
650
651    #[test]
652    fn push_arc_clears_redo() {
653        let mut store = SnapshotStore::with_default_config();
654        store.push(1);
655        store.push(2);
656        store.undo();
657        assert!(store.can_redo());
658
659        store.push_arc(Arc::new(3));
660        assert!(!store.can_redo());
661    }
662
663    #[test]
664    fn undo_redo_returns_correct_arc() {
665        let mut store = SnapshotStore::with_default_config();
666        store.push("a");
667        store.push("b");
668        store.push("c");
669
670        let undone = store.undo().unwrap();
671        assert_eq!(*undone, "b");
672
673        let redone = store.redo().unwrap();
674        assert_eq!(*redone, "c");
675    }
676}