prov_graph/relation.rs
1//! Relations — the configurable vocabulary of links declared in metadata.
2//!
3//! prov is opinionated about the *mechanism* (links live in embedded
4//! metadata; one relation is the canonical tree; the rest overlay it) but not
5//! about the *vocabulary*. A [`RelationSet`] names which fields are links, their
6//! cardinality, their inverse, and which single relation is **spanning**.
7
8use crate::link::ReferenceStyle;
9
10/// How many targets a relation field may hold.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Cardinality {
13 /// At most one target (e.g. a single-parent `part_of`).
14 One,
15 /// Any number of targets (e.g. `contents`, `links`).
16 Many,
17}
18
19/// A single named relation: the frontmatter key it reads, its inverse (if the
20/// pair is maintained bidirectionally), and its cardinality.
21#[derive(Debug, Clone)]
22pub struct Relation {
23 /// The frontmatter key this relation reads (e.g. `"contents"`).
24 pub name: String,
25 /// The inverse relation's name, if any (e.g. `contents` ↔ `part_of`).
26 pub inverse: Option<String>,
27 /// How many targets the field may hold.
28 pub cardinality: Cardinality,
29 /// The reference style prov authors *this* relation's links in,
30 /// overriding the workspace default. `None` inherits the default. This is
31 /// what lets links going "down" (`contents`) differ from links going "up"
32 /// (`part_of`) — style is resolved per relation (see
33 /// `docs/reference-styles.md`).
34 pub style: Option<ReferenceStyle>,
35}
36
37impl Relation {
38 /// A single-valued relation (cardinality [`Cardinality::One`]).
39 pub fn one(name: impl Into<String>) -> Self {
40 Self {
41 name: name.into(),
42 inverse: None,
43 cardinality: Cardinality::One,
44 style: None,
45 }
46 }
47
48 /// A multi-valued relation (cardinality [`Cardinality::Many`]).
49 pub fn many(name: impl Into<String>) -> Self {
50 Self {
51 name: name.into(),
52 inverse: None,
53 cardinality: Cardinality::Many,
54 style: None,
55 }
56 }
57
58 /// Declare this relation's inverse (builder-style).
59 pub fn inverse(mut self, name: impl Into<String>) -> Self {
60 self.inverse = Some(name.into());
61 self
62 }
63
64 /// Author this relation's links in a specific reference style, overriding
65 /// the workspace default (builder-style). E.g. `alias` wikilinks going down
66 /// through `contents`, durable `id` links going up through `part_of`.
67 pub fn style(mut self, style: ReferenceStyle) -> Self {
68 self.style = Some(style);
69 self
70 }
71}
72
73/// A resolved link found in a document's metadata: which relation declared it,
74/// where in that relation's value it sits, and the raw (unresolved) target
75/// string.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Edge {
78 /// The relation (frontmatter key) that declared this link.
79 pub relation: String,
80 /// The item's position in the relation's list, counting every item as
81 /// written — a non-string item that yields no edge still takes its place —
82 /// so the number is the one an editor reading the same list would use.
83 /// `None` when the relation is a bare scalar.
84 pub index: Option<usize>,
85 /// The raw target string exactly as written in the metadata.
86 pub target: String,
87}
88
89/// The configured set of relations for a workspace, and which one is spanning.
90///
91/// The **spanning** relation is the single-parent containment tree that gives
92/// the workspace its self-describing discovery spine. All other relations may
93/// be many-to-many overlays.
94#[derive(Debug, Clone, Default)]
95pub struct RelationSet {
96 relations: Vec<Relation>,
97 spanning: Option<String>,
98 registry: Option<String>,
99 config: Option<String>,
100 deletions: Option<String>,
101 recycle: Option<String>,
102 history: Option<String>,
103 about: Option<String>,
104}
105
106impl RelationSet {
107 /// An empty relation set.
108 pub fn new() -> Self {
109 Self::default()
110 }
111
112 /// Add a relation (builder-style).
113 pub fn with(mut self, relation: Relation) -> Self {
114 self.relations.push(relation);
115 self
116 }
117
118 /// Drop the named relation, if present (builder-style) — after this, the
119 /// field is not a link here, so [`edges`](Self::edges) ignores a document key
120 /// by that name and the value reads as ordinary carried content.
121 ///
122 /// The counterpart to [`with`](Self::with), and what makes a preset an
123 /// *overlay base* rather than an all-or-nothing choice: a config that starts
124 /// from [`diaryx`](Self::diaryx) and declares one relation needs a way to
125 /// both redefine a name (remove, then add) and retract one, without
126 /// restating the vocabulary it was otherwise happy with. See
127 /// `WorkspaceConfig::relation_set`.
128 ///
129 /// The **pointer marks** are deliberately untouched: dropping `registry`
130 /// stops it being a relation but leaves `registry_relation()` answering,
131 /// because that pointer is how a reader finds the workspace's machinery at
132 /// all (§6) and is not the vocabulary's to revoke.
133 pub fn without(mut self, name: &str) -> Self {
134 self.relations.retain(|r| r.name != name);
135 self
136 }
137
138 /// Mark the named relation as the spanning (canonical tree) relation.
139 pub fn spanning(mut self, name: impl Into<String>) -> Self {
140 self.spanning = Some(name.into());
141 self
142 }
143
144 /// Mark the named relation as the **registry pointer**: the root document
145 /// links its ID registry through this relation, which is what makes the
146 /// registry *reachable* — workspace-critical state discovered by following
147 /// links from the root, like everything else, rather than hidden in an
148 /// app-private sidecar folder.
149 pub fn registry(mut self, name: impl Into<String>) -> Self {
150 self.registry = Some(name.into());
151 self
152 }
153
154 /// Mark the named relation as the **config pointer**: the root document links
155 /// its workspace-config document through this relation — the same
156 /// reachability move as the registry (§6), so workspace policy
157 /// (`link_format`, defaults, …) is a self-describing node discovered by
158 /// following links from the root, never an app-private sidecar. The config
159 /// document is optional and lazily created; its absence means all defaults.
160 pub fn config(mut self, name: impl Into<String>) -> Self {
161 self.config = Some(name.into());
162 self
163 }
164
165 /// Mark the named relation as the **deletion-log pointer**: the root
166 /// document links its deletion log through this relation — the same
167 /// reachability move as the registry and config (§6). A delete destroys the
168 /// bytes and records what it destroyed: where the document sat, what it was
169 /// called, which id it held, and which parent listed it. That record is what
170 /// [`restore`] repairs the graph from once the bytes are back. Making the
171 /// log *reachable* is what keeps it honest: `check` validates it like any
172 /// other member, and nothing about a deletion is hidden in an app-private
173 /// folder.
174 ///
175 /// [`restore`]: https://docs.rs/prov/latest/prov/struct.Workspace.html#method.restore
176 pub fn deletions(mut self, name: impl Into<String>) -> Self {
177 self.deletions = Some(name.into());
178 self
179 }
180
181 /// Mark the named relation as the **legacy recycle-bin pointer** — the
182 /// spelling [`deletions`](Self::deletions) replaced.
183 ///
184 /// Kept only so a root written before the rename still resolves: the log is
185 /// read through this pointer when the document declares no `deletions`, and
186 /// `check` reports the old spelling as a rename to make. Nothing writes it.
187 /// A workspace that parked bytes under this pointer's `items/` keeps them
188 /// parked out of every walk for as long as it declares it.
189 pub fn recycle(mut self, name: impl Into<String>) -> Self {
190 self.recycle = Some(name.into());
191 self
192 }
193
194 /// Mark the named relation as the **history pointer**: the root document links
195 /// its history-store index through this relation — the same reachability move
196 /// as the registry, config and deletion log (§6). The store holds one immutable
197 /// event document per capture plus a content-addressed blob store, so a bad
198 /// sync merge can be rolled back file by file. Making it *reachable* is what
199 /// lets `check` validate it like any other member, and what keeps prov's own
200 /// safety net out of an app-private folder.
201 pub fn history(mut self, name: impl Into<String>) -> Self {
202 self.history = Some(name.into());
203 self
204 }
205
206 /// Mark the named relation as the **about pointer**: the root document links
207 /// its generated `about.md` through this relation — structurally the same
208 /// one-way move as the registry, config, deletion log and history (§6), but a
209 /// distinct target kind (spec §4, *generated prose*), because the file is
210 /// entirely prose in the workspace's content format rather than a whole-file
211 /// record store.
212 ///
213 /// The pointer exists so *prov* can find the page to regenerate and validate
214 /// it, and so the file is reachable rather than loose in the tree. It is
215 /// deliberately **not** the human reader's way in: a person opening the
216 /// directory finds `about.md` by its name, needing no pointer, no parser and
217 /// no convention beyond being able to read a text file. That is the whole
218 /// point of the artifact, and why the default filename is load-bearing.
219 pub fn about(mut self, name: impl Into<String>) -> Self {
220 self.about = Some(name.into());
221 self
222 }
223
224 /// The diaryx vocabulary: `contents`/`part_of` containment (spanning),
225 /// `links`/`link_of` arbitrary cross-references, `replaces`/`replaced_by`
226 /// succession, `derived_from`/`derivations` origin, `registry` (the root's
227 /// pointer to its ID registry document), `config` (the root's pointer to its
228 /// workspace-config document), `deletions` (the root's pointer to its
229 /// deletion log), `history` (the root's pointer to its history store), and
230 /// `about` (the root's pointer to its generated `about.md`).
231 ///
232 /// `recycle_bin` is here too, and is not one of those. It is the spelling
233 /// `deletions` replaced, kept readable so a root written before the rename
234 /// still resolves — see [`recycle`](Self::recycle).
235 pub fn diaryx() -> Self {
236 Self::new()
237 .with(Relation::many("contents").inverse("part_of"))
238 .with(Relation::one("part_of").inverse("contents"))
239 .with(Relation::many("links").inverse("link_of"))
240 .with(Relation::many("link_of").inverse("links"))
241 .with(Relation::many("replaces").inverse("replaced_by"))
242 .with(Relation::many("replaced_by").inverse("replaces"))
243 .with(Relation::many("derived_from").inverse("derivations"))
244 .with(Relation::many("derivations").inverse("derived_from"))
245 .with(Relation::one("registry"))
246 .with(Relation::one("config"))
247 .with(Relation::one("deletions"))
248 .with(Relation::one("recycle_bin"))
249 .with(Relation::one("history"))
250 .with(Relation::one("about"))
251 .spanning("contents")
252 .registry("registry")
253 .config("config")
254 .deletions("deletions")
255 .recycle("recycle_bin")
256 .history("history")
257 .about("about")
258 }
259
260 /// prov's own human gloss for a [`diaryx`](Self::diaryx) **content**
261 /// relation — what the preset would have written in a `means:` had the
262 /// workspace bothered to declare it. `None` for any other name.
263 ///
264 /// The preset is the base every workspace's vocabulary overlays, so an
265 /// undeclared `contents` is prov's `contents` and its meaning is known here
266 /// rather than being a blank a reader has to guess at. Only the eight
267 /// content relations are glossed: the five pointers are machinery a
268 /// consumer describes in its own words (see `prov`'s about page), not
269 /// vocabulary a reader follows.
270 ///
271 /// Succession and derivation are the keeper's claims, on the footing of
272 /// `author` and `generated`: a rewrite from scratch that supersedes has no
273 /// byte lineage, and an edit that keeps most of the text may be a different
274 /// document, so neither is a version-control tool's to fill in. The pairs
275 /// are the words Dublin Core and PROV-O already have — `replaces` is
276 /// `dcterms:replaces` / `prov:wasRevisionOf`, `derived_from` is
277 /// `dcterms:source` / `prov:wasDerivedFrom` — so an exporter maps them
278 /// without a workspace glossing them first.
279 pub fn diaryx_means(name: &str) -> Option<&'static str> {
280 match name {
281 "contents" => Some("documents contained by this one"),
282 "part_of" => Some("the document that contains this one"),
283 "links" => Some("arbitrary cross-references to other documents"),
284 "link_of" => Some("documents that cross-reference this one"),
285 "replaces" => Some("documents this one supersedes"),
286 "replaced_by" => Some("documents that supersede this one"),
287 "derived_from" => Some("documents this one was made from"),
288 "derivations" => Some("documents made from this one"),
289 _ => None,
290 }
291 }
292
293 /// The configured relations.
294 pub fn relations(&self) -> &[Relation] {
295 &self.relations
296 }
297
298 /// The per-relation reference style override for `name`, if that relation is
299 /// configured and carries one. `None` means "inherit the workspace default"
300 /// — the caller falls back to its own default style.
301 pub fn style_for(&self, name: &str) -> Option<ReferenceStyle> {
302 self.relations
303 .iter()
304 .find(|r| r.name == name)
305 .and_then(|r| r.style)
306 }
307
308 /// Overlay per-relation reference styles by name (builder-style) — the
309 /// config-driven form of [`Relation::style`]. Each configured relation whose
310 /// name appears in `styles` adopts that style; relations absent from the map
311 /// keep whatever style they already carry (usually none → the workspace
312 /// default). Names in `styles` with no matching relation are ignored. This is
313 /// how a workspace's vocabulary picks up the `relations` block of its config
314 /// document (see `prov`'s `WorkspaceConfig::resolved_relation_styles`).
315 ///
316 /// `prov`'s `WorkspaceConfig::resolved_relation_styles`: `prov`'s `WorkspaceConfig::resolved_relation_styles`
317 pub fn with_styles(
318 mut self,
319 styles: &std::collections::BTreeMap<String, ReferenceStyle>,
320 ) -> Self {
321 for relation in &mut self.relations {
322 if let Some(style) = styles.get(&relation.name) {
323 relation.style = Some(*style);
324 }
325 }
326 self
327 }
328
329 /// The name of the spanning relation, if one is configured.
330 pub fn spanning_relation(&self) -> Option<&str> {
331 self.spanning.as_deref()
332 }
333
334 /// The name of the registry-pointer relation, if one is configured.
335 pub fn registry_relation(&self) -> Option<&str> {
336 self.registry.as_deref()
337 }
338
339 /// The name of the config-pointer relation, if one is configured.
340 pub fn config_relation(&self) -> Option<&str> {
341 self.config.as_deref()
342 }
343
344 /// The name of the deletion-log-pointer relation, if one is configured.
345 pub fn deletions_relation(&self) -> Option<&str> {
346 self.deletions.as_deref()
347 }
348
349 /// The name of the **legacy** recycle-bin-pointer relation, if one is
350 /// configured — the spelling [`deletions_relation`](Self::deletions_relation)
351 /// replaced, resolved only when a root declares no `deletions` pointer.
352 pub fn recycle_relation(&self) -> Option<&str> {
353 self.recycle.as_deref()
354 }
355
356 /// The name of the history-pointer relation, if one is configured.
357 pub fn history_relation(&self) -> Option<&str> {
358 self.history.as_deref()
359 }
360
361 /// The name of the about-pointer relation, if one is configured.
362 pub fn about_relation(&self) -> Option<&str> {
363 self.about.as_deref()
364 }
365
366 /// Extract every link declared by a document's metadata, tagged by relation.
367 pub fn edges(&self, meta: &fig::Value) -> Vec<Edge> {
368 let mut edges = Vec::new();
369 for relation in &self.relations {
370 let Some(value) = meta.get(relation.name.as_str()) else {
371 continue;
372 };
373 for (index, target) in crate::meta::indexed_link_strings(value) {
374 edges.push(Edge {
375 relation: relation.name.clone(),
376 index,
377 target,
378 });
379 }
380 }
381 edges
382 }
383
384 /// The raw targets of the spanning relation — i.e. this node's children in
385 /// the canonical tree. Empty if no spanning relation is configured or the
386 /// field is absent.
387 pub fn children(&self, meta: &fig::Value) -> Vec<String> {
388 match self.spanning.as_deref().and_then(|name| meta.get(name)) {
389 Some(value) => crate::meta::link_strings(value),
390 None => Vec::new(),
391 }
392 }
393}
394
395// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
396#[cfg(all(test, feature = "yaml"))]
397mod tests {
398 use super::*;
399 use crate::document::Document;
400
401 fn doc(text: &str) -> Document {
402 Document::parse("index.md", text).unwrap()
403 }
404
405 #[test]
406 fn extracts_edges_tagged_by_relation() {
407 let d = doc("---\ncontents:\n- a.md\n- b.md\npart_of: ../root.md\n---\nbody\n");
408 let set = RelationSet::diaryx();
409 let edges = set.edges(&fig::Value::from(&d.meta));
410 assert_eq!(edges.len(), 3);
411 assert!(edges.contains(&Edge {
412 relation: "contents".into(),
413 index: Some(0),
414 target: "a.md".into()
415 }));
416 assert!(edges.contains(&Edge {
417 relation: "contents".into(),
418 index: Some(1),
419 target: "b.md".into()
420 }));
421 assert!(edges.contains(&Edge {
422 relation: "part_of".into(),
423 index: None,
424 target: "../root.md".into()
425 }));
426 }
427
428 #[test]
429 fn an_edge_keeps_the_position_of_the_item_as_written() {
430 // The middle item is a mapping, not a link: it yields no edge, and the
431 // item after it is still the third — the index an editor sees.
432 let d = doc("---\ncontents:\n- a.md\n- {not: a link}\n- b.md\n---\nbody\n");
433 let set = RelationSet::diaryx();
434 let edges = set.edges(&fig::Value::from(&d.meta));
435 assert_eq!(
436 edges
437 .iter()
438 .map(|e| (e.index, e.target.as_str()))
439 .collect::<Vec<_>>(),
440 vec![(Some(0), "a.md"), (Some(2), "b.md")]
441 );
442 }
443
444 #[test]
445 fn children_reads_the_spanning_relation() {
446 let d = doc("---\ncontents:\n- a.md\n- b.md\n---\nbody\n");
447 let set = RelationSet::diaryx();
448 assert_eq!(
449 set.children(&fig::Value::from(&d.meta)),
450 vec!["a.md".to_string(), "b.md".to_string()]
451 );
452 assert_eq!(set.spanning_relation(), Some("contents"));
453 }
454
455 #[test]
456 fn diaryx_declares_registry_config_deletions_history_and_about_pointers() {
457 let set = RelationSet::diaryx();
458 assert_eq!(set.registry_relation(), Some("registry"));
459 assert_eq!(set.config_relation(), Some("config"));
460 assert_eq!(set.deletions_relation(), Some("deletions"));
461 assert_eq!(set.history_relation(), Some("history"));
462 assert_eq!(set.about_relation(), Some("about"));
463 // The spelling `deletions` replaced, still resolvable so a root written
464 // before the rename keeps working.
465 assert_eq!(set.recycle_relation(), Some("recycle_bin"));
466 // Each is a single-valued pointer relation in the vocabulary.
467 assert!(set.relations().iter().any(|r| r.name == "config"));
468 assert!(set.relations().iter().any(|r| r.name == "deletions"));
469 assert!(set.relations().iter().any(|r| r.name == "recycle_bin"));
470 assert!(set.relations().iter().any(|r| r.name == "history"));
471 assert!(set.relations().iter().any(|r| r.name == "about"));
472 // `about` is one-way: it declares no inverse, so nothing writes a
473 // back-link into the generated page (spec §4, generated prose).
474 let about = set.relations().iter().find(|r| r.name == "about").unwrap();
475 assert_eq!(about.inverse, None);
476 }
477
478 #[test]
479 fn without_drops_the_relation_but_never_the_pointer_mark() {
480 let d = doc("---\nlinks:\n- a.md\nregistry: registry.yaml\n---\nbody\n");
481 let set = RelationSet::diaryx().without("links").without("registry");
482
483 // Neither key is a link any more, so both read as ordinary carried
484 // content — that is what retracting a relation means.
485 assert!(set.edges(&fig::Value::from(&d.meta)).is_empty());
486 assert!(!set.relations().iter().any(|r| r.name == "links"));
487 // …but the registry is still findable, because the pointer is how a
488 // reader reaches the workspace's machinery at all.
489 assert_eq!(set.registry_relation(), Some("registry"));
490 // Removing a name the set does not have is a no-op, not a panic.
491 let untouched = RelationSet::diaryx().without("nonexistent");
492 assert_eq!(untouched.relations().len(), 14);
493 }
494
495 #[test]
496 fn diaryx_means_glosses_the_content_relations_only() {
497 assert_eq!(
498 RelationSet::diaryx_means("part_of"),
499 Some("the document that contains this one")
500 );
501 // The pointers are machinery a consumer words for itself, and an
502 // unknown name is not the preset's to describe.
503 assert_eq!(RelationSet::diaryx_means("registry"), None);
504 assert_eq!(RelationSet::diaryx_means("sections"), None);
505 // Every glossed name is in fact a relation the preset declares.
506 let set = RelationSet::diaryx();
507 for name in [
508 "contents",
509 "part_of",
510 "links",
511 "link_of",
512 "replaces",
513 "replaced_by",
514 "derived_from",
515 "derivations",
516 ] {
517 assert!(RelationSet::diaryx_means(name).is_some(), "{name}");
518 assert!(set.relations().iter().any(|r| r.name == name), "{name}");
519 }
520 }
521
522 #[test]
523 fn succession_and_derivation_are_many_to_many_overlay_pairs() {
524 // A document may replace several and be derived from several, and
525 // neither pair is the spine — so both halves are `many`, each names
526 // the other as inverse, and `contents` is still the only spanning
527 // relation.
528 let set = RelationSet::diaryx();
529 for (name, inverse) in [
530 ("replaces", "replaced_by"),
531 ("replaced_by", "replaces"),
532 ("derived_from", "derivations"),
533 ("derivations", "derived_from"),
534 ] {
535 let rel = set
536 .relations()
537 .iter()
538 .find(|r| r.name == name)
539 .unwrap_or_else(|| panic!("{name} is in the base vocabulary"));
540 assert_eq!(rel.cardinality, Cardinality::Many, "{name}");
541 assert_eq!(rel.inverse.as_deref(), Some(inverse), "{name}");
542 }
543 assert_eq!(set.spanning_relation(), Some("contents"));
544 }
545
546 #[test]
547 fn with_styles_attaches_config_styles_by_name() {
548 use crate::link::{Addressing, LinkStyle, Wrapper};
549 use std::collections::BTreeMap;
550
551 let alias = ReferenceStyle {
552 wrapper: Wrapper::Wikilink,
553 addressing: Addressing::Alias,
554 label: false,
555 path_style: LinkStyle::default(),
556 };
557 let styles = BTreeMap::from([("contents".to_string(), alias)]);
558 let set = RelationSet::diaryx().with_styles(&styles);
559
560 // Named relation adopts the style; unnamed ones stay on the default.
561 assert_eq!(set.style_for("contents"), Some(alias));
562 assert_eq!(set.style_for("part_of"), None);
563 // A name with no matching relation is ignored, not an error.
564 let orphan = BTreeMap::from([("nonexistent".to_string(), alias)]);
565 assert!(
566 RelationSet::diaryx()
567 .with_styles(&orphan)
568 .style_for("contents")
569 .is_none()
570 );
571 }
572
573 #[test]
574 fn custom_vocabulary_is_honored() {
575 // Nothing diaryx-specific: organize by `part` / `whole`.
576 let set = RelationSet::new()
577 .with(Relation::many("part").inverse("whole"))
578 .with(Relation::one("whole").inverse("part"))
579 .spanning("part");
580 let d = doc("---\npart:\n- one.md\n- two.md\n---\nbody\n");
581 assert_eq!(
582 set.children(&fig::Value::from(&d.meta)),
583 vec!["one.md".to_string(), "two.md".to_string()]
584 );
585 }
586}