Skip to main content

blockworx/store/
prefix.rs

1//! Save-as over a container: a new one holding the source's manifest up
2//! to a rev, line for line, and the rev files those rows name.
3//!
4//! The user, dropping Restore for this (playbook R37): *"a cleaner solution
5//! is to have a way to save a log up to a given rev as a new document. This
6//! allows you to time-machine back to an earlier rev and make clean edits on
7//! top of it."*
8//!
9//! The rows are copied **verbatim**. D12's chain is over the bytes a file
10//! holds, so a save that re-serialized them would be a save that could
11//! break the chain that verifies it; copying a *contiguous prefix* keeps
12//! it whole by construction — every row still follows the one it named as
13//! its parent — and carries every column a re-serialization would have to
14//! invent: the wall times, the authors, the cameras, the edit/undo/redo
15//! kinds, the tag rows, and any field a later build adds.
16//!
17//! What is left behind is everything after the cut, which is what the user
18//! asked for: the revs past it, and the names given to them.
19//!
20//! Names given to revs *inside* the cut are kept whatever order they were
21//! written in — a rev tagged from the head, long after it was made, still
22//! arrives named. Those tag rows cannot be copied (their parent link names
23//! a row that is not here), so they are written again, by whoever asked
24//! for the save: a tag is a claim about history rather than part of it, so
25//! re-stating one is honest where re-stating an edit would not be.
26
27use std::collections::BTreeSet;
28use std::path::Path;
29
30use blockworx_doc::{hash::AssetHash, rev::Rev};
31
32use super::assets::AssetFormat;
33use super::container::{ASSETS, Container, ContainerError, MANIFEST};
34use super::handle::{Clock, Store};
35use super::manifest::{Replays, Row};
36use super::record::Identity;
37use super::revs;
38use super::tags::{Tagging, Tags};
39
40/// Why a document could not be saved as it stood at a rev.
41#[derive(Debug, thiserror::Error)]
42pub enum PrefixFailure {
43    #[error("the manifest could not be read: {0}")]
44    Read(std::io::Error),
45    #[error("line {line} of the manifest is not a row this build can read: {why}")]
46    NotARow { line: usize, why: serde_json::Error },
47    #[error("this history has no rev {}", .0.get())]
48    NoSuchRev(Rev),
49    #[error(transparent)]
50    Create(#[from] ContainerError),
51    #[error("{name} could not be carried over: {why}")]
52    Asset { name: String, why: std::io::Error },
53    #[error("rev {}'s copy could not be carried over: {why}", .at.get())]
54    Rev { at: Rev, why: std::io::Error },
55    #[error("the new container's projection did not land: {0}")]
56    Projection(super::Refusal),
57    #[error("rev {}'s name did not come over: {why}", .at.get())]
58    Tag { at: Rev, why: super::Refusal },
59}
60
61/// Write the document as it stood at `at` into a new container at `to`,
62/// and open it.
63///
64/// The head of the new history is `at`, so the session that lands in it is
65/// writable at the rev it was looking at — which is the whole point.
66///
67/// # Errors
68/// [`PrefixFailure`]: the source manifest could not be read or does not
69/// hold `at`, the container could not be laid out, revs or artwork the
70/// prefix names could not be carried over, or the projection did not land.
71pub fn save_through(
72    source: &Path,
73    at: Rev,
74    to: &Path,
75    mut clock: Clock,
76    by: &Identity,
77) -> Result<Store, PrefixFailure> {
78    let text = std::fs::read_to_string(source.join(MANIFEST)).map_err(PrefixFailure::Read)?;
79    let prefix = through(&text, at)?;
80    let container = Container::create_holding(to, clock.tick(), prefix.as_bytes())?;
81    carry_revs(source, to, at)?;
82    carry_assets(source, to, at)?;
83    let mut store = Store::over(container, clock)?;
84    rename_revs(&mut store, &named_through(&text, at)?, by)?;
85    store.save_projection().map_err(PrefixFailure::Projection)?;
86    Ok(store)
87}
88
89/// Make the saved container call its revs what `wanted` calls them.
90///
91/// Two directions, because the copied rows can be wrong either way: a name
92/// given after the cut is missing from them, and a name *taken off* after
93/// the cut is still in them. Both are one appended tag row, which is what
94/// a tag has always been.
95fn rename_revs(store: &mut Store, wanted: &Tags, by: &Identity) -> Result<(), PrefixFailure> {
96    let carried: Vec<(Rev, Vec<String>)> = store
97        .tags()
98        .iter()
99        .map(|(rev, names)| (rev, names.to_vec()))
100        .collect();
101    let mut restated: Vec<(Rev, String, Tagging)> = Vec::new();
102    for (rev, names) in &carried {
103        for name in names {
104            if !wanted.of(*rev).contains(name) {
105                restated.push((*rev, name.clone(), Tagging::Removed));
106            }
107        }
108    }
109    for (rev, names) in wanted.iter() {
110        for name in names {
111            if !store.tags().of(rev).contains(name) {
112                restated.push((rev, name.clone(), Tagging::Added));
113            }
114        }
115    }
116    for (rev, name, how) in restated {
117        store
118            .tag(rev, &name, how, by)
119            .map_err(|why| PrefixFailure::Tag { at: rev, why })?;
120    }
121    Ok(())
122}
123
124/// What the whole manifest calls the revs at or before `at` — the names
125/// the saved container owes, wherever in the file they were written. A
126/// later row that *clears* a name is honoured like any other: the rev
127/// arrives unnamed, because that is what the source says it is called.
128fn named_through(text: &str, at: Rev) -> Result<Tags, PrefixFailure> {
129    let mut tags = Tags::default();
130    for (nth, line) in whole_lines(text) {
131        let row = parse(nth, line)?;
132        if row.rev <= at
133            && let Replays::Vocabulary(how) = row.kind.replays()
134        {
135            tags.apply(row.rev, &row.label, how);
136        }
137    }
138    Ok(tags)
139}
140
141/// The leading run of `text`'s lines that reaches no further than `at`.
142///
143/// A *contiguous* prefix, because that is what keeps D12's chain whole: a
144/// line dropped from the middle would leave its successor naming a parent
145/// that is no longer there. Which means the tags that survive are the ones
146/// written before the cut — and a tag can only ever name a rev the history
147/// already held, so every one of them names a rev at or before `at`. The
148/// names given later to revs inside the cut are [`named_through`]'s to put
149/// back.
150fn through(text: &str, at: Rev) -> Result<&str, PrefixFailure> {
151    let mut end = 0;
152    let mut reached = Rev::ZERO;
153    for (nth, line) in whole_lines(text) {
154        let row = parse(nth, line)?;
155        if row.kind.takes_a_rev() {
156            if row.rev > at {
157                break;
158            }
159            reached = row.rev;
160        }
161        end += line.len();
162    }
163    if reached != at {
164        return Err(PrefixFailure::NoSuchRev(at));
165    }
166    Ok(&text[..end])
167}
168
169fn parse(nth: usize, line: &str) -> Result<Row, PrefixFailure> {
170    serde_json::from_str(line.trim_end())
171        .map_err(|why| PrefixFailure::NotARow { line: nth + 1, why })
172}
173
174/// The manifest's finished lines, numbered from one. A trailing line with
175/// no newline is left out, exactly as a load drops it: it is a row a crash
176/// caught half-written.
177fn whole_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
178    text.split_inclusive('\n')
179        .take_while(|line| line.ends_with('\n'))
180        .enumerate()
181}
182
183/// Copy the rev files at or below the cut, so the saved container arrives
184/// with the history it inherited already written.
185fn carry_revs(from: &Path, to: &Path, at: Rev) -> Result<(), PrefixFailure> {
186    for rev in revs::through(at) {
187        std::fs::copy(revs::path(from, rev), revs::path(to, rev))
188            .map_err(|why| PrefixFailure::Rev { at: rev, why })?;
189    }
190    Ok(())
191}
192
193/// Copy the artwork the carried revs reference from one container's
194/// `assets/` to another's. Content-addressed, so the file's name in the
195/// new container is the name it had in the old one and nothing has to be
196/// rewritten.
197///
198/// The revs are read back rather than the whole directory copied: what a
199/// save-as owes is the payloads its own history points at, and a payload
200/// nothing past the cut references is not part of what was asked for.
201fn carry_assets(from: &Path, to: &Path, at: Rev) -> Result<(), PrefixFailure> {
202    let backing = revs::Dir::at(to);
203    let mut wanted: BTreeSet<AssetHash> = BTreeSet::new();
204    for rev in revs::through(at) {
205        let Ok(document) = revs::read(&backing, rev) else {
206            continue;
207        };
208        wanted.extend(document.referenced_assets());
209    }
210    for name in wanted
211        .into_iter()
212        .flat_map(|hash| AssetFormat::ALL.map(move |format| format.file_name(hash)))
213    {
214        let (source, target) = (from.join(ASSETS).join(&name), to.join(ASSETS).join(&name));
215        if target.exists() || !source.exists() {
216            continue;
217        }
218        std::fs::copy(&source, &target).map_err(|why| PrefixFailure::Asset { name, why })?;
219    }
220    Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::store::record::Identity;
227    use crate::store::tests::fixture;
228    use blockworx_doc::fixtures::rev;
229
230    /// A container of three edits with a tag on the first and another on
231    /// the third, written by a pinned clock so the bytes are stable.
232    fn source(root: &Path) -> Store {
233        let mut store = Store::create(root, fixture::clock()).expect("the container");
234        let author = Identity::new("ada");
235        for (n, commit) in fixture::edits(3).iter().enumerate() {
236            store
237                .submit_edit(commit.clone(), &author)
238                .expect("the edit lands");
239            if n == 0 {
240                store
241                    .tag(rev(1), "the start", Tagging::Added, &author)
242                    .expect("the tag");
243            }
244        }
245        store
246            .tag(rev(3), "the end", Tagging::Added, &author)
247            .expect("the tag");
248        store
249    }
250
251    /// The load-bearing claim: the new log *is* the old log's first lines,
252    /// byte for byte. Anything less and the chain, the wall times, the
253    /// authors and the record kinds would all be this module's to reproduce.
254    #[test]
255    fn the_saved_log_is_the_sources_own_lines() {
256        let dir = fixture::dir("prefix-bytes");
257        let from = dir.join("source.bwx");
258        drop(source(&from));
259        let to = dir.join("through-2.bwx");
260
261        let saved = save_through(
262            &from,
263            rev(2),
264            &to,
265            fixture::clock(),
266            &Identity::new("grace"),
267        )
268        .expect("the prefix saves");
269        drop(saved);
270
271        let whole = std::fs::read_to_string(from.join(MANIFEST)).expect("the source manifest");
272        let prefix = std::fs::read_to_string(to.join(MANIFEST)).expect("the saved manifest");
273        assert!(
274            whole.starts_with(&prefix),
275            "the saved log is not a prefix of the one it came from",
276        );
277        assert_eq!(
278            prefix.lines().count(),
279            3,
280            "expected two edits and the tag between them, not {prefix}",
281        );
282        assert!(
283            whole.len() > prefix.len(),
284            "precondition: the source runs past rev 2",
285        );
286    }
287
288    /// The rev files come over as files, and only the ones at or below the
289    /// cut: the saved container arrives with the history it inherited
290    /// already written, and carries nothing of the revs it left behind.
291    #[test]
292    fn the_saved_container_carries_exactly_the_revs_through_the_cut() {
293        let dir = fixture::dir("prefix-revs");
294        let from = dir.join("source.bwx");
295        drop(source(&from));
296        let to = dir.join("through-2.bwx");
297        assert!(
298            revs::path(&from, rev(3)).is_file(),
299            "precondition: the source holds a rev past the cut",
300        );
301
302        let saved = save_through(
303            &from,
304            rev(2),
305            &to,
306            fixture::clock(),
307            &Identity::new("grace"),
308        )
309        .expect("the prefix saves");
310        drop(saved);
311
312        for at in revs::through(rev(2)) {
313            assert_eq!(
314                std::fs::read(revs::path(&from, at)).expect("the source rev"),
315                std::fs::read(revs::path(&to, at)).expect("the saved rev"),
316                "rev {} did not come over as the bytes it was",
317                at.get(),
318            );
319        }
320        assert!(
321            !revs::path(&to, rev(3)).exists(),
322            "a rev past the cut came over with the save",
323        );
324    }
325
326    /// Verify-grade: the container replays, its head is the rev that was
327    /// asked for, and the document it folds to is the one that rev held.
328    #[test]
329    fn the_saved_container_replays_to_the_rev_it_was_cut_at() {
330        let dir = fixture::dir("prefix-replay");
331        let from = dir.join("source.bwx");
332        let held = source(&from);
333        let at_two = blockworx_doc::repo::Repo::folding(&held.repo().log()[..2])
334            .expect("the prefix folds")
335            .document()
336            .clone();
337        drop(held);
338
339        let to = dir.join("through-2.bwx");
340        let saved = save_through(
341            &from,
342            rev(2),
343            &to,
344            fixture::clock(),
345            &Identity::new("grace"),
346        )
347        .expect("the prefix saves");
348        assert_eq!(saved.repo().rev(), rev(2), "the head is not the cut");
349        drop(saved);
350
351        let reopened = Store::open(&to, fixture::clock()).expect("the container reopens");
352        assert!(
353            reopened.read_only_reason().is_none(),
354            "the saved container did not verify: {:?}",
355            reopened.read_only_reason(),
356        );
357        assert_eq!(
358            reopened.document().clone(),
359            at_two,
360            "the saved container is not the document that rev held",
361        );
362        assert_eq!(
363            reopened.projection(),
364            crate::store::projection::Freshness::Fresh,
365            "the projection beside the new log does not stamp its own head",
366        );
367    }
368
369    /// The tags the user asked to keep, and the ones they asked to lose:
370    /// a name for a rev inside the prefix survives, and one for a rev past
371    /// it is not there to be found.
372    #[test]
373    fn a_tag_inside_the_cut_survives_and_one_past_it_does_not() {
374        let dir = fixture::dir("prefix-tags");
375        let from = dir.join("source.bwx");
376        let held = source(&from);
377        assert_eq!(held.tags().len(), 2, "precondition: both revs are named");
378        drop(held);
379
380        let to = dir.join("through-2.bwx");
381        let saved = save_through(
382            &from,
383            rev(2),
384            &to,
385            fixture::clock(),
386            &Identity::new("grace"),
387        )
388        .expect("the prefix saves");
389        assert_eq!(saved.tags().of(rev(1)), ["the start"]);
390        assert_eq!(
391            saved.tags().of(rev(3)),
392            Vec::<String>::new(),
393            "a rev past the cut came over with its name",
394        );
395    }
396
397    /// Editing on top of a saved prefix is ordinary authoring: the new
398    /// container is writable at the rev it was cut at, and the next commit
399    /// is rev N+1 of *its* log.
400    #[test]
401    fn a_saved_prefix_takes_the_next_edit_normally() {
402        let dir = fixture::dir("prefix-authoring");
403        let from = dir.join("source.bwx");
404        drop(source(&from));
405
406        let to = dir.join("through-1.bwx");
407        let mut saved = save_through(
408            &from,
409            rev(1),
410            &to,
411            fixture::clock(),
412            &Identity::new("grace"),
413        )
414        .expect("the prefix");
415        let next = saved
416            .submit_edit(fixture::edits(3)[1].clone(), &Identity::new("grace"))
417            .expect("the edit lands on the saved prefix");
418        assert_eq!(
419            next,
420            rev(2),
421            "the saved container did not author from its own head"
422        );
423        drop(saved);
424
425        let reopened = Store::open(&to, fixture::clock()).expect("the container reopens");
426        assert_eq!(reopened.repo().rev(), rev(2));
427        assert!(
428            reopened.read_only_reason().is_none(),
429            "the edit broke the chain"
430        );
431    }
432
433    /// A rev named from the head — long after it was made — is still named
434    /// in the container the cut writes. Its record cannot be copied (its
435    /// parent link names a line that is not there), so it is written again
436    /// by whoever asked for the save.
437    #[test]
438    fn a_name_given_later_to_a_rev_inside_the_cut_is_written_again() {
439        let dir = fixture::dir("prefix-late-tag");
440        let from = dir.join("source.bwx");
441        {
442            let mut store = source(&from);
443            store
444                .tag(
445                    rev(2),
446                    "worth keeping",
447                    Tagging::Added,
448                    &Identity::new("ada"),
449                )
450                .expect("the late tag");
451        }
452
453        let to = dir.join("through-2.bwx");
454        let saved = save_through(
455            &from,
456            rev(2),
457            &to,
458            fixture::clock(),
459            &Identity::new("grace"),
460        )
461        .expect("the prefix saves");
462        assert_eq!(
463            saved.tags().of(rev(2)),
464            ["worth keeping"],
465            "a name given after the cut's own rev was left behind with it",
466        );
467        assert_eq!(saved.repo().rev(), rev(2), "a re-written tag spent a rev");
468        drop(saved);
469
470        let reopened = Store::open(&to, fixture::clock()).expect("the container reopens");
471        assert!(
472            reopened.read_only_reason().is_none(),
473            "the re-written tag broke the chain: {:?}",
474            reopened.read_only_reason(),
475        );
476        assert_eq!(reopened.tags().of(rev(2)), ["worth keeping"]);
477    }
478
479    /// A name the source later *cleared* is not put back: what the saved
480    /// container calls a rev is what the source calls it now.
481    #[test]
482    fn a_name_the_source_took_back_does_not_come_over() {
483        let dir = fixture::dir("prefix-untag");
484        let from = dir.join("source.bwx");
485        {
486            let mut store = source(&from);
487            store
488                .tag(rev(1), "the start", Tagging::Removed, &Identity::new("ada"))
489                .expect("the untag");
490        }
491
492        let to = dir.join("through-2.bwx");
493        let saved = save_through(
494            &from,
495            rev(2),
496            &to,
497            fixture::clock(),
498            &Identity::new("grace"),
499        )
500        .expect("the prefix saves");
501        assert_eq!(
502            saved.tags().of(rev(1)),
503            Vec::<String>::new(),
504            "the saved container kept a name the source had dropped",
505        );
506    }
507
508    /// Artwork the prefix names travels with it — content-addressed, so
509    /// the file keeps its name — and the saved container hydrates it on
510    /// replay rather than opening read-only over a reference it cannot
511    /// honour.
512    #[test]
513    fn the_artwork_the_prefix_names_comes_with_it() {
514        let dir = fixture::dir("prefix-assets");
515        let from = dir.join("source.bwx");
516        let asset = {
517            let mut store = Store::create(&from, fixture::clock()).expect("the container");
518            let author = Identity::new("ada");
519            store
520                .submit_edit(fixture::edits(1)[0].clone(), &author)
521                .expect("the block lands");
522            let asset = fixture::svg(1);
523            store
524                .submit_edit(
525                    fixture::commit("Added an icon", fixture::icon(1, &asset)),
526                    &author,
527                )
528                .expect("the icon lands");
529            asset
530        };
531        assert_eq!(
532            std::fs::read_dir(from.join(ASSETS))
533                .expect("the source assets")
534                .count(),
535            1,
536            "precondition: the payload was extracted beside the source log",
537        );
538
539        let to = dir.join("through-2.bwx");
540        let saved = save_through(
541            &from,
542            rev(2),
543            &to,
544            fixture::clock(),
545            &Identity::new("grace"),
546        )
547        .expect("the prefix saves");
548        assert!(
549            saved.read_only_reason().is_none(),
550            "the saved container could not honour its own artwork: {:?}",
551            saved.read_only_reason(),
552        );
553        drop(saved);
554        let carried: Vec<Vec<u8>> = std::fs::read_dir(to.join(ASSETS))
555            .expect("the saved assets")
556            .map(|entry| std::fs::read(entry.expect("an entry").path()).expect("the payload"))
557            .collect();
558        assert_eq!(carried, vec![asset.bytes().to_vec()]);
559    }
560
561    /// The cut is a rev this log holds, or it is refused — a container half
562    /// written is worse than a save that did not happen.
563    #[test]
564    fn a_rev_the_log_does_not_hold_is_refused() {
565        let dir = fixture::dir("prefix-no-such-rev");
566        let from = dir.join("source.bwx");
567        drop(source(&from));
568
569        let to = dir.join("through-9.bwx");
570        assert!(matches!(
571            save_through(
572                &from,
573                rev(9),
574                &to,
575                fixture::clock(),
576                &Identity::new("grace")
577            ),
578            Err(PrefixFailure::NoSuchRev(_)),
579        ));
580        assert!(!to.exists(), "a refused save left a container behind");
581    }
582
583    /// Cutting at the head copies the whole log — which is what an ordinary
584    /// Save-as of an open container now is.
585    #[test]
586    fn cutting_at_the_head_copies_every_line() {
587        let dir = fixture::dir("prefix-whole");
588        let from = dir.join("source.bwx");
589        drop(source(&from));
590
591        let to = dir.join("whole.bwx");
592        let saved = save_through(
593            &from,
594            rev(3),
595            &to,
596            fixture::clock(),
597            &Identity::new("grace"),
598        )
599        .expect("the whole log");
600        assert_eq!(saved.repo().rev(), rev(3));
601        assert_eq!(saved.tags().len(), 2, "a whole copy dropped a name");
602        drop(saved);
603        assert_eq!(
604            std::fs::read(from.join(MANIFEST)).expect("the source manifest"),
605            std::fs::read(to.join(MANIFEST)).expect("the copy"),
606            "a whole copy is not the same bytes",
607        );
608    }
609}