1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum Writability {
39 Writable,
40 ReadOnly,
41}
42
43#[derive(Clone, Copy, PartialEq, Eq, Debug)]
54pub enum Authoring {
55 Offered,
56 Withheld,
57}
58
59impl Authoring {
60 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
90pub enum Viewing {
91 Head,
92 Past(Rev),
93}
94
95impl Viewing {
96 pub fn writability(self) -> Writability {
100 match self {
101 Viewing::Head => Writability::Writable,
102 Viewing::Past(_) => Writability::ReadOnly,
103 }
104 }
105
106 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 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
138pub enum At {
139 Rev(Rev),
140 Current,
141}
142
143#[derive(Clone, Copy, PartialEq, Eq, Debug)]
145pub enum TimeStep {
146 Back,
147 Forward,
148}
149
150#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155pub enum Saving {
156 Offered,
157 Withheld,
158}
159
160#[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#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
188pub struct DocumentNonce(u64);
189
190impl DocumentNonce {
191 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#[derive(Clone, Copy)]
212pub(crate) struct Stepping<'a> {
213 pub edit: Rev,
214 pub direction: Direction,
215 pub label: &'a str,
216}
217
218pub(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#[derive(Default)]
261pub struct Shelf {
262 revs: revs::Memory,
263 payloads: crate::store::assets::Held,
264 worked: std::collections::BTreeMap<Rev, crate::spotlight::Worked>,
269}
270
271impl Shelf {
272 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 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 fn stamp(&self, at: Rev) -> Digest {
305 revs::stamp(&self.revs, at).unwrap_or_else(|_| Digest::of(&[]))
306 }
307}
308
309fn 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
316pub enum Doc {
320 Scratch {
324 repo: Box<Repo>,
325 trail: Trail,
329 tags: Tags,
330 revs: Box<Shelf>,
334 session: DocumentNonce,
335 },
336 #[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 trail.seeded(repo.rev());
356 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 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 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 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 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 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 #[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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 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 pub fn renaming(&self) -> Renaming {
683 self.saving().into()
684 }
685
686 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}