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}