Skip to main content

blockworx_doc/
trail.rs

1//! The undo trail: which revs a session can step back to, and which
2//! forward.
3//!
4//! See `docs/log-vs-snapshot.md`. A step back is not an erasure — it is a
5//! new rev that adopts an older document — so the trail holds positions
6//! rather than commits: an [`Entry`] names the record it stands for and
7//! the rev whose document taking it adopts. Nothing here knows how a
8//! session finds that document; a container reads a rev file and a
9//! session with no files folds its log prefix, and both then run this
10//! same policy.
11
12use crate::rev::Rev;
13
14/// Which stack a record's entry lands on, and what it does to the other —
15/// the whole undo/redo policy, stated once.
16///
17/// It is also a durable record's *kind*, in the only spelling this
18/// headless crate knows: a replayed log hands each record's kind over as
19/// one of these, so a reopened document rebuilds its trail by running
20/// this policy rather than a second copy of it.
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub enum JournalAs {
23    Edit,
24    Undo { of: Rev },
25    Redo { of: Rev },
26}
27
28/// Which way a history step goes. Two variants, so "an edit is a history
29/// step" cannot be spelled.
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum Direction {
32    Undo,
33    Redo,
34}
35
36impl Direction {
37    /// How the record this step writes names itself, ahead of the label of
38    /// the record it moved.
39    pub fn verb(self) -> &'static str {
40        match self {
41            Direction::Undo => "Undo",
42            Direction::Redo => "Redo",
43        }
44    }
45
46    pub(crate) fn journal(self, of: Rev) -> JournalAs {
47        match self {
48            Direction::Undo => JournalAs::Undo { of },
49            Direction::Redo => JournalAs::Redo { of },
50        }
51    }
52}
53
54/// One step this trail can take: the record it stands for, and the rev
55/// whose document taking it adopts.
56///
57/// `restores` is the rev the session stood on when the record was
58/// written, which is why a step and its reverse land on the same document
59/// however many times they are repeated.
60#[derive(Clone, Copy, PartialEq, Eq, Debug)]
61pub struct Entry {
62    pub rev: Rev,
63    pub restores: Rev,
64}
65
66/// Why a history step did not happen.
67///
68/// [`Self::Stale`] earns its place: a caller keeping its own history can
69/// name a rev this trail does not hold on top, and stepping its neighbour
70/// instead would be silent.
71#[derive(Debug, thiserror::Error, PartialEq)]
72pub enum UndoRefusal {
73    #[error("nothing stands ready to be taken back")]
74    Spent,
75    #[error("the step names a rev this trail no longer holds on top ({top:?})")]
76    Stale { top: Rev },
77}
78
79/// Where a session's history stands: what one press would take back, what
80/// one would put back, and which rev's document the head currently holds.
81#[derive(Clone, Debug, Default, PartialEq, Eq)]
82pub struct Trail {
83    undo: Vec<Entry>,
84    redo: Vec<Entry>,
85    standing: Rev,
86}
87
88impl Trail {
89    /// The rev whose document the head holds. An edit at N stands at N; a
90    /// step stands at the rev it restored, so an undo and its redo return
91    /// the session to the position it started from rather than to a rev
92    /// that merely looks like it.
93    pub fn standing(&self) -> Rev {
94        self.standing
95    }
96
97    /// Stand at `rev` with nothing new to take back: a commit that is the
98    /// document's past rather than a step this session took — the seeding
99    /// path, and a repo built by folding a log before a session opened
100    /// over it.
101    ///
102    /// Without it the first edit over such a document would offer to
103    /// restore the empty document, since that is where a fresh trail
104    /// stands.
105    pub fn seeded(&mut self, rev: Rev) {
106        self.standing = rev;
107    }
108
109    /// Take a record of `rev` into the trail: an edit or a redo is a step
110    /// somebody can take back, an undo is one they can put back, and each
111    /// step retires the entry it consumed.
112    ///
113    /// This is the only place the policy lives, so a live session and a
114    /// replayed log cannot disagree about the trail they end up with.
115    pub fn record(&mut self, rev: Rev, journal_as: JournalAs) {
116        let entry = Entry {
117            rev,
118            restores: self.standing,
119        };
120        match journal_as {
121            JournalAs::Edit => {
122                self.redo.clear();
123                self.undo.push(entry);
124                self.standing = rev;
125            }
126            JournalAs::Undo { of } => {
127                self.redo.push(entry);
128                let retired = retire(&mut self.undo, of);
129                self.stand_where(retired);
130            }
131            JournalAs::Redo { of } => {
132                self.undo.push(entry);
133                let retired = retire(&mut self.redo, of);
134                self.stand_where(retired);
135            }
136        }
137    }
138
139    /// The entry a step naming `edit` would take, without taking it.
140    ///
141    /// # Errors
142    /// [`UndoRefusal::Spent`] when nothing stands ready, and
143    /// [`UndoRefusal::Stale`] — reporting its own top — when the top is a
144    /// different rev than `edit`.
145    pub fn stepping(&self, edit: Rev, direction: Direction) -> Result<Entry, UndoRefusal> {
146        let top = *self.stack(direction).last().ok_or(UndoRefusal::Spent)?;
147        if top.rev != edit {
148            return Err(UndoRefusal::Stale { top: top.rev });
149        }
150        Ok(top)
151    }
152
153    pub fn can_undo(&self) -> bool {
154        !self.undo.is_empty()
155    }
156
157    pub fn can_redo(&self) -> bool {
158        !self.redo.is_empty()
159    }
160
161    /// How many steps stand ready to be taken back. The editor keeps a
162    /// parallel stack that interleaves view steps with these, and the two
163    /// depths must agree — exposed so the mismatch is checkable rather
164    /// than silent.
165    pub fn undo_depth(&self) -> usize {
166        self.undo.len()
167    }
168
169    pub fn redo_depth(&self) -> usize {
170        self.redo.len()
171    }
172
173    /// The rev an undo would move, and the one a redo would — so a caller
174    /// holding its own history can tell whether the step it is about to
175    /// take is still the step this trail would take.
176    pub fn next_undo(&self) -> Option<Rev> {
177        self.undo.last().map(|entry| entry.rev)
178    }
179
180    pub fn next_redo(&self) -> Option<Rev> {
181        self.redo.last().map(|entry| entry.rev)
182    }
183
184    /// Every rev standing ready to be taken back, oldest first — for a
185    /// caller whose own stack must agree with this one, entry for entry.
186    pub fn undo_revs(&self) -> Vec<Rev> {
187        self.undo.iter().map(|entry| entry.rev).collect()
188    }
189
190    /// The forward half of [`Self::undo_revs`].
191    pub fn redo_revs(&self) -> Vec<Rev> {
192        self.redo.iter().map(|entry| entry.rev).collect()
193    }
194
195    /// Everywhere this trail can stand, deepest past first, with
196    /// [`Self::standing`] at index [`Self::undo_depth`] — one point per
197    /// step a walk from either end would take, which is what a session
198    /// rebuilding its own stack from a reopened document needs.
199    pub fn standings(&self) -> Vec<Rev> {
200        self.undo
201            .iter()
202            .map(|entry| entry.restores)
203            .chain(std::iter::once(self.standing))
204            .chain(self.redo.iter().rev().map(|entry| entry.restores))
205            .collect()
206    }
207
208    fn stack(&self, direction: Direction) -> &[Entry] {
209        match direction {
210            Direction::Undo => &self.undo,
211            Direction::Redo => &self.redo,
212        }
213    }
214
215    fn stand_where(&mut self, retired: Option<Entry>) {
216        if let Some(entry) = retired {
217            self.standing = entry.restores;
218        }
219    }
220}
221
222/// Drop the entry a step just consumed and say where it left the session
223/// standing. A step has already refused a name that is not the top, and a
224/// replayed record names what its own session's step did; a name the
225/// stack does not hold is left alone rather than taken as "the top one".
226fn retire(stack: &mut Vec<Entry>, edit: Rev) -> Option<Entry> {
227    let at = stack.iter().rposition(|entry| entry.rev == edit)?;
228    Some(stack.remove(at))
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::fixtures::rev;
235
236    /// The trail a run of edits leaves: each stands on itself, and each
237    /// entry restores the one before it.
238    fn edited(n: u64) -> Trail {
239        let mut trail = Trail::default();
240        for at in 1..=n {
241            trail.record(rev(at), JournalAs::Edit);
242        }
243        trail
244    }
245
246    #[test]
247    fn an_edit_stands_on_itself_and_restores_the_one_before_it() {
248        let trail = edited(3);
249        assert_eq!(trail.standing(), rev(3));
250        assert_eq!(trail.undo_revs(), [rev(1), rev(2), rev(3)]);
251        assert_eq!(trail.standings(), [rev(0), rev(1), rev(2), rev(3)]);
252        assert_eq!(
253            trail.stepping(rev(3), Direction::Undo),
254            Ok(Entry {
255                rev: rev(3),
256                restores: rev(2)
257            }),
258        );
259    }
260
261    #[test]
262    fn a_step_naming_anything_but_the_top_is_refused() {
263        let trail = edited(2);
264        assert_eq!(
265            trail.stepping(rev(1), Direction::Undo),
266            Err(UndoRefusal::Stale { top: rev(2) }),
267        );
268        assert_eq!(
269            trail.stepping(rev(1), Direction::Redo),
270            Err(UndoRefusal::Spent),
271        );
272    }
273
274    /// The property the walk depends on: an undo and its redo return the
275    /// session to the rev it started from, however deep into a mixed trail
276    /// the pair happens.
277    #[test]
278    fn a_step_and_its_reverse_return_to_the_rev_they_started_from() {
279        let mut trail = edited(3);
280        // Undo twice, redo once, edit — the shape that made a naive
281        // "restores is the rev before" rule leave the trail standing on a
282        // rev it had never stood on.
283        trail.record(rev(4), JournalAs::Undo { of: rev(3) });
284        trail.record(rev(5), JournalAs::Undo { of: rev(2) });
285        assert_eq!(trail.standing(), rev(1));
286
287        let before = trail.clone();
288        let put_back = trail.next_redo().expect("a step to put back");
289        trail.record(rev(6), JournalAs::Redo { of: put_back });
290        assert_eq!(trail.standing(), rev(2));
291        let take_back = trail.next_undo().expect("a step to take back");
292        trail.record(rev(7), JournalAs::Undo { of: take_back });
293
294        assert_eq!(trail.standing(), before.standing());
295        assert_eq!(trail.undo_revs().len(), before.undo_revs().len());
296        assert_eq!(trail.standings(), before.standings());
297    }
298
299    /// What the editor's walk needs: standing falls with every undo and
300    /// rises with every redo, so a walk toward a target terminates.
301    #[test]
302    fn standing_falls_along_the_undo_stack_and_rises_along_the_redo_stack() {
303        let mut trail = edited(3);
304        trail.record(rev(4), JournalAs::Undo { of: rev(3) });
305        trail.record(rev(5), JournalAs::Edit);
306        trail.record(rev(6), JournalAs::Undo { of: rev(5) });
307        trail.record(rev(7), JournalAs::Redo { of: rev(6) });
308
309        let line = trail.standings();
310        assert!(
311            line.windows(2).all(|pair| pair[0] < pair[1]),
312            "the trail's positions must be ordered for a walk to terminate: {line:?}",
313        );
314        assert_eq!(line[trail.undo_depth()], trail.standing());
315    }
316
317    #[test]
318    fn an_edit_abandons_the_redo_future() {
319        let mut trail = edited(2);
320        trail.record(rev(3), JournalAs::Undo { of: rev(2) });
321        assert!(trail.can_redo());
322        trail.record(rev(4), JournalAs::Edit);
323        assert!(!trail.can_redo(), "a new edit forks the history");
324        assert_eq!(trail.standing(), rev(4));
325    }
326}