Skip to main content

blockworx_doc/
repo.rs

1//! The document, in one type: the folded [`Document`] and the commit log
2//! it folds.
3//! Rationale: `docs/single-author-playbook.md`.
4
5use crate::{
6    commit::Commit,
7    document::{Document, FoldError},
8    rev::Rev,
9    trail::{Direction, Entry, JournalAs, Trail},
10};
11
12/// The document and everything that writes it. A commit is folded and
13/// appended inside [`Self::submit`] or [`Self::restore`], which are the
14/// only doors: there is no queue, no pending head, and no rev the caller
15/// can hold that the log does not.
16///
17/// The [`Trail`] a session steps through is *beside* this rather than in
18/// it (`docs/log-vs-snapshot.md`): what a step adopts is a document
19/// this type cannot reach on its own — a rev copy on disk, or a fold of a
20/// prefix of its own log — so the session owns both and hands them to
21/// each other.
22#[derive(Default)]
23pub struct Repo {
24    document: Document,
25    log: Vec<Commit>,
26}
27
28impl Repo {
29    /// A repo standing on a document somebody else read — the head rev
30    /// file a container opens on. Its log is this session's own commits
31    /// and starts empty: what came before is on disk, in rows and rev
32    /// files, not in memory (`docs/log-vs-snapshot.md`).
33    pub fn at(document: Document) -> Self {
34        Self {
35            document,
36            log: Vec::new(),
37        }
38    }
39
40    /// Fold `commits` into a fresh repo, trailing nothing — a log that
41    /// is already the document's past, not a session's edits.
42    ///
43    /// # Errors
44    /// The fold's refusal, naming the first commit this build will not take.
45    pub fn folding(commits: &[Commit]) -> Result<Self, FoldError> {
46        let mut repo = Self::default();
47        for commit in commits {
48            repo.fold_one(commit.clone())?;
49        }
50        Ok(repo)
51    }
52
53    /// Fold one more commit of the document's past in, trailing nothing,
54    /// and hand back what it folded to.
55    ///
56    /// The returned document is what makes a *verified* replay one fold
57    /// instead of two: the durable log stamps every record with the
58    /// document it produced, and a loader that could not see the
59    /// intermediate values would have to fold once to check them and
60    /// again to build the repo.
61    ///
62    /// # Errors
63    /// The fold's refusal, leaving the repo as it stood.
64    pub fn fold_one(&mut self, commit: Commit) -> Result<&Document, FoldError> {
65        self.document = self.document.try_apply(&commit)?;
66        self.log.push(commit);
67        Ok(&self.document)
68    }
69
70    /// What the editor draws.
71    pub fn document(&self) -> &Document {
72        &self.document
73    }
74
75    pub fn rev(&self) -> Rev {
76        self.document.rev()
77    }
78
79    /// The whole history, oldest first. `log[i]` holds `Rev(i + 1)` —
80    /// contiguous because a refused commit consumes no rev, which is what
81    /// lets the log be a `Vec`.
82    pub fn log(&self) -> &[Commit] {
83        &self.log
84    }
85
86    /// The revs assigned after `rev`, oldest first — for a caller whose own
87    /// history owes a step to each commit this repo has taken since it last
88    /// looked. Empty for a rev at or beyond the head, which is what lets a
89    /// caller carry a watermark across a document swap without arithmetic of
90    /// its own.
91    pub fn revs_after(&self, rev: Rev) -> Vec<Rev> {
92        let taken = self.rev().get().saturating_sub(rev.get()) as usize;
93        std::iter::successors(Some(rev.next()), |rev| Some(rev.next()))
94            .take(taken)
95            .collect()
96    }
97
98    /// Fold, append, and record the edit on `trail`, in that order: a
99    /// refused commit consumes no rev and leaves neither the log nor the
100    /// trail touched.
101    ///
102    /// The trail comes in rather than living here so that an edit cannot
103    /// be logged without standing on it — [`Self::fold_one`] is the door
104    /// for a past nobody may take back.
105    ///
106    /// # Errors
107    /// The fold's refusal.
108    pub fn submit(&mut self, commit: Commit, trail: &mut Trail) -> Result<Rev, FoldError> {
109        let rev = self.fold_one(commit)?.rev();
110        trail.record(rev, JournalAs::Edit);
111        Ok(rev)
112    }
113
114    /// Take the step `entry` names: adopt `target` — the document the
115    /// session stood on before the record at `entry.rev` was written — and
116    /// log it as a commit of its own, so a step back stays *in* the
117    /// history rather than erasing what it crossed.
118    ///
119    /// The commit carries no ops. A rev was validated by the fold when it
120    /// was written, so restoring it needs no second fold and no inverse to
121    /// build one from (`docs/log-vs-snapshot.md`): what a step records
122    /// is `label`, which the caller reads off the record it is moving.
123    ///
124    /// The adopted document keeps the one thing a session accumulates
125    /// rather than holds — the allocator's marks, which must never fall
126    /// (see `Document::restored_at`). Payloads it does not: they live in
127    /// the store, and a rev that references one gets it back when it is
128    /// read.
129    pub fn restore(&mut self, trail: &mut Trail, step: Restoring<'_>) -> Rev {
130        let Restoring {
131            entry,
132            direction,
133            target,
134            label,
135        } = step;
136        let rev = self.rev().next();
137        let mut adopted = target;
138        adopted.restored_at(rev, &self.document);
139        self.document = adopted;
140        self.log.push(Commit::new(
141            format!("{} {label}", direction.verb()),
142            Vec::new(),
143        ));
144        trail.record(rev, direction.journal(entry.rev));
145        rev
146    }
147}
148
149/// One history step, as [`Repo::restore`] takes it: which entry is being
150/// stepped, which way, the document that step adopts, and what the record
151/// it moves is called.
152pub struct Restoring<'a> {
153    pub entry: Entry,
154    pub direction: Direction,
155    /// The document the session stood on before the record at
156    /// `entry.rev` was written — read back from wherever this session
157    /// keeps its rev copies.
158    pub target: Document,
159    pub label: &'a str,
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::fixtures::{block_id, commit, pin_id, projection, rev};
166    use crate::opcode::{Crud, OpCodes};
167    use crate::trail::UndoRefusal;
168    use crate::{
169        block_model::{Block, BlockUpdate, Icon, Label, Pin},
170        geometry::{FracVal, GridPoint, GridRect, GridSize, PinSlot},
171        id::BlockId,
172        values::{LabelSide, PinDir, Role},
173    };
174    use std::collections::BTreeMap;
175
176    fn rect(x: i32, y: i32) -> GridRect {
177        GridRect {
178            top_left: GridPoint { x, y },
179            size: GridSize { w: 4, h: 4 },
180        }
181    }
182
183    fn label_init(name: String) -> Label {
184        Label {
185            name,
186            side: LabelSide::default(),
187            offset: FracVal::default(),
188            hidden: false,
189        }
190    }
191
192    fn block_create(n: u32) -> OpCodes {
193        OpCodes::Block(
194            block_id(n),
195            Crud::Create(Block {
196                parent: BlockId::NULL,
197                rect: rect(0, 0),
198                locked: false,
199                role: Role::default(),
200                title: label_init(format!("b{n}")),
201                type_label: label_init(String::new()),
202                icon: Icon::default(),
203            }),
204        )
205    }
206
207    fn resize(n: u32, to: GridRect) -> OpCodes {
208        OpCodes::Block(block_id(n), Crud::Update(BlockUpdate::Rect(to)))
209    }
210
211    /// A pin whose owner block does not exist — refused by the fold's
212    /// commit-end validation.
213    fn orphan_pin(n: u32) -> OpCodes {
214        OpCodes::Pin(
215            pin_id(n),
216            Crud::Create(Pin {
217                owner: block_id(200),
218                name: format!("p{n}"),
219                type_name: String::new(),
220                tag: String::new(),
221                tag_hidden: false,
222                rect: rect(0, 0),
223                slot: PinSlot::default(),
224                dir: PinDir::default(),
225                port_accent: Role::default(),
226                flip_lr: false,
227            }),
228        )
229    }
230
231    /// A session holding one top-level block, submitted rather than
232    /// seeded, so the create stands on the trail.
233    fn one_block() -> Session {
234        let mut live = Session::default();
235        live.edit("Added a block", vec![block_create(1)]);
236        live
237    }
238
239    /// The log's shape, stated once: every accepted commit takes the next
240    /// rev, and the rev a submission returns is the position its commit
241    /// occupies in the log.
242    #[test]
243    fn submit_assigns_contiguous_revs() {
244        let mut live = Session::default();
245        assert_eq!(
246            live.repo.rev(),
247            Rev::ZERO,
248            "an empty document holds no position",
249        );
250
251        let assigned: Vec<Rev> = (1..=3)
252            .map(|n| live.edit("Added a block", vec![block_create(n)]))
253            .collect();
254        let repo = &live.repo;
255
256        assert_eq!(
257            assigned.iter().map(|rev| rev.get()).collect::<Vec<_>>(),
258            [1, 2, 3],
259        );
260        assert_eq!(repo.rev(), assigned[2], "the head is the last assignment");
261        assert_eq!(repo.log().len(), 3);
262        assert_eq!(repo.revs_after(Rev::ZERO), assigned);
263        assert_eq!(repo.revs_after(assigned[1]), [assigned[2]]);
264        assert!(repo.revs_after(repo.rev()).is_empty(), "caught up");
265        assert!(
266            repo.revs_after(repo.rev().next()).is_empty(),
267            "and a rev beyond the head does not panic",
268        );
269    }
270
271    #[test]
272    fn a_commit_the_fold_refuses_takes_no_rev_and_trails_nothing() {
273        let mut live = Session::default();
274        let before = live.repo.document().clone();
275
276        assert!(
277            live.repo
278                .submit(commit("Orphan pin", vec![orphan_pin(3)]), &mut live.trail)
279                .is_err(),
280        );
281        assert_eq!(live.repo.rev(), Rev::ZERO, "a refusal mints no rev");
282        assert!(live.repo.log().is_empty(), "and logs nothing");
283        assert!(!live.trail.can_undo(), "and stands on nothing");
284        assert_eq!(live.repo.document(), &before, "the document is untouched");
285
286        let next = live.edit("Added a block", vec![block_create(1)]);
287        assert_eq!(
288            next,
289            Rev::ZERO.next(),
290            "the next commit takes the rev the refusal did not consume",
291        );
292    }
293
294    /// A step names the commit it means, and one that names anything but the
295    /// top is refused rather than silently inverting its neighbour.
296    #[test]
297    fn a_step_naming_anything_but_the_top_is_refused() {
298        let mut live = one_block();
299        let first = live.trail.next_undo().expect("the create stands");
300        let second = live.edit("Moved it", vec![resize(1, rect(5, 5))]);
301        assert_eq!(
302            live.trail.undo_revs(),
303            [first, second],
304            "precondition: the named edit sits below the top",
305        );
306
307        assert_eq!(
308            live.stepping(first, Direction::Undo),
309            Err(UndoRefusal::Stale { top: second }),
310        );
311        assert_eq!(live.trail.undo_depth(), 2, "and the refusal took no step");
312        assert!(
313            live.stepping(second, Direction::Undo).is_ok(),
314            "the step naming the top is taken",
315        );
316    }
317
318    /// A spent stack refuses rather than reporting a step it did not take.
319    #[test]
320    fn a_step_on_a_spent_stack_is_refused() {
321        let mut live = one_block();
322        let only = live.trail.next_undo().expect("the setup edit stands");
323        live.undo();
324        assert_eq!(live.trail.next_undo(), None);
325        assert_eq!(
326            live.stepping(only, Direction::Undo),
327            Err(UndoRefusal::Spent),
328        );
329    }
330
331    /// Undo and redo take revs of their own — the log records what was
332    /// taken back rather than losing it — and each adopts the document the
333    /// session stood on, so a round trip returns to the value it held.
334    #[test]
335    fn undo_and_redo_travel_as_ordinary_commits_and_round_trip_the_document() {
336        let mut live = one_block();
337        let placed = live.repo.document().clone();
338        let edit = live.edit("Moved it", vec![resize(1, rect(5, 5))]);
339        let moved = live.repo.document().clone();
340        assert_ne!(placed, moved, "the edit must be observable");
341
342        let undone = live.undo();
343        assert_eq!(undone, edit.next(), "the step took the next rev");
344        assert_eq!(
345            live.repo.document(),
346            &placed,
347            "undo restored the document the create left",
348        );
349        assert_eq!(
350            live.repo.log().last().map(Commit::label),
351            Some("Undo Moved it"),
352            "a step's commit is its label and nothing else",
353        );
354        assert!(
355            live.repo
356                .log()
357                .last()
358                .is_some_and(|step| step.ops().is_empty()),
359            "a step carries no ops: the rev copy is what it adopts",
360        );
361        assert_eq!(
362            live.trail.standing(),
363            edit.prev().expect("an edit before it")
364        );
365        assert!(live.trail.can_redo());
366
367        let redone = live.redo();
368        assert_eq!(redone, undone.next());
369        assert_eq!(
370            live.repo.document(),
371            &moved,
372            "and a round trip lands on the value the edit produced",
373        );
374        assert_eq!(
375            live.trail.standing(),
376            edit,
377            "and the trail stands where it started",
378        );
379        assert!(live.trail.can_undo());
380        assert!(!live.trail.can_redo(), "the redo future is spent");
381    }
382
383    #[test]
384    fn a_fresh_edit_abandons_the_redo_future() {
385        let mut live = one_block();
386        live.edit("Moved it", vec![resize(1, rect(5, 5))]);
387        live.undo();
388        assert!(live.trail.can_redo());
389
390        live.edit("Moved it elsewhere", vec![resize(1, rect(7, 7))]);
391        assert!(!live.trail.can_redo(), "a new edit forks the history");
392    }
393
394    #[test]
395    fn folding_a_log_reproduces_the_document_that_wrote_it() {
396        let mut live = Session::default();
397        for n in 1..=3 {
398            live.edit("Added a block", vec![block_create(n)]);
399        }
400
401        let replayed = Repo::folding(live.repo.log()).expect("the log folds");
402        assert_eq!(replayed.rev(), live.repo.rev());
403        assert_eq!(
404            replayed.document(),
405            live.repo.document(),
406            "a replayed log reproduces the document exactly",
407        );
408    }
409
410    // ── Trail reconstruction ─────────────────────────────────────────────
411
412    type Projection = Vec<(BlockId, BlockId, GridRect, String)>;
413
414    /// A session at this crate's altitude: the repo, the trail beside it,
415    /// and the record kinds its log would carry — what a durable store
416    /// writes beside each commit, and all a replay is given.
417    ///
418    /// It answers "where did I stand" from its own copy of the document
419    /// at every rev, which is what a session with no files beside it does
420    /// (`docs/log-vs-snapshot.md`, in memory).
421    #[derive(Default)]
422    struct Session {
423        repo: Repo,
424        trail: Trail,
425        kinds: Vec<JournalAs>,
426        revs: BTreeMap<Rev, Document>,
427        labels: BTreeMap<Rev, String>,
428    }
429
430    impl Session {
431        fn edit(&mut self, label: &str, ops: Vec<OpCodes>) -> Rev {
432            let rev = self
433                .repo
434                .submit(commit(label, ops), &mut self.trail)
435                .expect("the edit folds");
436            self.took(rev, JournalAs::Edit, label.to_owned());
437            rev
438        }
439
440        fn took(&mut self, rev: Rev, kind: JournalAs, label: String) {
441            self.kinds.push(kind);
442            self.revs.insert(rev, self.repo.document().clone());
443            self.labels.insert(rev, label);
444        }
445
446        fn at(&self, rev: Rev) -> Document {
447            self.revs.get(&rev).cloned().unwrap_or_default()
448        }
449
450        fn stepping(&mut self, edit: Rev, direction: Direction) -> Result<Rev, UndoRefusal> {
451            let entry = self.trail.stepping(edit, direction)?;
452            let target = self.at(entry.restores);
453            let label = self.labels[&edit].clone();
454            let rev = self.repo.restore(
455                &mut self.trail,
456                Restoring {
457                    entry,
458                    direction,
459                    target,
460                    label: &label,
461                },
462            );
463            self.took(
464                rev,
465                direction.journal(edit),
466                format!("{} {label}", direction.verb()),
467            );
468            Ok(rev)
469        }
470
471        fn undo(&mut self) -> Rev {
472            let of = self.trail.next_undo().expect("a step to take back");
473            self.stepping(of, Direction::Undo)
474                .expect("the top names itself")
475        }
476
477        fn redo(&mut self) -> Rev {
478            let of = self.trail.next_redo().expect("a step to put back");
479            self.stepping(of, Direction::Redo)
480                .expect("the top names itself")
481        }
482
483        /// What reopening this session's container hands back: the log
484        /// folded record by record, with the trail rebuilt from the kinds
485        /// alone.
486        fn reopened(&self) -> Session {
487            let mut reopened = Session::default();
488            for (nth, kind) in self.kinds.iter().enumerate() {
489                let at = rev(nth as u64 + 1);
490                reopened.trail.record(at, *kind);
491                reopened.kinds.push(*kind);
492                reopened.revs.insert(at, self.at(at));
493                reopened.labels.insert(at, self.labels[&at].clone());
494            }
495            reopened.repo = Repo::at(self.at(self.repo.rev()));
496            reopened
497        }
498    }
499
500    /// At this crate's altitude: the trail a reopened document carries is
501    /// the one its session closed with, entry for entry.
502    #[test]
503    fn a_replayed_log_rebuilds_the_trail_its_session_had() {
504        let mut live = Session::default();
505        live.edit("Added a block", vec![block_create(1)]);
506        live.edit("Moved it", vec![resize(1, rect(5, 5))]);
507        live.edit("Added another", vec![block_create(2)]);
508        live.undo();
509        live.undo();
510        live.redo();
511        assert_eq!(
512            (live.trail.undo_depth(), live.trail.redo_depth()),
513            (2, 1),
514            "precondition: the session closes with depth both ways",
515        );
516
517        let reopened = live.reopened();
518        assert_eq!(
519            reopened.trail, live.trail,
520            "the reopened trail is the one the session closed with",
521        );
522        assert_eq!(reopened.repo.document(), live.repo.document(),);
523    }
524
525    /// The reopened trail is not just the right shape — walking it all
526    /// the way down and back up moves the document through the same values
527    /// the live session's walk would have.
528    #[test]
529    fn walking_a_reopened_trail_moves_the_document_as_the_session_would_have() {
530        let mut live = Session::default();
531        live.edit("Added a block", vec![block_create(1)]);
532        live.edit("Moved it", vec![resize(1, rect(5, 5))]);
533        live.edit("Added another", vec![block_create(2)]);
534        live.undo();
535
536        let mut reopened = live.reopened();
537        let drained = drain(&mut live);
538        assert!(
539            drained.windows(2).any(|pair| pair[0] != pair[1]),
540            "the walk must move the document or it proves nothing",
541        );
542        assert_eq!(drain(&mut reopened), drained);
543    }
544
545    /// Undo every step, then redo every step, reporting the document at
546    /// each stop. The oracle for "the reconstructed trail is the live
547    /// one": two trails that walk the same document through the same
548    /// values *are* the same trail.
549    fn drain(live: &mut Session) -> Vec<Projection> {
550        let mut seen = vec![projection(live.repo.document())];
551        while live.trail.can_undo() {
552            live.undo();
553            seen.push(projection(live.repo.document()));
554        }
555        while live.trail.can_redo() {
556            live.redo();
557            seen.push(projection(live.repo.document()));
558        }
559        seen
560    }
561
562    /// "Edit, undo, quit, reopen": the redo future survives the restart,
563    /// because the undo record says which edit it took back.
564    #[test]
565    fn a_reopened_undo_can_still_be_redone() {
566        let mut live = Session::default();
567        live.edit("Added a block", vec![block_create(1)]);
568        live.edit("Moved it", vec![resize(1, rect(5, 5))]);
569        let moved = projection(live.repo.document());
570        live.undo();
571
572        let mut reopened = live.reopened();
573        assert!(
574            reopened.trail.can_redo(),
575            "the reopened document lost its future",
576        );
577        reopened.redo();
578        assert_eq!(
579            projection(reopened.repo.document()),
580            moved,
581            "redoing after a restart did not restore what the undo took back",
582        );
583    }
584
585    /// "Edit, undo, edit, quit, reopen": the second edit forked the
586    /// history before the restart, so there is no future to come back to.
587    #[test]
588    fn a_reopened_edit_after_an_undo_has_no_redo_future() {
589        let mut live = Session::default();
590        live.edit("Added a block", vec![block_create(1)]);
591        live.edit("Moved it", vec![resize(1, rect(5, 5))]);
592        live.undo();
593        live.edit("Moved it elsewhere", vec![resize(1, rect(7, 7))]);
594        assert!(!live.trail.can_redo(), "precondition: the fork spent it");
595
596        assert!(
597            !live.reopened().trail.can_redo(),
598            "the reconstructed trail offers a redo the session had abandoned",
599        );
600    }
601
602    /// A log a container was *seeded* with carries edit records, so it
603    /// replays as ordinary edits: reopening one makes the whole seeded past
604    /// undoable, which is the point rather than an accident of it.
605    /// Seeding itself stands on no trail — [`Repo::fold_one`] takes none.
606    #[test]
607    fn a_seeded_past_replays_as_edits_and_is_undoable() {
608        let mut seeded = Repo::default();
609        for n in 1..=3 {
610            seeded
611                .fold_one(commit("Added a block", vec![block_create(n)]))
612                .expect("the seed folds");
613        }
614        let mut reopened = Session::default();
615        for past in seeded.log() {
616            let rev = reopened
617                .repo
618                .fold_one(past.clone())
619                .expect("the log folds")
620                .rev();
621            reopened.trail.record(rev, JournalAs::Edit);
622        }
623        assert_eq!(reopened.trail.undo_revs(), [rev(1), rev(2), rev(3)]);
624        assert_eq!(reopened.repo.document(), seeded.document(),);
625    }
626}