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 ¤t {
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 ¤t {
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}