Skip to main content

prov_store/
index.rs

1//! The write half of the ID index.
2//!
3//! [`prov_graph::index`] declares [`IdIndex`] — the lookups link resolution
4//! needs, and no way to change what is stored. This module declares everything
5//! that does change it: [`IndexStore`], the [`Rebase`] seam a pending change
6//! set answers through, and the two concrete registries whose state only a
7//! writer has any use for — [`InMemoryIndex`] and the registry-document-backed
8//! [`FileIndex`].
9//!
10//! ## Tombstones — IDs are forever
11//!
12//! Deleting a document leaves a *tombstone*: the ID stops resolving but is
13//! never forgotten, so it can never be reminted to mean something else. A
14//! dangling `prov:` reference then stays *diagnosable* ("that document was
15//! deleted") instead of becoming a silent re-resolution hazard.
16
17use std::collections::{BTreeMap, BTreeSet, HashMap};
18use std::path::{Path, PathBuf};
19
20use prov_graph::Result;
21use prov_graph::document::{Document, MetaCarrier, require_whole_file, whole_file_format};
22use prov_graph::identity::Id;
23use prov_graph::index::{IdIndex, NoIndex};
24use prov_graph::meta::{Mapping, Value};
25
26use crate::edit::MetaEditor;
27
28/// What a pending change set can tell a store about its own persisted home.
29///
30/// [`IndexStore::rebase`] needs exactly two facts before it renders: where the
31/// document hosting its records will *end up* once the change lands, and what
32/// text will be in it. That is the whole of the dependency, so it is the whole
33/// of this trait — the alternative, handing `rebase` the change set itself,
34/// would make every store implementor depend on the mutation engine to answer
35/// two questions about a path.
36pub trait Rebase {
37    /// Where `path` will be after the change lands, if the change moves it.
38    fn renamed_to(&self, path: &Path) -> Option<PathBuf>;
39
40    /// The bytes the change will leave at `path`, if it writes there.
41    fn staged(&self, path: &Path) -> Option<&[u8]>;
42}
43
44/// Somewhere IDs (and eventually derived graph data) are persisted and queried —
45/// [`IdIndex`]'s lookups plus everything that changes what is stored.
46pub trait IndexStore: IdIndex {
47    /// Record that `id` names the document at `path`.
48    fn register(&mut self, id: &Id, path: &Path);
49
50    /// Update the path an ID points at (e.g. after a move/rename).
51    ///
52    /// Bijection-safe like [`register`](IndexStore::register): if `new_path`
53    /// already carries a *different* id, that id's forward entry must not
54    /// survive pointing at a path it no longer owns. A caller that moves an id
55    /// it did not just mint should ask
56    /// `prov`'s `Workspace::move_conflict` first and
57    /// refuse the collision up front — the document being displaced still
58    /// spells the id in its own frontmatter. This eviction is the same last
59    /// line of defence [`register`](IndexStore::register) keeps, for when
60    /// something slips through anyway. A store with tombstones should also
61    /// *retire* what it displaces, so an evicted id stays
62    /// [`is_known`](IdIndex::is_known) and can never be reissued;
63    /// [`FileIndex`] does.
64    fn set_path(&mut self, id: &Id, new_path: &Path);
65
66    /// Retire an ID (e.g. after a delete). A store with tombstones keeps the
67    /// ID on record so it is never reissued; a plain store may forget it.
68    fn unregister(&mut self, id: &Id);
69
70    // ---- staging ----
71    //
72    // A mutation's registry update has to land in the *same* unit as its
73    // document edits (§ the module docs): a rename that repoints three links but
74    // loses its `id → path` update leaves every `prov:<id>` reference to the
75    // moved document resolving to nothing — the exact failure IDs exist to
76    // prevent, and the one the documents cannot self-heal from, because the
77    // registry is authoritative rather than derived (DESIGN §5).
78    //
79    // So the op mutates the store in memory *first*, stages the resulting write
80    // alongside the documents', and applies the lot. These four hooks are what
81    // make that reversible. All default to nothing, which is exactly right for
82    // [`NoIndex`] (nothing to persist) and for a store that persists itself.
83
84    /// Snapshot the store, so a mutation that fails can put it back. Called
85    /// before an op touches the index; paired with exactly one
86    /// [`rollback`](IndexStore::rollback) or [`committed`](IndexStore::committed).
87    fn checkpoint(&mut self) {}
88
89    /// Restore the last [`checkpoint`](IndexStore::checkpoint) — the mutation
90    /// failed and its writes were unwound, so the in-memory store must forget it
91    /// too, or it would claim a move that never happened.
92    fn rollback(&mut self) {}
93
94    /// The mutation's writes landed: drop the checkpoint.
95    ///
96    /// `persisted` says whether this store's own [`pending_write`] was among
97    /// them. These are two different facts and must not be conflated: the
98    /// checkpoint is dropped **unconditionally**, because the op succeeded and
99    /// there is nothing left to undo, while `dirty` clears only when the write
100    /// actually went out. A store with no home stages nothing yet still commits
101    /// successfully — leaving its checkpoint outstanding would make the *next*
102    /// op's `prov`'s `change`(`prov`'s `Workspace::change`) mistake it for one
103    /// abandoned mid-edit and unwind a mutation that fully happened.
104    ///
105    /// [`pending_write`]: IndexStore::pending_write
106    fn committed(&mut self, persisted: bool) {
107        let _ = persisted;
108    }
109
110    /// Follow the mutation's change set to wherever it leaves this store's home.
111    ///
112    /// Called just before [`pending_write`](IndexStore::pending_write), because a
113    /// store that persists into a *document* has a problem the rest of the
114    /// mutation does not: that document is itself part of the workspace, and the
115    /// same op may be moving or rewriting it. The registry declares a `part_of`
116    /// back at the root, so moving the root re-relativizes it; and the registry
117    /// document can simply be renamed like any other node.
118    ///
119    /// Either way its write is staged *last*, so without this it would render
120    /// against the text read at startup and land at the path read at startup —
121    /// silently reverting the op's edit, or recreating the file the op just
122    /// renamed away from. Rebasing first makes the last write build *on* the
123    /// earlier one instead of erasing it.
124    fn rebase(&mut self, cs: &dyn Rebase) -> Result<()> {
125        let _ = cs;
126        Ok(())
127    }
128
129    /// The write that would persist this store, as `(path, full new text)` —
130    /// staged into the mutation's change set and applied with it.
131    ///
132    /// `None` when there is nothing to write: the store is unchanged, has no
133    /// file home, or persists itself some other way. A store that returns `None`
134    /// while dirty is left dirty, so a caller that knows a home this store does
135    /// not can still write it (the CLI bootstrapping a registry document only
136    /// once a fix has actually minted an ID).
137    fn pending_write(&mut self) -> Result<Option<(PathBuf, String)>> {
138        Ok(None)
139    }
140}
141
142impl IndexStore for NoIndex {
143    fn register(&mut self, _id: &Id, _path: &Path) {}
144    fn set_path(&mut self, _id: &Id, _new_path: &Path) {}
145    fn unregister(&mut self, _id: &Id) {}
146}
147
148/// A simple in-memory registry — for tests and ephemeral workspaces. No
149/// tombstones: an unregistered ID is forgotten entirely.
150#[derive(Debug, Clone, Default)]
151pub struct InMemoryIndex {
152    forward: HashMap<Id, PathBuf>,
153    reverse: HashMap<PathBuf, Id>,
154    /// The last [`checkpoint`](IndexStore::checkpoint), restored by
155    /// [`rollback`](IndexStore::rollback). Nothing is persisted from here, so
156    /// the two maps are the whole of the state to save.
157    saved: Option<Box<InMemoryState>>,
158}
159
160/// An [`InMemoryIndex`]'s saved state — see its `saved` field.
161#[derive(Debug, Clone)]
162struct InMemoryState {
163    forward: HashMap<Id, PathBuf>,
164    reverse: HashMap<PathBuf, Id>,
165}
166
167impl InMemoryIndex {
168    /// An empty registry.
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    /// The number of registered IDs.
174    pub fn len(&self) -> usize {
175        self.forward.len()
176    }
177
178    /// Whether the registry is empty.
179    pub fn is_empty(&self) -> bool {
180        self.forward.is_empty()
181    }
182}
183
184impl IdIndex for InMemoryIndex {
185    fn resolve(&self, id: &Id) -> Option<PathBuf> {
186        self.forward.get(id).cloned()
187    }
188
189    fn id_for_path(&self, path: &Path) -> Option<Id> {
190        self.reverse.get(path).cloned()
191    }
192}
193
194impl IndexStore for InMemoryIndex {
195    /// Displacing an existing registration must not leave the *other* map
196    /// pointing at the old counterpart — [`set_path`](IndexStore::set_path)
197    /// delegates here for exactly this care. Without it a collision leaves the
198    /// registry claiming two paths for one id (or two ids for one path), which
199    /// nothing downstream can make sense of.
200    ///
201    /// Eviction is the last line of defence, not the intended path: callers that
202    /// register (or move, via `set_path`) an id they did not just mint should
203    /// ask `prov`'s `Workspace::registration_conflict` or
204    /// `prov`'s `Workspace::move_conflict` first and refuse,
205    /// because the document being displaced still spells the id in its own
206    /// frontmatter. What this guarantees is only that the index stays
207    /// *consistent* when something slips through.
208    fn register(&mut self, id: &Id, path: &Path) {
209        if let Some(old_path) = self.forward.insert(id.clone(), path.to_path_buf()) {
210            self.reverse.remove(&old_path);
211        }
212        if let Some(old_id) = self.reverse.insert(path.to_path_buf(), id.clone())
213            && old_id != *id
214        {
215            self.forward.remove(&old_id);
216        }
217    }
218
219    /// Moving an id onto a path is the same bijection-safe upsert as
220    /// registering it there fresh — displacing in either direction must not
221    /// leave the other map pointing at the old counterpart — so this *is*
222    /// [`register`](IndexStore::register), not a near-duplicate of it. Before
223    /// this delegated, a displacement in the new-path direction went
224    /// unevicted: `new_path`'s previous id kept a forward entry pointing at a
225    /// path it no longer owned, the exact two-ids-one-path break `register`
226    /// was fixed against in 11abd38.
227    fn set_path(&mut self, id: &Id, new_path: &Path) {
228        self.register(id, new_path);
229    }
230
231    fn unregister(&mut self, id: &Id) {
232        if let Some(path) = self.forward.remove(id) {
233            self.reverse.remove(&path);
234        }
235    }
236
237    fn checkpoint(&mut self) {
238        self.saved = Some(Box::new(InMemoryState {
239            forward: self.forward.clone(),
240            reverse: self.reverse.clone(),
241        }));
242    }
243
244    fn rollback(&mut self) {
245        if let Some(saved) = self.saved.take() {
246            self.forward = saved.forward;
247            self.reverse = saved.reverse;
248        }
249    }
250
251    /// Nothing here is ever persisted, so `persisted` is irrelevant — but the
252    /// checkpoint must still be dropped on every success.
253    fn committed(&mut self, _persisted: bool) {
254        self.saved = None;
255    }
256}
257
258/// The persistent registry: a snapshot with tombstones, living **under the
259/// `registry` key of a workspace document** — the document the root's
260/// registry-pointer relation targets.
261///
262/// The host document can be either shape (`MetaCarrier`): a bare config file
263/// (`registry.yaml`, `registry.figl`, …) whose whole content is metadata, or a
264/// prose document (`registry.md`) whose fenced frontmatter carries the records.
265/// Writes splice only the `registry` value back through the carrier-aware
266/// editor, so the host's other keys (`title`, `part_of` — the self-description
267/// that makes the registry a first-class node of the tree), its comments
268/// outside the records, its body, and its fence style all survive.
269///
270/// The rendered records are one per line (in YAML hosts), sorted by ID; a live
271/// record is `id: path`, a tombstone is `id: null` (DESIGN §5's diff-friendly
272/// shape). This type is pure — text in ([`FileIndex::parse`]), text out
273/// ([`FileIndex::render`]) — so any storage backend can host it; the caller
274/// owns the I/O and can consult [`is_dirty`](FileIndex::is_dirty) to skip
275/// no-op writes.
276#[derive(Debug, Clone)]
277pub struct FileIndex {
278    live: InMemoryIndex,
279    tombstones: BTreeSet<Id>,
280    /// The host document's workspace-relative path — where
281    /// [`pending_write`](IndexStore::pending_write) stages its write. `None` for
282    /// a registry with no document behind it yet: an
283    /// [`InMemoryIndex`]-in-disguise built by [`new`](FileIndex::new), either
284    /// because the workspace stores IDs in frontmatter only (nothing to persist)
285    /// or because no registry document has been bootstrapped yet. Such a store
286    /// stays dirty rather than silently dropping records, so a caller that knows
287    /// a home can still write it.
288    host: Option<PathBuf>,
289    /// The host document's full current text and carrier — what `render`
290    /// splices the records back into.
291    host_text: String,
292    carrier: MetaCarrier,
293    /// The record state as currently written in `host_text` — `render` applies
294    /// only the per-record diff against this, as scalar upserts (whole-mapping
295    /// splices cannot round-trip through every carrier; scalars can).
296    persisted: BTreeMap<Id, Option<String>>,
297    /// Whether `host_text` already has a `registry` key. When it does not, the
298    /// first render inserts the whole mapping at once — that is what gets the
299    /// block (one-record-per-line) layout on bare hosts; per-record creation
300    /// would make fig auto-create a flow map.
301    has_registry_key: bool,
302    dirty: bool,
303    /// The last [`checkpoint`](IndexStore::checkpoint).
304    saved: Option<Box<FileIndexState>>,
305}
306
307/// Every field of a [`FileIndex`] a mutation can move — saved by
308/// [`checkpoint`](IndexStore::checkpoint) and put back by
309/// [`rollback`](IndexStore::rollback). `render` advances `host_text`/`persisted`
310/// as a side effect of staging, so those are as much part of the mutation as the
311/// records themselves and have to unwind with them.
312#[derive(Debug, Clone)]
313struct FileIndexState {
314    live: InMemoryIndex,
315    tombstones: BTreeSet<Id>,
316    host_text: String,
317    persisted: BTreeMap<Id, Option<String>>,
318    has_registry_key: bool,
319    dirty: bool,
320}
321
322impl FileIndex {
323    /// An empty registry with no host document — see the `host` field. Records
324    /// resolve in memory; nothing is staged for writing.
325    pub fn new(format: fig::Format) -> Self {
326        Self {
327            live: InMemoryIndex::new(),
328            tombstones: BTreeSet::new(),
329            host: None,
330            host_text: String::new(),
331            carrier: MetaCarrier::WholeFile(format),
332            persisted: BTreeMap::new(),
333            has_registry_key: false,
334            dirty: false,
335            saved: None,
336        }
337    }
338
339    /// Give a registry a host document to persist into, adopting `text` as its
340    /// current contents.
341    ///
342    /// The bootstrap seam: a workspace that only discovers it needs a registry
343    /// *after* a mutation has minted an ID (`check --fix` declines to create one
344    /// until a fix actually registers something) creates the document, then hands
345    /// it here so the write renders against the real host — its title, its
346    /// `part_of`, its fence style — rather than against nothing.
347    ///
348    /// **This store's records stay authoritative.** Only the write *target* is
349    /// adopted: the host's text, carrier, and already-persisted record state, so
350    /// [`render`](Self::render) splices a correct diff into it. Records the host
351    /// happens to carry are not merged into memory — they were not part of what
352    /// this store was built from, and adopting them here would resurrect, as live
353    /// records, whatever a scan or a caller had deliberately left out. They are
354    /// not *lost* either: `render` only ever touches the records it knows about,
355    /// so their lines survive in the document and are read back normally by the
356    /// next [`parse`](Self::parse).
357    pub fn set_host(&mut self, path: impl Into<PathBuf>, text: &str) -> Result<()> {
358        let path = path.into();
359        let reparsed = Self::parse(&path, text)?;
360        self.host = Some(path);
361        self.carrier = reparsed.carrier;
362        self.host_text = reparsed.host_text;
363        self.persisted = reparsed.persisted;
364        self.has_registry_key = reparsed.has_registry_key;
365        Ok(())
366    }
367
368    /// The document this registry persists into, if it has one.
369    pub fn host(&self) -> Option<&Path> {
370        self.host.as_deref()
371    }
372
373    /// Parse the registry out of its host document. `path` picks the carrier
374    /// (a config extension means the whole file is metadata; anything else is
375    /// searched for a fenced block); the records are read from the metadata's
376    /// `registry` key. A host with no `registry` key is an empty registry —
377    /// the rest of its metadata is left alone.
378    pub fn parse(path: &Path, text: &str) -> Result<Self> {
379        let doc = Document::parse(path, text)?;
380        let carrier = doc.carrier.unwrap_or_else(|| {
381            // No metadata yet: default by extension, else fresh YAML frontmatter.
382            whole_file_format(path)
383                .map(MetaCarrier::WholeFile)
384                .unwrap_or(MetaCarrier::Fenced(fig::EmbedType::FrontmatterYaml))
385        });
386        // A registry is a record store, so it must be a whole-file config
387        // document — a markdown carrier is refused (DESIGN §5, whole-file rule).
388        require_whole_file(path, carrier)?;
389        let mut index = Self {
390            live: InMemoryIndex::new(),
391            tombstones: BTreeSet::new(),
392            host: Some(path.to_path_buf()),
393            host_text: text.to_string(),
394            carrier,
395            persisted: BTreeMap::new(),
396            has_registry_key: doc.meta.get("registry").is_some(),
397            dirty: false,
398            saved: None,
399        };
400        if let Some(registry) = doc.meta.get("registry").and_then(Value::as_mapping) {
401            for (id, value) in registry {
402                let id = Id(id.clone());
403                match value {
404                    Value::Null => {
405                        index.persisted.insert(id.clone(), None);
406                        index.tombstones.insert(id);
407                    }
408                    Value::String(path) => {
409                        index.persisted.insert(id.clone(), Some(path.clone()));
410                        index.live.register(&id, Path::new(path));
411                    }
412                    _ => {
413                        return Err(prov_graph::error::Error::Structure(format!(
414                            "registry entry `{id}` must be a path or null (tombstone)"
415                        )));
416                    }
417                }
418            }
419        }
420        Ok(index)
421    }
422
423    /// Render the host document with the current records applied to its
424    /// `registry` key. Each changed record is a *scalar* upsert
425    /// (`registry.<id> = path` / `null`), so everything else in the host —
426    /// title, part_of, comments, body, fences, existing record lines — is
427    /// untouched, whatever the carrier. Records never reorder; new ones land
428    /// in ID order.
429    pub fn render(&mut self) -> Result<String> {
430        let mut current: BTreeMap<Id, Option<String>> = BTreeMap::new();
431        for id in &self.tombstones {
432            current.insert(id.clone(), None);
433        }
434        for (id, path) in &self.live.forward {
435            current.insert(id.clone(), Some(path.to_string_lossy().into_owned()));
436        }
437        if current == self.persisted {
438            return Ok(self.host_text.clone());
439        }
440
441        // First materialization of the `registry` key.
442        if !self.has_registry_key {
443            let mut registry = Mapping::new();
444            for (id, value) in &current {
445                registry.insert(
446                    id.0.clone(),
447                    value.clone().map(Value::String).unwrap_or(Value::Null),
448                );
449            }
450            let rendered = match self.carrier {
451                // Bare host: rebuild the whole config document (its metadata
452                // plus the new registry mapping) through `serialize_mapping`,
453                // whose block layout gives one record per line. This is the
454                // one write that does not go through the comment-preserving
455                // editor — a fig value splice renders short maps in flow
456                // style, which would freeze the registry inline forever.
457                // Bootstrap hosts are machine-generated, so nothing of note
458                // is lost; afterwards every write is a preserving upsert.
459                MetaCarrier::WholeFile(format) => {
460                    let mut top = prov_graph::meta::parse_mapping(&self.host_text, format)?;
461                    top.insert("registry".into(), Value::Mapping(registry));
462                    prov_graph::meta::serialize_mapping(&top, format)?
463                }
464                // A registry is always whole-file (enforced in `parse`/`new`), so
465                // a fenced carrier cannot reach here; refuse defensively rather
466                // than silently write a store the load path would then reject.
467                MetaCarrier::Fenced(_) => {
468                    return Err(prov_graph::error::Error::MarkdownStore(
469                        self.host.clone().unwrap_or_default(),
470                    ));
471                }
472            };
473            self.host_text = rendered.clone();
474            self.persisted = current;
475            self.has_registry_key = true;
476            return Ok(rendered);
477        }
478
479        // Steady state: per-record comment-preserving upserts of the diff.
480        let mut editor = MetaEditor::open_or_init(&self.host_text, Some(self.carrier))?;
481        for (id, value) in &current {
482            if self.persisted.get(id) == Some(value) {
483                continue;
484            }
485            let fig_value = value
486                .clone()
487                .map(fig::Value::Str)
488                .unwrap_or(fig::Value::Null);
489            editor.set_value(
490                &[
491                    fig::Segment::Key("registry"),
492                    fig::Segment::Key(id.as_str()),
493                ],
494                fig_value,
495            )?;
496        }
497        let rendered = editor.render()?;
498        self.host_text = rendered.clone();
499        self.persisted = current;
500        Ok(rendered)
501    }
502
503    /// Whether the registry changed since it was parsed/created (i.e. needs a
504    /// write). Cleared by [`mark_clean`](FileIndex::mark_clean).
505    pub fn is_dirty(&self) -> bool {
506        self.dirty
507    }
508
509    /// Mark the registry as persisted.
510    pub fn mark_clean(&mut self) {
511        self.dirty = false;
512    }
513
514    /// The number of live (resolving) IDs.
515    pub fn len(&self) -> usize {
516        self.live.len()
517    }
518
519    /// Whether the registry has no live IDs.
520    pub fn is_empty(&self) -> bool {
521        self.live.is_empty()
522    }
523
524    /// Whether `id` is retired: known but no longer resolving.
525    pub fn is_tombstoned(&self, id: &Id) -> bool {
526        self.tombstones.contains(id)
527    }
528
529    /// Iterate live records as `(id, path)`, sorted by ID.
530    pub fn iter(&self) -> impl Iterator<Item = (&Id, &PathBuf)> {
531        let mut live: Vec<_> = self.live.forward.iter().collect();
532        live.sort_by(|a, b| a.0.cmp(b.0));
533        live.into_iter()
534    }
535}
536
537impl IdIndex for FileIndex {
538    fn resolve(&self, id: &Id) -> Option<PathBuf> {
539        self.live.resolve(id)
540    }
541
542    fn id_for_path(&self, path: &Path) -> Option<Id> {
543        self.live.id_for_path(path)
544    }
545
546    /// A tombstoned id no longer resolves but stays known forever, so it can
547    /// never be reminted to mean something else.
548    fn is_known(&self, id: &Id) -> bool {
549        self.live.resolve(id).is_some() || self.tombstones.contains(id)
550    }
551}
552
553impl IndexStore for FileIndex {
554    /// Registering an id **retires its tombstone**, because the id is live
555    /// again and a record cannot be both. This is not a hypothetical pairing:
556    /// `restore` from the recycle bin re-registers the very id `recycle`
557    /// tombstoned, so the sequence runs whenever a delete is undone.
558    ///
559    /// [`render`](Self::render) has always resolved the two in this direction —
560    /// it lays the live records down *over* the tombstones — so without this the
561    /// store disagrees with its own serialization until the process restarts,
562    /// and a round trip through the registry document silently "changes" it.
563    /// Nothing is lost by forgetting the tombstone: `is_known` stays true
564    /// through `resolve` while the id is live, so mint-by-rejection cannot
565    /// reissue it, and retiring it again tombstones it again.
566    /// Registering maintains the tombstone set in **both** directions, which is
567    /// what makes "an ID is never reissued" (DESIGN §10) true of this store
568    /// rather than merely intended.
569    ///
570    /// *Retires whatever it displaces.* Taking a path out from under the id
571    /// currently carrying it evicts that id from the live map — the bijection
572    /// repair [`InMemoryIndex::register`] performs and documents. Eviction alone
573    /// would forget the id *entirely*, so [`is_known`](IdIndex::is_known)
574    /// would go from true to false and a later mint could reissue it while the
575    /// displaced document still spells it in its own frontmatter. Reaching that
576    /// needs a displacement to slip past `registration_conflict` /
577    /// `move_conflict`, which is precisely the case this store is the last line
578    /// of defence for, so the displaced id earns a tombstone on the way out.
579    ///
580    /// *Un-retires what it registers.* An id being registered is live, and a
581    /// record cannot be both live and retired. This runs whenever a delete is
582    /// undone: `restore` re-registers the very id `recycle` tombstoned.
583    /// [`render`](Self::render) has always resolved the pair this way — it lays
584    /// the live records over the tombstones — so without this the store
585    /// disagrees with its own serialization until the process restarts. Nothing
586    /// is lost by forgetting the tombstone, because the id is `is_known` through
587    /// `resolve` while it is live, and the clause above tombstones it again if it
588    /// is ever displaced.
589    ///
590    /// The two clauses only work together. Un-retiring without retiring the
591    /// displaced would make the forgetting *easier* to reach: an id restored
592    /// from the bin and then displaced would have no tombstone left to fall back
593    /// on.
594    fn register(&mut self, id: &Id, path: &Path) {
595        if let Some(displaced) = self.live.id_for_path(path)
596            && displaced != *id
597        {
598            self.tombstones.insert(displaced);
599        }
600        self.live.register(id, path);
601        self.tombstones.remove(id);
602        self.dirty = true;
603    }
604
605    /// Moving an id onto a path is registering it there — the same
606    /// bijection-safe eviction in both directions — so this delegates rather
607    /// than restating it, exactly as [`InMemoryIndex::set_path`] delegates to
608    /// its own `register`.
609    fn set_path(&mut self, id: &Id, new_path: &Path) {
610        self.register(id, new_path);
611    }
612
613    /// Retire to a tombstone: the ID stops resolving but stays known forever.
614    fn unregister(&mut self, id: &Id) {
615        self.live.unregister(id);
616        self.tombstones.insert(id.clone());
617        self.dirty = true;
618    }
619
620    fn checkpoint(&mut self) {
621        self.saved = Some(Box::new(FileIndexState {
622            live: self.live.clone(),
623            tombstones: self.tombstones.clone(),
624            host_text: self.host_text.clone(),
625            persisted: self.persisted.clone(),
626            has_registry_key: self.has_registry_key,
627            dirty: self.dirty,
628        }));
629    }
630
631    fn rollback(&mut self) {
632        let Some(saved) = self.saved.take() else {
633            return;
634        };
635        let FileIndexState {
636            live,
637            tombstones,
638            host_text,
639            persisted,
640            has_registry_key,
641            dirty,
642        } = *saved;
643        self.live = live;
644        self.tombstones = tombstones;
645        self.host_text = host_text;
646        self.persisted = persisted;
647        self.has_registry_key = has_registry_key;
648        self.dirty = dirty;
649    }
650
651    fn committed(&mut self, persisted: bool) {
652        self.saved = None;
653        if persisted {
654            self.dirty = false;
655        }
656    }
657
658    fn rebase(&mut self, cs: &dyn Rebase) -> Result<()> {
659        let Some(host) = self.host.clone() else {
660            return Ok(());
661        };
662        // Follow a move of the host document to its final path.
663        let dest = cs.renamed_to(&host).unwrap_or(host);
664        // Whatever the set will leave in that document is the text the records
665        // must be spliced into — the op's edit, not the copy read at startup.
666        if let Some(bytes) = cs.staged(&dest) {
667            let text = String::from_utf8(bytes.to_vec()).map_err(|e| {
668                prov_graph::error::Error::Structure(format!(
669                    "{} is not valid UTF-8: {e}",
670                    dest.display()
671                ))
672            })?;
673            return self.set_host(dest, &text);
674        }
675        // Moved but not rewritten: the bytes travelled with the rename, so
676        // `host_text` still describes it and only the path changes.
677        self.host = Some(dest);
678        Ok(())
679    }
680
681    /// The registry's write, rendered against its host document. `None` — and
682    /// crucially *still dirty* — when there is no host to write to.
683    fn pending_write(&mut self) -> Result<Option<(PathBuf, String)>> {
684        if !self.dirty {
685            return Ok(None);
686        }
687        let Some(host) = self.host.clone() else {
688            return Ok(None);
689        };
690        Ok(Some((host, self.render()?)))
691    }
692}
693
694// These engine tests use YAML fixtures throughout, so they run whenever the
695// (default) `yaml` feature is on.
696#[cfg(all(test, feature = "yaml"))]
697mod tests {
698    use super::*;
699
700    #[test]
701    fn set_host_keeps_this_stores_records_and_preserves_the_hosts() {
702        // The bootstrap backstop: an index built with no home (records minted by
703        // fixes) is given one after the fact. Its own records must survive into
704        // the write, and any the host already carried must not be trampled by it.
705        let mut ix = FileIndex::new(fig::Format::Yaml);
706        let mine = Id("mineeee".into());
707        ix.register(&mine, Path::new("fixed.md"));
708
709        // A host that already has a record of its own, plus self-description.
710        let host = "title: ID registry\npart_of: index.md\nregistry:\n  theirss: other.md\n";
711        ix.set_host("registry.yaml", host).unwrap();
712
713        let (path, rendered) = ix
714            .pending_write()
715            .unwrap()
716            .expect("dirty, and now has a home");
717        assert_eq!(path, PathBuf::from("registry.yaml"));
718        assert!(
719            rendered.contains("fixed.md"),
720            "this store's record must land: {rendered}"
721        );
722        assert!(
723            rendered.contains("other.md"),
724            "the host's record must survive: {rendered}"
725        );
726        assert!(
727            rendered.contains("part_of"),
728            "the host's self-description survives: {rendered}"
729        );
730
731        // The host's record was not adopted as live in memory — but the next
732        // parse of what we just wrote reads both, which is what makes that safe.
733        assert_eq!(ix.resolve(&Id("theirss".into())), None);
734        let reread = FileIndex::parse(Path::new("registry.yaml"), &rendered).unwrap();
735        assert_eq!(reread.resolve(&mine), Some(PathBuf::from("fixed.md")));
736        assert_eq!(
737            reread.resolve(&Id("theirss".into())),
738            Some(PathBuf::from("other.md"))
739        );
740    }
741
742    #[test]
743    fn a_store_with_no_host_stays_dirty_rather_than_dropping_records() {
744        // Frontmatter-only workspaces, and the window before a registry is
745        // bootstrapped: nothing to stage, so the caller must still be told there
746        // is something to write.
747        let mut ix = FileIndex::new(fig::Format::Yaml);
748        ix.register(&Id("orphann".into()), Path::new("a.md"));
749        assert_eq!(ix.pending_write().unwrap(), None, "nowhere to write");
750        assert!(ix.is_dirty(), "and so it must not claim to be persisted");
751    }
752
753    #[test]
754    fn registers_and_resolves_both_directions() {
755        let mut ix = InMemoryIndex::new();
756        let id = Id("ajp7eq".into());
757        ix.register(&id, Path::new("notes/a.md"));
758        assert_eq!(ix.resolve(&id), Some(PathBuf::from("notes/a.md")));
759        assert_eq!(ix.id_for_path(Path::new("notes/a.md")), Some(id.clone()));
760        assert_eq!(ix.len(), 1);
761    }
762
763    #[test]
764    fn move_updates_path_and_clears_stale_reverse() {
765        let mut ix = InMemoryIndex::new();
766        let id = Id("ajp7eq".into());
767        ix.register(&id, Path::new("a.md"));
768        ix.set_path(&id, Path::new("moved/a.md"));
769        assert_eq!(ix.resolve(&id), Some(PathBuf::from("moved/a.md")));
770        assert_eq!(ix.id_for_path(Path::new("a.md")), None);
771        assert_eq!(ix.id_for_path(Path::new("moved/a.md")), Some(id));
772    }
773
774    #[test]
775    fn a_displacing_register_leaves_no_stale_entry_in_either_map() {
776        // The index is a bijection, and `register` is the one mutator that used to
777        // be able to break it: displacing in one direction left the other map
778        // pointing at the old counterpart, so the registry claimed two paths for
779        // one id. A caller should refuse the collision up front
780        // (`registration_conflict`); this is what keeps the store coherent when
781        // one slips through anyway.
782        let (a, b) = (Id("aaaaaaa".into()), Id("bbbbbbb".into()));
783
784        // Same id, new path: the path it left must stop claiming it.
785        let mut ix = InMemoryIndex::new();
786        ix.register(&a, Path::new("one.md"));
787        ix.register(&a, Path::new("two.md"));
788        assert_eq!(ix.resolve(&a), Some(PathBuf::from("two.md")));
789        assert_eq!(ix.id_for_path(Path::new("one.md")), None);
790        assert_eq!(ix.len(), 1);
791
792        // Same path, new id: the id it displaced must stop resolving to it.
793        let mut ix = InMemoryIndex::new();
794        ix.register(&a, Path::new("one.md"));
795        ix.register(&b, Path::new("one.md"));
796        assert_eq!(ix.id_for_path(Path::new("one.md")), Some(b.clone()));
797        assert_eq!(ix.resolve(&a), None);
798        assert_eq!(ix.len(), 1);
799
800        // Re-registering the pair already held changes nothing.
801        ix.register(&b, Path::new("one.md"));
802        assert_eq!(ix.resolve(&b), Some(PathBuf::from("one.md")));
803        assert_eq!(ix.id_for_path(Path::new("one.md")), Some(b));
804        assert_eq!(ix.len(), 1);
805    }
806
807    #[test]
808    fn a_displacing_set_path_leaves_no_stale_entry_in_either_map() {
809        // `set_path` used to take only half of `register`'s care: it evicted the
810        // *moving* id's old reverse entry but ignored what `reverse.insert` at the
811        // new path returned, so a displaced id's forward entry survived pointing
812        // at a path it no longer owned — two ids resolving to one path. A caller
813        // should refuse the collision up front (`Workspace::move_conflict`); this
814        // is what keeps the store coherent when one slips through anyway, exactly
815        // as `a_displacing_register_leaves_no_stale_entry_in_either_map` covers
816        // for `register`.
817        let (a, b) = (Id("aaaaaaa".into()), Id("bbbbbbb".into()));
818        let mut ix = InMemoryIndex::new();
819        ix.register(&a, Path::new("one.md"));
820        ix.register(&b, Path::new("two.md"));
821
822        // Move `a` onto `two.md`, which `b` already holds.
823        ix.set_path(&a, Path::new("two.md"));
824
825        assert_eq!(ix.resolve(&a), Some(PathBuf::from("two.md")));
826        assert_eq!(ix.id_for_path(Path::new("two.md")), Some(a));
827        assert_eq!(ix.id_for_path(Path::new("one.md")), None);
828        // `b`'s forward entry must not survive pointing at a path it no longer
829        // owns — the exact break that made `id:b` links resolve to the wrong
830        // document.
831        assert_eq!(ix.resolve(&b), None);
832        assert_eq!(ix.len(), 1);
833    }
834
835    #[test]
836    fn unregister_removes_both_directions() {
837        let mut ix = InMemoryIndex::new();
838        let id = Id("x".into());
839        ix.register(&id, Path::new("a.md"));
840        ix.unregister(&id);
841        assert!(ix.is_empty());
842        assert_eq!(ix.id_for_path(Path::new("a.md")), None);
843    }
844
845    #[test]
846    fn file_index_round_trips_sorted_with_tombstones() {
847        let mut ix = FileIndex::new(fig::Format::Yaml);
848        ix.register(&Id("zzzzzzz".into()), Path::new("z.md"));
849        ix.register(&Id("bcdfghj".into()), Path::new("notes/a.md"));
850        ix.register(&Id("mmmmmmm".into()), Path::new("gone.md"));
851        ix.unregister(&Id("mmmmmmm".into()));
852
853        let text = ix.render().unwrap();
854        // Sorted, one record per line, tombstone as null.
855        let b = text.find("bcdfghj").unwrap();
856        let m = text.find("mmmmmmm").unwrap();
857        let z = text.find("zzzzzzz").unwrap();
858        assert!(b < m && m < z, "{text}");
859        assert!(text.contains("mmmmmmm: null"), "{text}");
860
861        let back = FileIndex::parse(Path::new("registry.yaml"), &text).unwrap();
862        assert_eq!(
863            back.resolve(&Id("bcdfghj".into())),
864            Some(PathBuf::from("notes/a.md"))
865        );
866        assert_eq!(back.resolve(&Id("mmmmmmm".into())), None);
867        assert!(
868            back.is_known(&Id("mmmmmmm".into())),
869            "tombstone survives the round-trip"
870        );
871        assert!(back.is_tombstoned(&Id("mmmmmmm".into())));
872        assert!(!back.is_dirty());
873    }
874
875    #[test]
876    fn registry_host_keeps_its_self_description_and_comments() {
877        // A bare config host with a title, a part_of back to the root, and a
878        // comment: splicing records must leave all of that alone.
879        let host = "# who am I? see title
880title: ID registry
881part_of: index.md
882registry:
883  bcdfghj: a.md
884";
885        let mut ix = FileIndex::parse(Path::new("registry.yaml"), host).unwrap();
886        ix.register(&Id("zzzzzzz".into()), Path::new("z.md"));
887        let out = ix.render().unwrap();
888        assert!(out.contains("# who am I? see title"), "{out}");
889        assert!(out.contains("title: ID registry"), "{out}");
890        assert!(out.contains("part_of: index.md"), "{out}");
891        assert!(out.contains("bcdfghj: a.md"), "{out}");
892        assert!(out.contains("zzzzzzz: z.md"), "{out}");
893    }
894
895    #[test]
896    fn a_markdown_carrier_registry_is_refused() {
897        // A registry is a record store (DESIGN §5, whole-file rule): a markdown
898        // (fenced) carrier has no stable home for prov's sorted records, so it is
899        // rejected at load rather than read.
900        let host = "---
901title: Registry
902part_of: index.md
903registry:
904  bcdfghj: a.md
905---
906# About this file
907
908Prose does not belong in a record store.
909";
910        let err = FileIndex::parse(Path::new("registry.md"), host).unwrap_err();
911        assert!(
912            matches!(err, prov_graph::error::Error::MarkdownStore(_)),
913            "expected MarkdownStore, got {err:?}"
914        );
915    }
916
917    #[test]
918    fn tombstoned_ids_are_never_free_for_reminting() {
919        let mut ix = FileIndex::new(fig::Format::Yaml);
920        let id = Id("bcdfghj".into());
921        ix.register(&id, Path::new("a.md"));
922        ix.unregister(&id);
923        assert_eq!(ix.resolve(&id), None, "does not resolve");
924        assert!(ix.is_known(&id), "but is still known — never reminted");
925    }
926
927    #[test]
928    fn dirty_tracks_mutations() {
929        let mut ix = FileIndex::new(fig::Format::Yaml);
930        assert!(!ix.is_dirty());
931        ix.register(&Id("x".into()), Path::new("a.md"));
932        assert!(ix.is_dirty());
933        ix.mark_clean();
934        assert!(!ix.is_dirty());
935    }
936
937    #[test]
938    fn empty_text_is_an_empty_registry() {
939        let ix = FileIndex::parse(Path::new("registry.yaml"), "").unwrap();
940        assert!(ix.is_empty());
941    }
942
943    #[test]
944    fn a_displaced_id_is_retired_rather_than_forgotten() {
945        // `register` keeps the bijection by evicting whatever it displaced. What
946        // must *not* go with the eviction is the id's existence: `is_known` is
947        // the mint-by-rejection predicate, so forgetting the id would make it
948        // available to be minted again for a different document while the
949        // displaced one still spells it in its own frontmatter — the case
950        // DESIGN §10 answers with "IDs are never reissued".
951        //
952        // Reaching this needs a displacement to slip past `registration_conflict`
953        // / `move_conflict`. That is what those guards are for, and this store is
954        // the last line of defence when one gets through.
955        let mut ix = FileIndex::new(fig::Format::Yaml);
956        let (first, second) = (Id("aaa111a".into()), Id("bbb222b".into()));
957
958        ix.register(&first, Path::new("a.md"));
959        ix.register(&second, Path::new("a.md")); // displaces `first`
960
961        assert_eq!(ix.resolve(&first), None, "evicted from the live map");
962        assert!(ix.is_tombstoned(&first), "and retired on the way out");
963        assert!(ix.is_known(&first), "so it can never be reissued");
964        assert_eq!(ix.resolve(&second), Some(PathBuf::from("a.md")));
965    }
966
967    #[test]
968    fn re_registering_an_id_retires_its_tombstone() {
969        // The `restore`-from-bin path: `recycle` tombstones the id, `restore`
970        // registers it again. A record cannot be live and retired at once, and
971        // `render` has always resolved that in favour of live — so the in-memory
972        // store must agree, or it disagrees with its own serialization until the
973        // process restarts.
974        let mut ix = FileIndex::new(fig::Format::Yaml);
975        let id = Id("ccc333c".into());
976        ix.set_host("registry.yaml", "title: ID registry\n")
977            .unwrap();
978
979        ix.register(&id, Path::new("a.md"));
980        ix.unregister(&id);
981        assert!(ix.is_tombstoned(&id), "retired by the delete");
982
983        ix.register(&id, Path::new("a.md")); // the restore
984        assert_eq!(ix.resolve(&id), Some(PathBuf::from("a.md")), "live again");
985        assert!(!ix.is_tombstoned(&id), "and no longer retired");
986
987        // Which is what the file said all along, so the round trip is lossless.
988        let text = ix.render().unwrap();
989        let reloaded = FileIndex::parse(Path::new("registry.yaml"), &text).unwrap();
990        assert_eq!(reloaded.resolve(&id), Some(PathBuf::from("a.md")));
991        assert!(!reloaded.is_tombstoned(&id));
992    }
993
994    #[test]
995    fn a_restored_id_that_is_later_displaced_is_still_never_reissued() {
996        // The two clauses of `register` in one sequence — and the reason they
997        // had to land together. Un-retiring on registration, without retiring
998        // what a registration displaces, would make this the *easiest* way to
999        // forget an id rather than an impossible one.
1000        let mut ix = FileIndex::new(fig::Format::Yaml);
1001        let (restored, other) = (Id("aaa111a".into()), Id("bbb222b".into()));
1002
1003        ix.register(&restored, Path::new("a.md"));
1004        ix.unregister(&restored); // recycled
1005        ix.register(&restored, Path::new("a.md")); // restored — tombstone cleared
1006        ix.register(&other, Path::new("a.md")); // displaced again
1007
1008        assert!(ix.is_known(&restored), "retired again on the way out");
1009    }
1010
1011    /// Laws over the registry, rather than examples of it.
1012    ///
1013    /// DESIGN §5 singles this store out: the graph and resolution parts of the
1014    /// index are a derived cache, harmless when stale, but `id → path` is
1015    /// *authoritative, non-derivable state* — lose it and no amount of reading
1016    /// the documents puts it back. So the invariant it keeps deserves to be
1017    /// asserted universally rather than witnessed:
1018    ///
1019    /// > **`forward` and `reverse` are two views of one bijection.**
1020    ///
1021    /// [`InMemoryIndex::register`] says as much in its own doc comment, and the
1022    /// history is instructive — 11abd38 fixed a displacement that went unevicted
1023    /// in one direction, leaving an id with a forward entry to a path it no
1024    /// longer owned. That is a two-line slip in a four-line function, invisible
1025    /// to any single example, and it is precisely what a sequence of colliding
1026    /// registrations finds.
1027    ///
1028    /// The generators use **three ids and three paths**. That is the whole
1029    /// design: a small universe makes collision and displacement the common
1030    /// case rather than a rare one, which is where every bug in a bijection
1031    /// lives. A generator drawing fresh ids would exercise the easy path
1032    /// forever.
1033    mod properties {
1034        use super::*;
1035        use proptest::prelude::*;
1036
1037        const IDS: [&str; 3] = ["aaa111a", "bbb222b", "ccc333c"];
1038        const PATHS: [&str; 3] = ["a.md", "b.md", "n/c.md"];
1039
1040        #[derive(Debug, Clone)]
1041        enum Op {
1042            Register { id: usize, path: usize },
1043            SetPath { id: usize, path: usize },
1044            Unregister { id: usize },
1045        }
1046
1047        fn op() -> impl Strategy<Value = Op> {
1048            prop_oneof![
1049                (0..IDS.len(), 0..PATHS.len()).prop_map(|(id, path)| Op::Register { id, path }),
1050                (0..IDS.len(), 0..PATHS.len()).prop_map(|(id, path)| Op::SetPath { id, path }),
1051                (0..IDS.len()).prop_map(|id| Op::Unregister { id }),
1052            ]
1053        }
1054
1055        fn run(ix: &mut impl IndexStore, op: &Op) {
1056            match op {
1057                Op::Register { id, path } => {
1058                    ix.register(&Id(IDS[*id].into()), Path::new(PATHS[*path]))
1059                }
1060                Op::SetPath { id, path } => {
1061                    ix.set_path(&Id(IDS[*id].into()), Path::new(PATHS[*path]))
1062                }
1063                Op::Unregister { id } => ix.unregister(&Id(IDS[*id].into())),
1064            }
1065        }
1066
1067        /// Both directions of the claim. Only ids and paths the sequence names
1068        /// can be in the maps, so checking those is checking all of them.
1069        fn assert_bijection(ix: &impl IndexStore) -> std::result::Result<(), TestCaseError> {
1070            for id in IDS.map(|i| Id(i.into())) {
1071                if let Some(path) = ix.resolve(&id) {
1072                    let back = ix.id_for_path(&path);
1073                    prop_assert_eq!(
1074                        back.as_ref(),
1075                        Some(&id),
1076                        "`{}` resolves to `{}`, which does not point back",
1077                        id,
1078                        path.display()
1079                    );
1080                }
1081            }
1082            for path in PATHS.map(Path::new) {
1083                if let Some(id) = ix.id_for_path(path) {
1084                    let back = ix.resolve(&id);
1085                    prop_assert_eq!(
1086                        back.as_deref(),
1087                        Some(path),
1088                        "`{}` carries `{}`, which does not point back",
1089                        path.display(),
1090                        id
1091                    );
1092                }
1093            }
1094            Ok(())
1095        }
1096
1097        proptest! {
1098            /// The registry never names two paths for one id, or two ids for one
1099            /// path — after *any* sequence of registrations, moves and
1100            /// retirements, however much they displace each other.
1101            #[test]
1102            fn the_id_map_stays_a_bijection(ops in prop::collection::vec(op(), 1..12)) {
1103                let mut ix = InMemoryIndex::new();
1104                for (n, op) in ops.iter().enumerate() {
1105                    run(&mut ix, op);
1106                    assert_bijection(&ix).map_err(|e| {
1107                        TestCaseError::fail(format!("after op {n} ({op:?}) of {ops:?}: {e}"))
1108                    })?;
1109                }
1110            }
1111
1112            /// The same law for the persistent store, which delegates but wraps
1113            /// the delegation in dirty-tracking and tombstones — and inherits
1114            /// nothing automatically just because it forwards today.
1115            #[test]
1116            fn the_persistent_id_map_stays_a_bijection(
1117                ops in prop::collection::vec(op(), 1..12),
1118            ) {
1119                let mut ix = FileIndex::new(fig::Format::Yaml);
1120                for op in &ops {
1121                    run(&mut ix, op);
1122                    assert_bijection(&ix)?;
1123                }
1124            }
1125
1126            /// **A retired id is never forgotten.** Mint-by-rejection depends on
1127            /// it: an id that stops resolving must stay *known*, or a later mint
1128            /// could reissue it and a dangling `id:` reference would quietly
1129            /// change meaning — the difference between "that document was
1130            /// deleted" and "that was never issued here" (DESIGN §10).
1131            #[test]
1132            fn a_retired_id_stays_known_forever(ops in prop::collection::vec(op(), 1..12)) {
1133                let mut ix = FileIndex::new(fig::Format::Yaml);
1134                let mut retired: Vec<Id> = Vec::new();
1135                for op in &ops {
1136                    run(&mut ix, op);
1137                    if let Op::Unregister { id } = op {
1138                        retired.push(Id(IDS[*id].into()));
1139                    }
1140                    for id in &retired {
1141                        prop_assert!(ix.is_known(id), "`{id}` was retired and then forgotten");
1142                    }
1143                }
1144            }
1145
1146            /// **An id that has ever been issued stays known.** DESIGN §10
1147            /// settles the tombstone question with "IDs are never reissued", and
1148            /// `is_known` is the predicate mint-by-rejection asks, so it must be
1149            /// monotonic: once true for an id, true forever.
1150            ///
1151            /// Displacements included: this property found that an evicted id
1152            /// was forgotten rather than tombstoned, and `FileIndex::register`
1153            /// now retires what it displaces, so the law holds unscoped.
1154            #[test]
1155            fn is_known_is_monotonic(ops in prop::collection::vec(op(), 1..12)) {
1156                let mut ix = FileIndex::new(fig::Format::Yaml);
1157                let mut ever = Vec::new();
1158                for (n, op) in ops.iter().enumerate() {
1159                    run(&mut ix, op);
1160                    for id in IDS.map(|i| Id(i.into())) {
1161                        if ix.is_known(&id) && !ever.contains(&id) {
1162                            ever.push(id);
1163                        }
1164                    }
1165                    for id in &ever {
1166                        prop_assert!(
1167                            ix.is_known(id),
1168                            "`{id}` was known and then forgotten at op {n} ({op:?}) of {ops:?}"
1169                        );
1170                    }
1171                }
1172            }
1173
1174            /// **Rollback restores exactly.** `checkpoint`/`rollback` is what
1175            /// lets a failed change set unwind the registry alongside the
1176            /// documents (DESIGN §5: the registry's write rides the same unit).
1177            /// A partial restore would leave the one artifact that cannot be
1178            /// rebuilt disagreeing with the files it describes.
1179            #[test]
1180            fn rollback_restores_the_map_it_checkpointed(
1181                before in prop::collection::vec(op(), 0..6),
1182                after in prop::collection::vec(op(), 1..8),
1183            ) {
1184                let mut ix = InMemoryIndex::new();
1185                for op in &before {
1186                    run(&mut ix, op);
1187                }
1188                let snapshot: Vec<Option<PathBuf>> =
1189                    IDS.map(|i| ix.resolve(&Id(i.into()))).into();
1190
1191                ix.checkpoint();
1192                for op in &after {
1193                    run(&mut ix, op);
1194                }
1195                ix.rollback();
1196
1197                let restored: Vec<Option<PathBuf>> =
1198                    IDS.map(|i| ix.resolve(&Id(i.into()))).into();
1199                prop_assert_eq!(restored, snapshot, "rolled back to a different map");
1200                assert_bijection(&ix)?;
1201            }
1202
1203            /// **A rendered registry reads back as itself.** The store is a real
1204            /// document a user (or a merge) can open, so the text is the durable
1205            /// form — and its records must survive the round trip, tombstones
1206            /// included, since a tombstone that failed to persist would let the
1207            /// id be reissued by the next run.
1208            #[test]
1209            fn a_rendered_registry_parses_back_to_the_same_records(
1210                ops in prop::collection::vec(op(), 1..12),
1211            ) {
1212                let mut ix = FileIndex::new(fig::Format::Yaml);
1213                ix.set_host("registry.yaml", "title: ID registry\n").unwrap();
1214                for op in &ops {
1215                    run(&mut ix, op);
1216                }
1217
1218                let text = ix.render().expect("render");
1219                let reloaded = FileIndex::parse(Path::new("registry.yaml"), &text)
1220                    .expect("prov's own registry must parse");
1221
1222                for id in IDS.map(|i| Id(i.into())) {
1223                    prop_assert_eq!(
1224                        reloaded.resolve(&id),
1225                        ix.resolve(&id),
1226                        "`{}` did not survive the round trip through:\n{}",
1227                        id,
1228                        text
1229                    );
1230                    prop_assert_eq!(
1231                        reloaded.is_tombstoned(&id),
1232                        ix.is_tombstoned(&id),
1233                        "`{}`'s tombstone did not survive:\n{}",
1234                        id,
1235                        text
1236                    );
1237                }
1238                assert_bijection(&reloaded)?;
1239            }
1240        }
1241    }
1242}