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