Skip to main content

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