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