Skip to main content

neco_history/
lib.rs

1//! Tree-based edit history with undo, redo, branching, and checkpointing.
2//!
3//! This crate provides [`EditHistory`], a text-editing history that records
4//! edits as nodes in a [`neco_tree::CursoredTree`].  When the user undoes
5//! and then makes a new edit, a new branch is created instead of discarding
6//! the old timeline.
7//!
8//! Two recording modes are supported:
9//!
10//! - [`EntryKind::Reversible`]: stores forward and inverse [`TextPatch`]es.
11//!   Inverse patches are derived automatically from the current text and the
12//!   forward patches.
13//! - [`EntryKind::Snapshot`]: stores a complete text snapshot.  Use this for
14//!   operations where computing a delta is impractical (e.g. external file
15//!   reload).
16//!
17//! Periodic checkpoints (full snapshots) are inserted automatically so that
18//! [`jump_to`](EditHistory::jump_to) can reach distant nodes efficiently.
19
20use neco_textpatch::{apply_patches, inverse_patches, TextPatch};
21use neco_tree::{CursoredTree, PrunePolicy, Tree};
22
23// ---------------------------------------------------------------------------
24// EntryKind
25// ---------------------------------------------------------------------------
26
27/// Determines how an edit can be undone/redone.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EntryKind {
30    /// Reversible via forward/inverse patches.
31    Reversible,
32    /// Restored from a full text snapshot.
33    Snapshot,
34}
35
36// ---------------------------------------------------------------------------
37// HistoryEntry
38// ---------------------------------------------------------------------------
39
40/// Data stored in each history node.
41///
42/// Fields are private.  Use the accessor methods to read them.
43#[derive(Debug, Clone)]
44pub struct HistoryEntry {
45    label: String,
46    kind: EntryKind,
47    forward_patches: Option<Vec<TextPatch>>,
48    inverse_patches: Option<Vec<TextPatch>>,
49    snapshot: Option<String>,
50    checkpoint: Option<String>,
51    group_id: Option<u64>,
52}
53
54impl HistoryEntry {
55    /// Human-readable label for this edit.
56    pub fn label(&self) -> &str {
57        &self.label
58    }
59
60    /// How this entry can be reversed.
61    pub fn kind(&self) -> EntryKind {
62        self.kind
63    }
64
65    /// Forward patches (present for [`EntryKind::Reversible`]).
66    pub fn forward_patches(&self) -> Option<&[TextPatch]> {
67        self.forward_patches.as_deref()
68    }
69
70    /// Inverse patches (present for [`EntryKind::Reversible`]).
71    pub fn inverse_patches(&self) -> Option<&[TextPatch]> {
72        self.inverse_patches.as_deref()
73    }
74
75    /// Full text snapshot (present for [`EntryKind::Snapshot`]).
76    pub fn snapshot(&self) -> Option<&str> {
77        self.snapshot.as_deref()
78    }
79
80    /// Checkpoint snapshot inserted for fast long-range jumps.
81    pub fn checkpoint(&self) -> Option<&str> {
82        self.checkpoint.as_deref()
83    }
84}
85
86// ---------------------------------------------------------------------------
87// UndoResult / RedoResult / JumpStep
88// ---------------------------------------------------------------------------
89
90/// Information returned by [`EditHistory::undo`].
91#[derive(Debug, Clone)]
92pub struct UndoResult {
93    pub kind: EntryKind,
94    pub inverse_patches: Option<Vec<TextPatch>>,
95    pub snapshot: Option<String>,
96    pub label: String,
97}
98
99/// Information returned by [`EditHistory::redo`].
100#[derive(Debug, Clone)]
101pub struct RedoResult {
102    pub kind: EntryKind,
103    pub forward_patches: Option<Vec<TextPatch>>,
104    pub snapshot: Option<String>,
105    pub label: String,
106}
107
108/// A single step in a [`EditHistory::jump_to`] path.
109#[derive(Debug, Clone)]
110pub enum JumpStep {
111    /// Move toward the root (undo direction).
112    Undo(UndoResult),
113    /// Move toward a leaf (redo direction).
114    Redo(RedoResult),
115}
116
117// ---------------------------------------------------------------------------
118// EditHistory
119// ---------------------------------------------------------------------------
120
121/// Tree-structured edit history with cursor-based undo/redo.
122///
123/// The root node holds the initial text as a snapshot.  Each subsequent node
124/// stores either reversible patches or a full snapshot.
125///
126/// `EditHistory` wraps a [`CursoredTree<HistoryEntry>`].  The cursor always
127/// points to the *current* state.
128#[derive(Debug, Clone)]
129pub struct EditHistory {
130    tree: CursoredTree<HistoryEntry>,
131    checkpoint_interval: u32,
132    edits_since_checkpoint: u32,
133    active_group_id: Option<u64>,
134    group_counter: u64,
135}
136
137impl EditHistory {
138    /// Create a new history with `initial_text` stored as the root snapshot.
139    pub fn new(initial_text: &str) -> Self {
140        let root_entry = HistoryEntry {
141            label: String::new(),
142            kind: EntryKind::Snapshot,
143            forward_patches: None,
144            inverse_patches: None,
145            snapshot: Some(initial_text.to_string()),
146            checkpoint: Some(initial_text.to_string()),
147            group_id: None,
148        };
149        Self {
150            tree: CursoredTree::new(root_entry),
151            checkpoint_interval: 20,
152            edits_since_checkpoint: 0,
153            active_group_id: None,
154            group_counter: 0,
155        }
156    }
157
158    /// Record a reversible edit.
159    ///
160    /// `current_text` is the text *before* applying `forward`.  Inverse
161    /// patches are computed automatically via
162    /// [`neco_textpatch::inverse_patches`].
163    ///
164    /// Returns the id of the new history node.
165    pub fn push_edit(&mut self, label: &str, current_text: &str, forward: Vec<TextPatch>) -> u64 {
166        let inverse = inverse_patches(current_text, &forward);
167        let checkpoint = self.maybe_checkpoint(current_text, &forward);
168
169        let entry = HistoryEntry {
170            label: label.to_string(),
171            kind: EntryKind::Reversible,
172            forward_patches: Some(forward),
173            inverse_patches: Some(inverse),
174            snapshot: None,
175            checkpoint,
176            group_id: self.active_group_id,
177        };
178        self.tree.push(entry)
179    }
180
181    /// Record a snapshot-based edit.
182    ///
183    /// Use this when computing a delta is impractical (e.g. external file
184    /// reload).
185    ///
186    /// Returns the id of the new history node.
187    pub fn push_snapshot(&mut self, label: &str, full_text: String) -> u64 {
188        self.edits_since_checkpoint = 0;
189        let entry = HistoryEntry {
190            label: label.to_string(),
191            kind: EntryKind::Snapshot,
192            forward_patches: None,
193            inverse_patches: None,
194            snapshot: Some(full_text.clone()),
195            checkpoint: Some(full_text),
196            group_id: self.active_group_id,
197        };
198        self.tree.push(entry)
199    }
200
201    /// Begin a group of edits that undo and redo together.
202    ///
203    /// Nested calls are no-ops; the existing group continues.
204    pub fn begin_group(&mut self, _label: &str) {
205        if self.active_group_id.is_none() {
206            self.group_counter += 1;
207            self.active_group_id = Some(self.group_counter);
208        }
209    }
210
211    /// End the current edit group. Has no effect when no group is active.
212    pub fn end_group(&mut self) {
213        self.active_group_id = None;
214    }
215
216    /// Undo: return the information needed to reverse the current edit, then
217    /// move the cursor to the parent.
218    ///
219    /// Returns `None` when at the root (nothing to undo).
220    pub fn undo(&mut self) -> Option<Vec<UndoResult>> {
221        let mut results = Vec::new();
222        let (first_group_id, first_result) = self.undo_one()?;
223        results.push(first_result);
224
225        if let Some(group_id) = first_group_id {
226            while self.tree.has_parent() {
227                let entry = self.tree.current().value();
228                if entry.group_id != Some(group_id) {
229                    break;
230                }
231                let (_, result) = self.undo_one()?;
232                results.push(result);
233            }
234        }
235
236        Some(results)
237    }
238
239    /// Redo: move the cursor to the last (newest) child and return the
240    /// information needed to replay that edit.
241    ///
242    /// Returns `None` when there are no children.
243    pub fn redo(&mut self) -> Option<Vec<RedoResult>> {
244        let mut results = Vec::new();
245        let (first_group_id, first_result) = self.redo_one()?;
246        results.push(first_result);
247
248        if let Some(group_id) = first_group_id {
249            while self.tree.has_children() {
250                let next_index = self.tree.current().child_count() - 1;
251                let next_entry = self.tree.current().children()[next_index].value();
252                if next_entry.group_id != Some(group_id) {
253                    break;
254                }
255                let (_, result) = self.redo_one()?;
256                results.push(result);
257            }
258        }
259
260        Some(results)
261    }
262
263    /// Jump to an arbitrary node, returning the sequence of undo/redo steps
264    /// along the path.
265    ///
266    /// The path goes from the current node up to the lowest common ancestor
267    /// (LCA), then down to the target.  Returns `None` when `id` does not
268    /// exist.
269    pub fn jump_to(&mut self, id: u64) -> Option<Vec<JumpStep>> {
270        let current_path = self.tree.find_path_to(self.tree.current_id())?;
271        let target_path = self.tree.find_path_to(id)?;
272
273        // Find LCA depth (length of shared prefix).
274        let lca_depth = current_path
275            .iter()
276            .zip(target_path.iter())
277            .take_while(|(a, b)| a == b)
278            .count();
279
280        let mut steps = Vec::new();
281
282        // Undo from current up to LCA.
283        let undo_count = current_path.len() - lca_depth;
284        for _ in 0..undo_count {
285            if let Some((_, result)) = self.undo_one() {
286                steps.push(JumpStep::Undo(result));
287            }
288        }
289
290        // Redo from LCA down to target.
291        let redo_indices = &target_path[lca_depth..];
292        for &child_index in redo_indices {
293            if let Some((_, result)) = self.redo_child(child_index) {
294                steps.push(JumpStep::Redo(result));
295            }
296        }
297
298        Some(steps)
299    }
300
301    /// `true` when undo is possible (not at root).
302    pub fn can_undo(&self) -> bool {
303        self.tree.has_parent()
304    }
305
306    /// `true` when redo is possible (current node has children).
307    pub fn can_redo(&self) -> bool {
308        self.tree.has_children()
309    }
310
311    /// Id of the current history node.
312    pub fn current_id(&self) -> u64 {
313        self.tree.current_id()
314    }
315
316    /// Label of the current history node.
317    pub fn current_label(&self) -> &str {
318        self.tree.current().value().label()
319    }
320
321    /// Entry data of the current history node.
322    pub fn current_entry(&self) -> &HistoryEntry {
323        self.tree.current().value()
324    }
325
326    /// Set the checkpoint interval (number of edits between automatic
327    /// snapshots).  Default is 20.
328    pub fn set_checkpoint_interval(&mut self, interval: u32) {
329        self.checkpoint_interval = interval;
330    }
331
332    /// Prune old branches from the history tree.
333    pub fn prune(&mut self, policy: PrunePolicy) {
334        self.tree.prune(policy);
335    }
336
337    /// Read-only access to the underlying tree (e.g. for visualization).
338    pub fn tree(&self) -> &Tree<HistoryEntry> {
339        self.tree.tree()
340    }
341
342    /// Read-only access to the underlying cursored tree.
343    pub fn cursored_tree(&self) -> &CursoredTree<HistoryEntry> {
344        &self.tree
345    }
346
347    // -- private ------------------------------------------------------------
348
349    fn maybe_checkpoint(&mut self, current_text: &str, forward: &[TextPatch]) -> Option<String> {
350        self.edits_since_checkpoint += 1;
351        if self.edits_since_checkpoint >= self.checkpoint_interval {
352            self.edits_since_checkpoint = 0;
353            apply_patches(current_text, forward).ok()
354        } else {
355            None
356        }
357    }
358
359    fn undo_one(&mut self) -> Option<(Option<u64>, UndoResult)> {
360        if !self.tree.has_parent() {
361            return None;
362        }
363        let entry = self.tree.current().value();
364        let group_id = entry.group_id;
365        let result = UndoResult {
366            kind: entry.kind(),
367            inverse_patches: entry.inverse_patches.clone(),
368            snapshot: self.find_parent_snapshot(),
369            label: entry.label.clone(),
370        };
371        self.tree.go_parent();
372        Some((group_id, result))
373    }
374
375    fn redo_one(&mut self) -> Option<(Option<u64>, RedoResult)> {
376        if !self.tree.has_children() {
377            return None;
378        }
379        let child_index = self.tree.current().child_count() - 1;
380        self.redo_child(child_index)
381    }
382
383    fn redo_child(&mut self, child_index: usize) -> Option<(Option<u64>, RedoResult)> {
384        if !self.tree.go_child(child_index) {
385            return None;
386        }
387        let entry = self.tree.current().value();
388        Some((
389            entry.group_id,
390            RedoResult {
391                kind: entry.kind(),
392                forward_patches: entry.forward_patches.clone(),
393                snapshot: entry.snapshot.clone(),
394                label: entry.label.clone(),
395            },
396        ))
397    }
398
399    fn find_parent_snapshot(&self) -> Option<String> {
400        // For Snapshot entries, the parent's snapshot (or checkpoint)
401        // is the text to restore.  Walk up to find the nearest snapshot
402        // or checkpoint.
403        let current_path = self.tree.cursor_path();
404        if current_path.is_empty() {
405            return None;
406        }
407        let parent_path = &current_path[..current_path.len() - 1];
408        self.resolve_snapshot_at_path(parent_path)
409    }
410
411    fn resolve_snapshot_at_path(&self, path: &[usize]) -> Option<String> {
412        // Walk from root along path, looking for the most recent snapshot
413        // or checkpoint.
414        let mut node = self.tree.root();
415        let entry = node.value();
416        let mut latest = entry.snapshot.as_ref().or(entry.checkpoint.as_ref());
417
418        for &index in path {
419            node = node.children().get(index)?;
420            let e = node.value();
421            if e.snapshot.is_some() {
422                latest = e.snapshot.as_ref();
423            } else if e.checkpoint.is_some() {
424                latest = e.checkpoint.as_ref();
425            }
426        }
427        latest.cloned()
428    }
429}
430
431// ---------------------------------------------------------------------------
432// Tests
433// ---------------------------------------------------------------------------
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use neco_textpatch::apply_patches;
439
440    fn make_insert(offset: usize, text: &str) -> Vec<TextPatch> {
441        vec![TextPatch::insert(offset, text)]
442    }
443
444    fn make_delete(start: usize, end: usize) -> Vec<TextPatch> {
445        vec![TextPatch::delete(start, end).unwrap()]
446    }
447
448    fn make_replace(start: usize, end: usize, text: &str) -> Vec<TextPatch> {
449        vec![TextPatch::replace(start, end, text).unwrap()]
450    }
451
452    // -- basic construction -------------------------------------------------
453
454    #[test]
455    fn new_creates_root_with_initial_snapshot() {
456        let h = EditHistory::new("hello");
457        assert_eq!(h.current_id(), 0);
458        assert!(!h.can_undo());
459        assert!(!h.can_redo());
460
461        let root = h.tree().root().value();
462        assert_eq!(root.kind(), EntryKind::Snapshot);
463        assert_eq!(root.snapshot(), Some("hello"));
464        assert_eq!(root.checkpoint(), Some("hello"));
465    }
466
467    // -- push_edit ----------------------------------------------------------
468
469    #[test]
470    fn push_edit_records_forward_and_inverse() {
471        let mut h = EditHistory::new("abc");
472        let text = "abc";
473        let forward = make_replace(1, 2, "B");
474        let id = h.push_edit("replace b", text, forward);
475
476        assert_eq!(h.current_id(), id);
477        assert!(h.can_undo());
478
479        let entry = h.cursored_tree().current().value();
480        assert_eq!(entry.kind(), EntryKind::Reversible);
481        assert_eq!(entry.label(), "replace b");
482
483        // Forward: replace [1..2) with "B"
484        let fwd = entry.forward_patches().unwrap();
485        assert_eq!(fwd.len(), 1);
486        assert_eq!(fwd[0].replacement(), "B");
487
488        // Inverse: replace [1..2) with "b"
489        let inv = entry.inverse_patches().unwrap();
490        assert_eq!(inv.len(), 1);
491        assert_eq!(inv[0].replacement(), "b");
492    }
493
494    #[test]
495    fn push_edit_inverse_roundtrip() {
496        let original = "hello world";
497        let mut h = EditHistory::new(original);
498
499        let forward = make_replace(6, 11, "rust");
500        h.push_edit("change world", original, forward.clone());
501
502        let modified = apply_patches(original, &forward).unwrap();
503        assert_eq!(modified, "hello rust");
504
505        let entry = h.cursored_tree().current().value();
506        let inv = entry.inverse_patches().unwrap();
507        let restored = apply_patches(&modified, inv).unwrap();
508        assert_eq!(restored, original);
509    }
510
511    // -- push_snapshot ------------------------------------------------------
512
513    #[test]
514    fn push_snapshot_stores_full_text() {
515        let mut h = EditHistory::new("old");
516        let id = h.push_snapshot("reload", "new content".to_string());
517
518        assert_eq!(h.current_id(), id);
519        let entry = h.cursored_tree().current().value();
520        assert_eq!(entry.kind(), EntryKind::Snapshot);
521        assert_eq!(entry.snapshot(), Some("new content"));
522    }
523
524    // -- undo / redo --------------------------------------------------------
525
526    #[test]
527    fn undo_returns_inverse_and_moves_to_parent() {
528        let mut h = EditHistory::new("abc");
529        h.push_edit("insert", "abc", make_insert(3, "d"));
530
531        assert!(h.can_undo());
532        let result = h.undo().unwrap().remove(0);
533        assert_eq!(result.kind, EntryKind::Reversible);
534        assert_eq!(result.label, "insert");
535        assert!(result.inverse_patches.is_some());
536        assert_eq!(h.current_id(), 0);
537        assert!(!h.can_undo());
538    }
539
540    #[test]
541    fn undo_at_root_returns_none() {
542        let mut h = EditHistory::new("text");
543        assert!(h.undo().is_none());
544    }
545
546    #[test]
547    fn redo_returns_forward_and_moves_to_child() {
548        let mut h = EditHistory::new("abc");
549        h.push_edit("insert", "abc", make_insert(3, "d"));
550        h.undo();
551
552        assert!(h.can_redo());
553        let result = h.redo().unwrap().remove(0);
554        assert_eq!(result.kind, EntryKind::Reversible);
555        assert_eq!(result.label, "insert");
556        assert!(result.forward_patches.is_some());
557        assert!(!h.can_redo());
558    }
559
560    #[test]
561    fn redo_at_leaf_returns_none() {
562        let mut h = EditHistory::new("text");
563        h.push_edit("edit", "text", make_insert(4, "!"));
564        assert!(h.redo().is_none());
565    }
566
567    #[test]
568    fn grouped_edits_undo_and_redo_together() {
569        let mut h = EditHistory::new("abc");
570        h.begin_group("group");
571        h.push_edit("first", "abc", make_insert(3, "d"));
572        h.push_edit("second", "abcd", make_insert(4, "e"));
573        h.end_group();
574
575        let undo = h.undo().unwrap();
576        assert_eq!(undo.len(), 2);
577        assert_eq!(h.current_id(), 0);
578
579        let redo = h.redo().unwrap();
580        assert_eq!(redo.len(), 2);
581        assert_eq!(h.current_id(), 2);
582    }
583
584    // -- branching ----------------------------------------------------------
585
586    #[test]
587    fn undo_then_push_creates_new_branch() {
588        let mut h = EditHistory::new("abc");
589        let id1 = h.push_edit("first", "abc", make_insert(3, "1"));
590        h.undo(); // back to root
591        let id2 = h.push_edit("second", "abc", make_insert(3, "2"));
592
593        assert_ne!(id1, id2);
594        // Root should now have 2 children (two branches).
595        assert_eq!(h.tree().root().child_count(), 2);
596        assert_eq!(h.current_id(), id2);
597    }
598
599    // -- jump_to ------------------------------------------------------------
600
601    #[test]
602    fn jump_to_returns_undo_redo_steps() {
603        let mut h = EditHistory::new("abc");
604        let a = h.push_edit("a", "abc", make_insert(3, "d"));
605        let _a1 = h.push_edit("a1", "abcd", make_insert(4, "e"));
606
607        // Jump back to root
608        let steps = h.jump_to(0).unwrap();
609        assert_eq!(steps.len(), 2);
610        assert!(matches!(steps[0], JumpStep::Undo(_)));
611        assert!(matches!(steps[1], JumpStep::Undo(_)));
612        assert_eq!(h.current_id(), 0);
613
614        // Jump forward to a
615        let steps = h.jump_to(a).unwrap();
616        assert_eq!(steps.len(), 1);
617        assert!(matches!(steps[0], JumpStep::Redo(_)));
618        assert_eq!(h.current_id(), a);
619    }
620
621    #[test]
622    fn jump_to_nonexistent_returns_none() {
623        let mut h = EditHistory::new("abc");
624        assert!(h.jump_to(999).is_none());
625    }
626
627    #[test]
628    fn jump_to_current_returns_empty_steps() {
629        let mut h = EditHistory::new("abc");
630        let a = h.push_edit("a", "abc", make_insert(3, "d"));
631        let steps = h.jump_to(a).unwrap();
632        assert!(steps.is_empty());
633    }
634
635    #[test]
636    fn jump_to_through_snapshot_node() {
637        let mut h = EditHistory::new("abc");
638        h.push_edit("edit", "abc", make_insert(3, "d"));
639        let snap_id = h.push_snapshot("reload", "xyz".to_string());
640        h.push_edit("after reload", "xyz", make_insert(3, "!"));
641
642        // Jump back to root, passing through snapshot node
643        let steps = h.jump_to(0).unwrap();
644        assert_eq!(steps.len(), 3);
645        assert!(matches!(steps[0], JumpStep::Undo(_)));
646        assert!(matches!(steps[1], JumpStep::Undo(_)));
647        assert!(matches!(steps[2], JumpStep::Undo(_)));
648        assert_eq!(h.current_id(), 0);
649
650        // Jump forward to snapshot node
651        let steps = h.jump_to(snap_id).unwrap();
652        assert_eq!(steps.len(), 2);
653        assert_eq!(h.current_id(), snap_id);
654    }
655
656    #[test]
657    fn jump_to_inside_group_is_node_precise() {
658        let mut h = EditHistory::new("abc");
659        h.begin_group("group");
660        let first = h.push_edit("first", "abc", make_insert(3, "d"));
661        let second = h.push_edit("second", "abcd", make_insert(4, "e"));
662        h.end_group();
663
664        assert_eq!(h.current_id(), second);
665
666        let steps = h.jump_to(first).unwrap();
667        assert_eq!(steps.len(), 1);
668        assert!(matches!(steps[0], JumpStep::Undo(_)));
669        assert_eq!(h.current_id(), first);
670    }
671
672    // -- checkpoint ---------------------------------------------------------
673
674    #[test]
675    fn checkpoint_is_inserted_at_interval() {
676        let mut h = EditHistory::new("x");
677        h.set_checkpoint_interval(3);
678
679        let mut text = "x".to_string();
680        for i in 0..5 {
681            let ch = char::from(b'a' + i);
682            let forward = make_insert(text.len(), &ch.to_string());
683            h.push_edit(&format!("add {ch}"), &text, forward.clone());
684            text = apply_patches(&text, &forward).unwrap();
685        }
686
687        // After 3 edits (interval=3), checkpoint should be set on edit #3.
688        let node_3 = h.tree().find(3).unwrap().value();
689        assert!(
690            node_3.checkpoint().is_some(),
691            "edit #3 should have a checkpoint"
692        );
693
694        // Edit #1, #2, #4 should not have checkpoints.
695        let node_1 = h.tree().find(1).unwrap().value();
696        assert!(node_1.checkpoint().is_none());
697        let node_2 = h.tree().find(2).unwrap().value();
698        assert!(node_2.checkpoint().is_none());
699    }
700
701    // -- can_undo / can_redo ------------------------------------------------
702
703    #[test]
704    fn can_undo_can_redo_reflect_cursor_position() {
705        let mut h = EditHistory::new("abc");
706        assert!(!h.can_undo());
707        assert!(!h.can_redo());
708
709        h.push_edit("edit", "abc", make_insert(3, "d"));
710        assert!(h.can_undo());
711        assert!(!h.can_redo());
712
713        h.undo();
714        assert!(!h.can_undo());
715        assert!(h.can_redo());
716    }
717
718    // -- prune --------------------------------------------------------------
719
720    #[test]
721    fn prune_removes_old_branches() {
722        let mut h = EditHistory::new("abc");
723        h.push_edit("a", "abc", make_insert(3, "1"));
724        h.undo();
725        h.push_edit("b", "abc", make_insert(3, "2"));
726        h.undo();
727        h.push_edit("c", "abc", make_insert(3, "3"));
728
729        // Root has 3 branches.
730        assert_eq!(h.tree().root().child_count(), 3);
731
732        h.prune(PrunePolicy::KeepLastN(1));
733        // Only the newest branch survives.
734        assert_eq!(h.tree().root().child_count(), 1);
735    }
736
737    // -- snapshot undo ------------------------------------------------------
738
739    #[test]
740    fn undo_snapshot_entry_provides_parent_snapshot() {
741        let mut h = EditHistory::new("original");
742        h.push_snapshot("reload", "reloaded".to_string());
743
744        let result = h.undo().unwrap().remove(0);
745        assert_eq!(result.kind, EntryKind::Snapshot);
746        assert_eq!(result.snapshot.as_deref(), Some("original"));
747    }
748
749    // -- multiple edits roundtrip -------------------------------------------
750
751    #[test]
752    fn multiple_edits_undo_redo_roundtrip() {
753        let mut h = EditHistory::new("hello");
754        let mut text = "hello".to_string();
755
756        // Edit 1: insert " world"
757        let fwd1 = make_insert(5, " world");
758        h.push_edit("add world", &text, fwd1.clone());
759        text = apply_patches(&text, &fwd1).unwrap();
760        assert_eq!(text, "hello world");
761
762        // Edit 2: replace "world" with "rust"
763        let fwd2 = make_replace(6, 11, "rust");
764        h.push_edit("change lang", &text, fwd2.clone());
765        text = apply_patches(&text, &fwd2).unwrap();
766        assert_eq!(text, "hello rust");
767
768        // Undo edit 2
769        let u2 = h.undo().unwrap().remove(0);
770        text = apply_patches(&text, u2.inverse_patches.as_ref().unwrap()).unwrap();
771        assert_eq!(text, "hello world");
772
773        // Undo edit 1
774        let u1 = h.undo().unwrap().remove(0);
775        text = apply_patches(&text, u1.inverse_patches.as_ref().unwrap()).unwrap();
776        assert_eq!(text, "hello");
777
778        // Redo edit 1
779        let r1 = h.redo().unwrap().remove(0);
780        text = apply_patches(&text, r1.forward_patches.as_ref().unwrap()).unwrap();
781        assert_eq!(text, "hello world");
782
783        // Redo edit 2
784        let r2 = h.redo().unwrap().remove(0);
785        text = apply_patches(&text, r2.forward_patches.as_ref().unwrap()).unwrap();
786        assert_eq!(text, "hello rust");
787    }
788
789    // -- delete roundtrip ---------------------------------------------------
790
791    #[test]
792    fn delete_undo_restores_text() {
793        let mut h = EditHistory::new("abcdef");
794        let fwd = make_delete(2, 4);
795        h.push_edit("delete cd", "abcdef", fwd.clone());
796
797        let modified = apply_patches("abcdef", &fwd).unwrap();
798        assert_eq!(modified, "abef");
799
800        let u = h.undo().unwrap().remove(0);
801        let restored = apply_patches(&modified, u.inverse_patches.as_ref().unwrap()).unwrap();
802        assert_eq!(restored, "abcdef");
803    }
804}