Skip to main content

blockworx_store/
doc.rs

1//! The editor's document handle: the two places a document can live, as one
2//! enum.
3//!
4//! [`Doc::Scratch`] is an in-process [`Repo`] — a session that dies with the
5//! process. It is what an opened document file holds. [`Doc::Attached`] is
6//! a `.bwx` container, wherever its bytes are kept: the same repo, with
7//! every accepted commit in `revs/` and `manifest.jsonl` before the call
8//! returns.
9//!
10//! Reads go through [`Doc::repo`]/[`Doc::document`]; writes through
11//! [`Doc::submit`] and its two history neighbours, which route to the
12//! container's write door when there is one. Nothing hands out a `&mut Repo`
13//! for an attached document, so "folded but not logged" is unrepresentable
14//! rather than merely avoided.
15
16use blockworx_doc::{
17    commit::Commit,
18    document::Document,
19    repo::Repo,
20    rev::Rev,
21    trail::{Direction, Trail},
22};
23use serde::{Deserialize, Serialize};
24
25use crate::Refusal;
26use crate::history::Journal;
27use crate::record::{Attribution, Digest};
28use crate::revs;
29use crate::stamp::Stamp;
30use crate::tags::Tags;
31use crate::worked::Worked;
32
33use crate::container::{Drained, ReadOnlyReason};
34use crate::handle::Store;
35use crate::storage::{Any, Name};
36
37/// Whether this session may write its document. Not a bool: the read-only
38/// arm always has a reason, and the reason is something the user is told.
39#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
40pub enum Writability {
41    Writable,
42    ReadOnly,
43}
44
45/// Which document the canvas is showing: the log's head, or a rev out of
46/// the past — the time machine.
47///
48/// Only the *state*: the fold a past rev is viewed through lives in the
49/// editor beside it, so this stays `Copy` and can be handed to the command
50/// registry and the chrome. Viewing the past is the second consumer of the
51/// one read-only presentation, which is why it answers the same
52/// [`Writability`] question a container does.
53#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
54pub enum Viewing {
55    Head,
56    Past(Rev),
57}
58
59impl Viewing {
60    /// Looking at the past is looking, never editing: every writing
61    /// command is withheld, and undo/redo with them — they act on the head
62    /// nobody is looking at.
63    pub fn writability(self) -> Writability {
64        match self {
65            Viewing::Head => Writability::Writable,
66            Viewing::Past(_) => Writability::ReadOnly,
67        }
68    }
69
70    /// One step through a log whose newest rev is `head`. `None` where the
71    /// step has nowhere to go.
72    ///
73    /// The policy in one place, because two surfaces read it: the cluster
74    /// draws its buttons enabled or not by it, and the dispatch acts on it.
75    /// Log positions are contiguous — a refused commit consumes none — so
76    /// stepping is arithmetic rather than a search. Stepping forward off
77    /// the end is how the past gives way to the writable present.
78    pub fn stepped(self, head: Rev, step: TimeStep) -> Option<At> {
79        let Viewing::Past(at) = self else {
80            return None;
81        };
82        match step {
83            TimeStep::Back => at.prev().filter(|before| *before > Rev::ZERO).map(At::Rev),
84            TimeStep::Forward if at >= head => Some(At::Current),
85            TimeStep::Forward => Some(At::Rev(at.next())),
86        }
87    }
88}
89
90/// Where a time-machine step lands: on a rev, or back in the writable
91/// present.
92#[derive(Clone, Copy, PartialEq, Eq, Debug)]
93pub enum At {
94    Rev(Rev),
95    Current,
96}
97
98/// Which way a time-machine step goes.
99#[derive(Clone, Copy, PartialEq, Eq, Debug)]
100pub enum TimeStep {
101    Back,
102    Forward,
103}
104
105/// Whether "Save" has anything to write. Saving refreshes the projection
106/// beside a log, so it needs a writable container — a scratch session has
107/// no file to keep in step with, and a read-only one may not touch the file
108/// it has.
109#[derive(Clone, Copy, PartialEq, Eq, Debug)]
110pub enum Saving {
111    Offered,
112    Withheld,
113}
114
115/// Whether there is a container behind this document at all. Half of what
116/// [`Saving`] asks; the surfaces that need the halves apart ask this and
117/// [`Doc::writability`] rather than reading one answer as two.
118#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
119pub enum Attachment {
120    Attached,
121    /// No container: the document lives and dies with the process.
122    Scratch,
123}
124
125/// Whether the document can be renamed. Renaming moves the container the
126/// document lives in, so it asks exactly what [`Saving`] asks — is there a
127/// container, and may this session write it — and is derived from it
128/// rather than forming a second opinion.
129#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
130pub enum Renaming {
131    Offered,
132    Withheld,
133}
134
135impl From<Saving> for Renaming {
136    fn from(saving: Saving) -> Self {
137        match saving {
138            Saving::Offered => Renaming::Offered,
139            Saving::Withheld => Renaming::Withheld,
140        }
141    }
142}
143
144/// Which opened document this is, for the life of the handle holding it —
145/// minted when a document is created or opened, never written to a log, a
146/// projection, or anything else durable.
147///
148/// Its whole job is to make two documents opened in one process compare
149/// unequal, and one document opened in two processes compare unequal too:
150/// 64 drawn bits, so a payload that travels through the OS clipboard can
151/// say which document it was cut out of and be believed.
152#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
153pub struct DocumentNonce(u64);
154
155impl DocumentNonce {
156    /// A document of its own. Every [`Doc`] mints one, which is what makes
157    /// "this handle's document" a thing the clipboard can name without any
158    /// call site remembering to.
159    ///
160    /// Drawn through `RandomState` rather than `naming::entropy`, which is
161    /// desktop-only: the browser build has documents and a clipboard too.
162    /// The counter is what keeps two documents *in one process* apart
163    /// whatever the draw does.
164    pub fn mint() -> Self {
165        use std::hash::{BuildHasher as _, Hasher as _, RandomState};
166        use std::sync::atomic::{AtomicU64, Ordering};
167        static OPENED: AtomicU64 = AtomicU64::new(0);
168        let mut hasher = RandomState::new().build_hasher();
169        hasher.write_u64(OPENED.fetch_add(1, Ordering::Relaxed));
170        Self(hasher.finish())
171    }
172}
173
174/// Which record a step moves, which way, and what that record is called —
175/// the three things a step's own commit is written from.
176#[derive(Clone, Copy)]
177pub(crate) struct Stepping<'a> {
178    pub edit: Rev,
179    pub direction: Direction,
180    pub label: &'a str,
181}
182
183/// One history step: adopt the document the trail's top entry restores,
184/// and record it as a commit of its own so the step stays *in* the
185/// history.
186///
187/// The whole policy, in one place, over the one thing the two session
188/// arms disagree about — where the document at a rev comes from. Both
189/// read a rev copy; only the shelf it sits on differs.
190///
191/// # Errors
192/// [`Refusal::Step`] when the trail will not take the step, and whatever
193/// `at` says when the document it names cannot be had.
194pub(crate) fn stepped(
195    repo: &mut Repo,
196    trail: &mut Trail,
197    step: Stepping<'_>,
198    at: impl FnOnce(Rev) -> Result<Document, Refusal>,
199) -> Result<Rev, Refusal> {
200    let Stepping {
201        edit,
202        direction,
203        label,
204    } = step;
205    let entry = trail.stepping(edit, direction)?;
206    let target = at(entry.restores)?;
207    Ok(repo.restore(
208        trail,
209        blockworx_doc::repo::Restoring {
210            entry,
211            direction,
212            target,
213            label,
214        },
215    ))
216}
217
218/// A session with no files, keeping the document at every rev exactly as
219/// a container does — the same encoding over a map instead of a directory
220/// (`docs/log-vs-snapshot.md`).
221///
222/// The payloads come off the revs and go into a store of their own for
223/// the same reason they do on disk: a rev the session steps back through
224/// is one copy of the document, not one copy of every icon in it.
225#[derive(Default)]
226pub struct Shelf {
227    revs: revs::Memory,
228    payloads: crate::assets::Held,
229    /// What each rev's act named and the scope it was made in — a
230    /// manifest row's two advisory fields, for a session that writes no
231    /// rows. A step keeps the framing of the act it moves, so it frames
232    /// what it moved.
233    worked: std::collections::BTreeMap<Rev, Worked>,
234}
235
236impl Shelf {
237    /// Keep the document this session has just reached as rev `at`.
238    ///
239    /// A write that fails is a fault in a map, so it is reported and
240    /// dropped: the document is still in hand, and what a lost rev costs
241    /// is a step this session can no longer take back — which the trail
242    /// then refuses out loud.
243    fn keep(&mut self, at: Rev, document: &Document, worked: Worked) {
244        if let Err(why) = revs::write(&mut self.revs, at, document, &mut self.payloads) {
245            tracing::error!("rev {} was not kept: {why}", at.get());
246        }
247        self.worked.insert(at, worked);
248    }
249
250    fn worked_at(&self, at: Rev) -> Option<Worked> {
251        self.worked.get(&at).cloned()
252    }
253
254    /// The document at `at`, payloads and all.
255    ///
256    /// # Errors
257    /// [`Refusal::Unreachable`] for a rev this session did not keep.
258    fn at(&self, at: Rev) -> Result<Document, Refusal> {
259        let unreachable = |why: &dyn std::fmt::Display| Refusal::Unreachable {
260            at,
261            why: why.to_string(),
262        };
263        let document = revs::read(&self.revs, at).map_err(|why| unreachable(&why))?;
264        revs::attached(document, &self.payloads).map_err(|why| unreachable(&why))
265    }
266
267    /// The digest of the bytes rev `at` was written as, or the empty
268    /// document's for one this session did not keep.
269    fn stamp(&self, at: Rev) -> Digest {
270        revs::stamp(&self.revs, at).unwrap_or_else(|_| Digest::of(&[]))
271    }
272}
273
274/// What the commit at `at` is called — `log[i]` holds rev `i + 1` for a
275/// session whose commits are all its own.
276fn label_of(repo: &Repo, at: Rev) -> Option<String> {
277    let ndx = usize::try_from(at.get().checked_sub(1)?).ok()?;
278    repo.log().get(ndx).map(|commit| commit.label().to_owned())
279}
280
281/// Both arms are boxed, so swapping the document — which is all the editor
282/// ever does with one — moves two words rather than a whole folded document
283/// either way.
284pub enum Doc {
285    /// No file behind it: the history lives and dies with the process.
286    /// The browser has only this arm. Its tags die with it too — there
287    /// are no rows for them to be rebuilt from.
288    Scratch {
289        repo: Box<Repo>,
290        /// Where this session's history stands. Beside the repo rather
291        /// than in it: a step adopts a document the repo cannot reach on
292        /// its own (`docs/log-vs-snapshot.md`).
293        trail: Trail,
294        tags: Tags,
295        /// The document at every rev, in the container's own encoding
296        /// over a map rather than a directory — so this arm steps exactly
297        /// as an attached one does.
298        revs: Box<Shelf>,
299        session: DocumentNonce,
300    },
301    /// A `.bwx` container, open and (unless it says otherwise) writable.
302    /// Which storage holds it is the container's own business, so it is a
303    /// fact of the handle rather than of the target: a directory of files
304    /// on a desktop, origin-private storage in a browser.
305    Attached {
306        store: Box<Store<Any>>,
307        session: DocumentNonce,
308    },
309}
310
311impl Default for Doc {
312    fn default() -> Self {
313        Doc::scratch(Repo::default())
314    }
315}
316
317impl Doc {
318    pub fn scratch(repo: Repo) -> Self {
319        let mut trail = Trail::default();
320        // A repo handed in has already folded its own past, and nobody may
321        // take back a step they did not make.
322        trail.seeded(repo.rev());
323        // The past a handed-in repo arrives with is folded once here, so
324        // the time machine and a step both read a copy rather than
325        // re-folding a prefix per pick.
326        let mut revs = Shelf::default();
327        let mut folding = Repo::default();
328        for commit in repo.log() {
329            let worked = Worked::of(folding.document(), commit);
330            match folding.fold_one(commit.clone()) {
331                Ok(document) => revs.keep(document.rev(), document, worked),
332                Err(why) => tracing::error!("a session's own past will not fold: {why}"),
333            }
334        }
335        revs.keep(repo.rev(), repo.document(), Worked::nothing());
336        Doc::Scratch {
337            repo: Box::new(repo),
338            trail,
339            tags: Tags::default(),
340            revs: Box::new(revs),
341            session: DocumentNonce::mint(),
342        }
343    }
344
345    pub fn attached(store: Store<Any>) -> Self {
346        Doc::Attached {
347            store: Box::new(store),
348            session: DocumentNonce::mint(),
349        }
350    }
351
352    /// Which document this handle is holding. Stable while it is open and
353    /// unequal to every other, which is what a cut payload compares itself
354    /// against when a paste asks whether it has come home.
355    pub fn session(&self) -> DocumentNonce {
356        match self {
357            Doc::Scratch { session, .. } | Doc::Attached { session, .. } => *session,
358        }
359    }
360
361    pub fn repo(&self) -> &Repo {
362        match self {
363            Doc::Scratch { repo, .. } => repo,
364            Doc::Attached { store, .. } => store.repo(),
365        }
366    }
367
368    /// Where this session's history stands: which rev's document the head
369    /// holds, and what one press either way would move.
370    pub fn trail(&self) -> &Trail {
371        match self {
372            Doc::Scratch { trail, .. } => trail,
373            Doc::Attached { store, .. } => store.trail(),
374        }
375    }
376
377    /// What this document's revs are called.
378    pub fn tags(&self) -> &Tags {
379        match self {
380            Doc::Scratch { tags, .. } => tags,
381            Doc::Attached { store, .. } => store.tags(),
382        }
383    }
384
385    /// Where the rows a reader is shown come from: this container's
386    /// manifest, or a session's own commits.
387    pub fn journal(&self) -> Journal<'_> {
388        match self {
389            Doc::Scratch { repo, .. } => Journal::Session(repo.log()),
390            Doc::Attached { store, .. } => Journal::Recorded(store.rows()),
391        }
392    }
393
394    /// What the record at `at` says it worked on: the scope its author was
395    /// standing in and the entities they named. `None` for a rev this
396    /// history does not hold.
397    ///
398    /// A container reads the row; a session with no rows reads the commit
399    /// it still holds, which is what keeps ops in memory.
400    pub fn worked_at(&self, at: Rev) -> Option<Worked> {
401        match self {
402            Doc::Scratch { revs, .. } => revs.worked_at(at),
403            Doc::Attached { store, .. } => store.framing(at).map(Worked::recorded),
404        }
405    }
406
407    /// Where the author of the record at `at` was looking when they wrote
408    /// it. `None` for a session that keeps no rows: there is nothing
409    /// recorded to stand at, so the pick frames the change instead.
410    pub fn camera_at(&self, at: Rev) -> Option<crate::record::Camera> {
411        match self {
412            Doc::Scratch { .. } => None,
413            Doc::Attached { store, .. } => store.framing(at).map(|row| row.camera),
414        }
415    }
416
417    /// What the record at `at` is called — how the undo and redo buttons
418    /// name the step they stand over.
419    pub fn label_at(&self, at: Rev) -> Option<&str> {
420        match self {
421            Doc::Scratch { repo, .. } => {
422                let ndx = usize::try_from(at.get().checked_sub(1)?).ok()?;
423                repo.log().get(ndx).map(Commit::label)
424            }
425            Doc::Attached { store, .. } => store.row(at).map(|row| row.label.as_str()),
426        }
427    }
428
429    /// The document this session holds at `at`, payloads and all — the rev
430    /// copy, never a fold.
431    ///
432    /// # Errors
433    /// [`Refusal::Unreachable`] for a rev whose copy cannot be shown.
434    pub fn document_at(&self, at: Rev) -> Result<Document, Refusal> {
435        match self {
436            Doc::Scratch { revs, .. } => revs.at(at),
437            Doc::Attached { store, .. } => store.document_at(at),
438        }
439    }
440
441    /// The rev this session heads at, and the digest of the bytes it was
442    /// written as — what an export stamps itself with.
443    pub fn stamp_at(&self, at: Rev) -> Stamp {
444        Stamp::at(at, self.state_at(at))
445    }
446
447    fn state_at(&self, at: Rev) -> Digest {
448        match self {
449            Doc::Scratch { revs, .. } => revs.stamp(at),
450            Doc::Attached { store, .. } => store
451                .row(at)
452                .map_or_else(|| Digest::of(&[]), |row| row.hash),
453        }
454    }
455
456    pub fn document(&self) -> &Document {
457        self.repo().document()
458    }
459
460    /// # Errors
461    /// [`Refusal::Fold`] when the fold refuses the commit, and — for an
462    /// attached document — [`Refusal::ReadOnly`] or [`Refusal::Append`].
463    pub fn submit<'a>(
464        &mut self,
465        commit: Commit,
466        by: impl Into<Attribution<'a>>,
467    ) -> Result<Rev, Refusal> {
468        match self {
469            Doc::Scratch {
470                repo, trail, revs, ..
471            } => {
472                // Read before the fold: what a commit worked on is
473                // measured against the document it was written against.
474                let worked = Worked::of(repo.document(), &commit);
475                let rev = repo.submit(commit, trail)?;
476                revs.keep(rev, repo.document(), worked);
477                Ok(rev)
478            }
479            Doc::Attached { store, .. } => store.submit_edit(commit, by),
480        }
481    }
482
483    /// Name `rev` — or, with an empty `name`, stop naming it. Never an
484    /// edit: no rev is spent and the trail does not move.
485    ///
486    /// # Errors
487    /// As [`Store::tag`]; a scratch session's tags are in memory and
488    /// refuse only a rev the log does not hold.
489    pub fn tag<'a>(
490        &mut self,
491        rev: Rev,
492        name: &str,
493        how: crate::tags::Tagging,
494        by: impl Into<Attribution<'a>>,
495    ) -> Result<(), Refusal> {
496        match self {
497            Doc::Scratch { repo, tags, .. } => {
498                if rev == Rev::ZERO || rev > repo.rev() {
499                    return Err(Refusal::NoSuchRev(rev));
500                }
501                tags.apply(rev, name, how);
502                Ok(())
503            }
504            Doc::Attached { store, .. } => store.tag(rev, name, how, by),
505        }
506    }
507
508    /// # Errors
509    /// [`Refusal::Step`] when the trail will not take the step, and as
510    /// [`Self::submit`] otherwise.
511    pub fn undo<'a>(&mut self, edit: Rev, by: impl Into<Attribution<'a>>) -> Result<Rev, Refusal> {
512        self.step(edit, Direction::Undo, by)
513    }
514
515    /// # Errors
516    /// As [`Self::undo`].
517    pub fn redo<'a>(&mut self, edit: Rev, by: impl Into<Attribution<'a>>) -> Result<Rev, Refusal> {
518        self.step(edit, Direction::Redo, by)
519    }
520
521    /// One history step, whichever way and whichever arm holds the revs.
522    ///
523    /// # Errors
524    /// As [`Self::undo`].
525    fn step<'a>(
526        &mut self,
527        edit: Rev,
528        direction: Direction,
529        by: impl Into<Attribution<'a>>,
530    ) -> Result<Rev, Refusal> {
531        match self {
532            Doc::Scratch {
533                repo, trail, revs, ..
534            } => {
535                // As the store: the trail's own refusal comes first.
536                let entry = trail.stepping(edit, direction)?;
537                let label = label_of(repo, entry.rev).ok_or(Refusal::NoSuchRev(edit))?;
538                let moved = revs.worked_at(entry.rev).unwrap_or_else(Worked::nothing);
539                let at = stepped(
540                    repo,
541                    trail,
542                    Stepping {
543                        edit,
544                        direction,
545                        label: &label,
546                    },
547                    |restores| revs.at(restores),
548                )?;
549                revs.keep(at, repo.document(), moved);
550                Ok(at)
551            }
552            Doc::Attached { store, .. } => match direction {
553                Direction::Undo => store.undo(edit, by),
554                Direction::Redo => store.redo(edit, by),
555            },
556        }
557    }
558
559    /// A scratch session is always writable: there is nothing for a lock to
560    /// be held on and no history to be broken.
561    pub fn writability(&self) -> Writability {
562        match self {
563            Doc::Scratch { .. } => Writability::Writable,
564            Doc::Attached { store, .. } => match store.read_only_reason() {
565                Some(_) => Writability::ReadOnly,
566                None => Writability::Writable,
567            },
568        }
569    }
570
571    /// Why this document cannot be written, when it cannot be.
572    pub fn read_only_reason(&self) -> Option<&ReadOnlyReason> {
573        match self {
574            Doc::Scratch { .. } => None,
575            Doc::Attached { store, .. } => store.read_only_reason(),
576        }
577    }
578
579    /// Whether this session can be asked to refresh its projection.
580    /// Whether a container stands behind this document.
581    pub fn attachment(&self) -> Attachment {
582        match self {
583            Doc::Scratch { .. } => Attachment::Scratch,
584            Doc::Attached { .. } => Attachment::Attached,
585        }
586    }
587
588    pub fn saving(&self) -> Saving {
589        match self {
590            Doc::Scratch { .. } => Saving::Withheld,
591            Doc::Attached { store, .. } => match store.read_only_reason() {
592                Some(_) => Saving::Withheld,
593                None => Saving::Offered,
594            },
595        }
596    }
597
598    /// Whether this session can be asked to rename its document.
599    pub fn renaming(&self) -> Renaming {
600        self.saving().into()
601    }
602
603    /// Rename this document's container — the directory's name *is* the
604    /// document's name.
605    ///
606    /// # Errors
607    /// [`Refusal::Detached`] for a session with no container to rename, and
608    /// as [`Store::rename`].
609    pub fn rename(&mut self, to: &Name) -> Result<(), Refusal> {
610        match self {
611            Doc::Scratch { .. } => Err(Refusal::Detached),
612            Doc::Attached { store, .. } => store.rename(to),
613        }
614    }
615
616    /// [`Self::rename`] for a container whose storage is not written to as
617    /// it is asked.
618    ///
619    /// # Errors
620    /// As [`Self::rename`].
621    pub async fn renaming_to(&mut self, to: &Name) -> Result<(), Refusal> {
622        match self {
623            Doc::Scratch { .. } => Err(Refusal::Detached),
624            Doc::Attached { store, .. } => store.renaming_to(to).await,
625        }
626    }
627
628    /// How many writes this session has made to its container's memory and
629    /// not yet to its storage. Zero for a session with no container, and
630    /// for one whose container is written to as it goes.
631    pub fn pending(&self) -> usize {
632        match self {
633            Doc::Scratch { .. } => 0,
634            Doc::Attached { store, .. } => store.pending(),
635        }
636    }
637
638    /// Make the writes this session owes its storage, in the order it made
639    /// them. See [`Store::drain`].
640    ///
641    /// # Errors
642    /// The write that did not land.
643    pub async fn drain(&mut self) -> std::io::Result<Drained> {
644        match self {
645            Doc::Scratch { .. } => Ok(Drained::NOTHING),
646            Doc::Attached { store, .. } => store.drain().await,
647        }
648    }
649
650    /// What this document's container is called, for the chrome that
651    /// names it. `None` for a session with no container.
652    pub fn container_name(&self) -> Option<Name> {
653        match self {
654            Doc::Scratch { .. } => None,
655            Doc::Attached { store, .. } => Some(store.name()),
656        }
657    }
658
659    /// Where this document's container sits on a filesystem. `None` where
660    /// there is no container, and where the one there is does not live on
661    /// one — a fact of the storage, which only a shell that can name a
662    /// path has any use for.
663    pub fn container_path(&self) -> Option<&std::path::Path> {
664        match self {
665            Doc::Scratch { .. } => None,
666            Doc::Attached { store, .. } => store.path(),
667        }
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674    use crate::record::Identity;
675    use blockworx_doc::fixtures::rev;
676
677    fn author() -> Identity {
678        Identity::new("ada")
679    }
680
681    /// The stepping policy the cluster's two buttons and the dispatch both
682    /// read: bounded at the oldest rev, and off the newest into the present.
683    #[test]
684    fn stepping_walks_the_log_and_falls_off_its_end_into_the_present() {
685        let head = rev(3);
686        let step = |at: Viewing, dir| at.stepped(head, dir);
687
688        assert_eq!(step(Viewing::Head, TimeStep::Back), None);
689        assert_eq!(step(Viewing::Head, TimeStep::Forward), None);
690        assert_eq!(step(Viewing::Past(rev(1)), TimeStep::Back), None);
691        assert_eq!(
692            step(Viewing::Past(rev(2)), TimeStep::Back),
693            Some(At::Rev(rev(1))),
694        );
695        assert_eq!(
696            step(Viewing::Past(rev(2)), TimeStep::Forward),
697            Some(At::Rev(rev(3))),
698        );
699        assert_eq!(
700            step(Viewing::Past(rev(3)), TimeStep::Forward),
701            Some(At::Current),
702        );
703        assert_eq!(
704            step(Viewing::Past(rev(3)), TimeStep::Back),
705            Some(At::Rev(rev(2))),
706            "the newest rev still steps back",
707        );
708    }
709
710    /// A tag is not an edit: no rev is spent and nothing becomes undoable.
711    #[test]
712    fn tagging_a_scratch_rev_moves_neither_the_log_nor_the_trail() {
713        let mut doc = Doc::default();
714        let at = doc.submit(one_block(), &author()).expect("the edit lands");
715        let depth = doc.trail().undo_depth();
716
717        doc.tag(at, "Initial Draft", crate::tags::Tagging::Added, &author())
718            .expect("it tags");
719        assert_eq!(doc.tags().of(at), ["Initial Draft"]);
720        assert_eq!(doc.repo().rev(), at, "tagging spent a rev");
721        assert_eq!(doc.trail().undo_depth(), depth, "tagging moved the trail");
722
723        doc.tag(
724            at,
725            "Initial Draft",
726            crate::tags::Tagging::Removed,
727            &author(),
728        )
729        .expect("and untags");
730        assert!(doc.tags().is_empty());
731        assert!(matches!(
732            doc.tag(rev(9), "Nowhere", crate::tags::Tagging::Added, &author()),
733            Err(Refusal::NoSuchRev(_)),
734        ));
735    }
736
737    fn one_block() -> Commit {
738        Commit::new(
739            "Added a block".to_owned(),
740            vec![crate::fixture::block_create(1, "Adder")],
741        )
742    }
743
744    /// A scratch document takes the same three calls an attached one does,
745    /// so the editor's write path has no second shape for the no-file case.
746    #[test]
747    fn a_scratch_document_submits_undoes_and_redoes() {
748        let mut doc = Doc::default();
749        assert_eq!(doc.writability(), Writability::Writable);
750
751        let edit = doc.submit(one_block(), &author()).expect("the edit lands");
752        let undone = doc.undo(edit, &author()).expect("and undoes");
753        doc.redo(undone, &author()).expect("and redoes");
754        assert_eq!(doc.repo().log().len(), 3, "an undo is a forward commit");
755    }
756
757    /// The scratch arm's answer to "where did I stand": the log prefix,
758    /// folded. It has no rev copies, so this is the one place the two
759    /// session arms differ — and they must reach the same documents.
760    #[test]
761    fn a_scratch_session_steps_through_its_own_prefix_folds() {
762        use blockworx_doc::repo::Repo;
763
764        let mut doc = Doc::default();
765        let mut revs = Vec::new();
766        for n in 1..=3 {
767            revs.push(
768                doc.submit(
769                    Commit::new(
770                        format!("Added block {n}"),
771                        vec![crate::fixture::block_create(n, &format!("b{n}"))],
772                    ),
773                    &author(),
774                )
775                .expect("the edit lands"),
776            );
777        }
778        let folded = |at: Rev| {
779            Repo::folding(&doc.repo().log()[..at.get() as usize])
780                .expect("the prefix folds")
781                .document()
782                .clone()
783        };
784        let at_three = folded(revs[2]);
785        let at_two = folded(revs[1]);
786        assert_ne!(at_two, at_three, "precondition: the last edit moved it");
787
788        let undone = doc.undo(revs[2], &author()).expect("the undo lands");
789        assert_eq!(doc.document(), &at_two, "the undo did not reach rev 2");
790        assert_eq!(doc.trail().standing(), revs[1]);
791
792        doc.undo(revs[1], &author()).expect("the second undo lands");
793        assert_eq!(doc.trail().standing(), revs[0]);
794
795        doc.redo(
796            doc.trail().next_redo().expect("a step to put back"),
797            &author(),
798        )
799        .expect("the redo lands");
800        doc.redo(undone, &author()).expect("and the second redo");
801        assert_eq!(
802            doc.document(),
803            &at_three,
804            "the walk back up did not return the document it started from",
805        );
806        assert_eq!(doc.trail().standing(), revs[2]);
807    }
808
809    /// The refusal the editor's history walk reads to decide whether a step
810    /// is spent or the repo is broken, in the spelling both arms report.
811    #[test]
812    fn a_step_the_trail_does_not_hold_is_refused_as_a_step() {
813        let mut doc = Doc::default();
814        assert!(matches!(
815            doc.undo(blockworx_doc::fixtures::rev(1), &author()),
816            Err(Refusal::Step(blockworx_doc::trail::UndoRefusal::Spent)),
817        ));
818    }
819
820    #[test]
821    fn an_attached_document_writes_through_the_container() {
822        use crate::handle::Clock;
823
824        let dir = crate::fixture::dir("doc-attached");
825        let root = dir.join("doc.bwx");
826        let store = Store::create(Any::new(crate::storage::Native::at(&root)), Clock::System)
827            .expect("the container");
828        let mut doc = Doc::attached(store);
829        assert_eq!(doc.writability(), Writability::Writable);
830
831        doc.submit(one_block(), &author()).expect("the edit lands");
832        assert_eq!(
833            std::fs::read_to_string(root.join(crate::container::MANIFEST.as_str()))
834                .expect("the log")
835                .lines()
836                .count(),
837            1,
838            "an edit through the document handle did not reach the log",
839        );
840    }
841
842    /// A container someone else holds opens read-only, and the handle says so
843    /// in the one spelling the command registry and the chrome both read.
844    #[test]
845    fn a_locked_container_is_read_only_and_refuses_writes() {
846        use crate::handle::Clock;
847        use crate::storage::Native;
848
849        let dir = crate::fixture::dir("doc-locked");
850        let root = dir.join("doc.bwx");
851        let _held = Store::create(Native::at(&root), Clock::System).expect("the first session");
852
853        let mut doc = Doc::attached(
854            Store::open(Any::new(Native::at(&root)), Clock::System).expect("the second"),
855        );
856        assert_eq!(doc.writability(), Writability::ReadOnly);
857        assert!(matches!(
858            doc.submit(one_block(), &author()),
859            Err(Refusal::ReadOnly),
860        ));
861        // Renaming is a write on someone else's open document, not a read.
862        assert_eq!(doc.renaming(), Renaming::Withheld);
863        assert!(matches!(
864            doc.rename(&Name::of_document("renamed").expect("a name")),
865            Err(Refusal::ReadOnly),
866        ));
867        assert!(root.exists(), "the refused rename moved it anyway");
868    }
869
870    /// A session with no container has no name to change, and says so
871    /// rather than inventing a file.
872    #[test]
873    fn a_scratch_session_has_no_container_to_rename() {
874        let mut doc = Doc::default();
875        assert_eq!(doc.renaming(), Renaming::Withheld);
876        assert_eq!(doc.container_name(), None);
877        assert!(matches!(
878            doc.rename(&Name::of_document("nowhere").expect("a name")),
879            Err(Refusal::Detached),
880        ));
881    }
882}