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/// and the raw (unresolved) target string.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Edge {
77 /// The relation (frontmatter key) that declared this link.
78 pub relation: String,
79 /// The raw target string exactly as written in the metadata.
80 pub target: String,
81}
82
83/// The configured set of relations for a workspace, and which one is spanning.
84///
85/// The **spanning** relation is the single-parent containment tree that gives
86/// the workspace its self-describing discovery spine. All other relations may
87/// be many-to-many overlays.
88#[derive(Debug, Clone, Default)]
89pub struct RelationSet {
90 relations: Vec<Relation>,
91 spanning: Option<String>,
92 registry: Option<String>,
93 config: Option<String>,
94 deletions: Option<String>,
95 recycle: Option<String>,
96 history: Option<String>,
97 about: Option<String>,
98}
99
100impl RelationSet {
101 /// An empty relation set.
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 /// Add a relation (builder-style).
107 pub fn with(mut self, relation: Relation) -> Self {
108 self.relations.push(relation);
109 self
110 }
111
112 /// Drop the named relation, if present (builder-style) — after this, the
113 /// field is not a link here, so [`edges`](Self::edges) ignores a document key
114 /// by that name and the value reads as ordinary carried content.
115 ///
116 /// The counterpart to [`with`](Self::with), and what makes a preset an
117 /// *overlay base* rather than an all-or-nothing choice: a config that starts
118 /// from [`diaryx`](Self::diaryx) and declares one relation needs a way to
119 /// both redefine a name (remove, then add) and retract one, without
120 /// restating the vocabulary it was otherwise happy with. See
121 /// `WorkspaceConfig::relation_set`.
122 ///
123 /// The **pointer marks** are deliberately untouched: dropping `registry`
124 /// stops it being a relation but leaves `registry_relation()` answering,
125 /// because that pointer is how a reader finds the workspace's machinery at
126 /// all (§6) and is not the vocabulary's to revoke.
127 pub fn without(mut self, name: &str) -> Self {
128 self.relations.retain(|r| r.name != name);
129 self
130 }
131
132 /// Mark the named relation as the spanning (canonical tree) relation.
133 pub fn spanning(mut self, name: impl Into<String>) -> Self {
134 self.spanning = Some(name.into());
135 self
136 }
137
138 /// Mark the named relation as the **registry pointer**: the root document
139 /// links its ID registry through this relation, which is what makes the
140 /// registry *reachable* — workspace-critical state discovered by following
141 /// links from the root, like everything else, rather than hidden in an
142 /// app-private sidecar folder.
143 pub fn registry(mut self, name: impl Into<String>) -> Self {
144 self.registry = Some(name.into());
145 self
146 }
147
148 /// Mark the named relation as the **config pointer**: the root document links
149 /// its workspace-config document through this relation — the same
150 /// reachability move as the registry (§6), so workspace policy
151 /// (`link_format`, defaults, …) is a self-describing node discovered by
152 /// following links from the root, never an app-private sidecar. The config
153 /// document is optional and lazily created; its absence means all defaults.
154 pub fn config(mut self, name: impl Into<String>) -> Self {
155 self.config = Some(name.into());
156 self
157 }
158
159 /// Mark the named relation as the **deletion-log pointer**: the root
160 /// document links its deletion log through this relation — the same
161 /// reachability move as the registry and config (§6). A delete destroys the
162 /// bytes and records what it destroyed: where the document sat, what it was
163 /// called, which id it held, and which parent listed it. That record is what
164 /// [`restore`] repairs the graph from once the bytes are back. Making the
165 /// log *reachable* is what keeps it honest: `check` validates it like any
166 /// other member, and nothing about a deletion is hidden in an app-private
167 /// folder.
168 ///
169 /// [`restore`]: https://docs.rs/prov/latest/prov/struct.Workspace.html#method.restore
170 pub fn deletions(mut self, name: impl Into<String>) -> Self {
171 self.deletions = Some(name.into());
172 self
173 }
174
175 /// Mark the named relation as the **legacy recycle-bin pointer** — the
176 /// spelling [`deletions`](Self::deletions) replaced.
177 ///
178 /// Kept only so a root written before the rename still resolves: the log is
179 /// read through this pointer when the document declares no `deletions`, and
180 /// `check` reports the old spelling as a rename to make. Nothing writes it.
181 /// A workspace that parked bytes under this pointer's `items/` keeps them
182 /// parked out of every walk for as long as it declares it.
183 pub fn recycle(mut self, name: impl Into<String>) -> Self {
184 self.recycle = Some(name.into());
185 self
186 }
187
188 /// Mark the named relation as the **history pointer**: the root document links
189 /// its history-store index through this relation — the same reachability move
190 /// as the registry, config and deletion log (§6). The store holds one immutable
191 /// event document per capture plus a content-addressed blob store, so a bad
192 /// sync merge can be rolled back file by file. Making it *reachable* is what
193 /// lets `check` validate it like any other member, and what keeps prov's own
194 /// safety net out of an app-private folder.
195 pub fn history(mut self, name: impl Into<String>) -> Self {
196 self.history = Some(name.into());
197 self
198 }
199
200 /// Mark the named relation as the **about pointer**: the root document links
201 /// its generated `about.md` through this relation — structurally the same
202 /// one-way move as the registry, config, deletion log and history (§6), but a
203 /// distinct target kind (spec §4, *generated prose*), because the file is
204 /// entirely prose in the workspace's content format rather than a whole-file
205 /// record store.
206 ///
207 /// The pointer exists so *prov* can find the page to regenerate and validate
208 /// it, and so the file is reachable rather than loose in the tree. It is
209 /// deliberately **not** the human reader's way in: a person opening the
210 /// directory finds `about.md` by its name, needing no pointer, no parser and
211 /// no convention beyond being able to read a text file. That is the whole
212 /// point of the artifact, and why the default filename is load-bearing.
213 pub fn about(mut self, name: impl Into<String>) -> Self {
214 self.about = Some(name.into());
215 self
216 }
217
218 /// The diaryx vocabulary: `contents`/`part_of` containment (spanning),
219 /// `links`/`link_of` arbitrary cross-references, `registry` (the root's
220 /// pointer to its ID registry document), `config` (the root's pointer to its
221 /// workspace-config document), `deletions` (the root's pointer to its
222 /// deletion log), `history` (the root's pointer to its history store), and
223 /// `about` (the root's pointer to its generated `about.md`).
224 ///
225 /// `recycle_bin` is here too, and is not one of those. It is the spelling
226 /// `deletions` replaced, kept readable so a root written before the rename
227 /// still resolves — see [`recycle`](Self::recycle).
228 pub fn diaryx() -> Self {
229 Self::new()
230 .with(Relation::many("contents").inverse("part_of"))
231 .with(Relation::one("part_of").inverse("contents"))
232 .with(Relation::many("links").inverse("link_of"))
233 .with(Relation::many("link_of").inverse("links"))
234 .with(Relation::one("registry"))
235 .with(Relation::one("config"))
236 .with(Relation::one("deletions"))
237 .with(Relation::one("recycle_bin"))
238 .with(Relation::one("history"))
239 .with(Relation::one("about"))
240 .spanning("contents")
241 .registry("registry")
242 .config("config")
243 .deletions("deletions")
244 .recycle("recycle_bin")
245 .history("history")
246 .about("about")
247 }
248
249 /// prov's own human gloss for a [`diaryx`](Self::diaryx) **content**
250 /// relation — what the preset would have written in a `means:` had the
251 /// workspace bothered to declare it. `None` for any other name.
252 ///
253 /// The preset is the base every workspace's vocabulary overlays, so an
254 /// undeclared `contents` is prov's `contents` and its meaning is known here
255 /// rather than being a blank a reader has to guess at. Only the four content
256 /// relations are glossed: the five pointers are machinery a consumer
257 /// describes in its own words (see `prov`'s about page), not vocabulary a
258 /// reader follows.
259 pub fn diaryx_means(name: &str) -> Option<&'static str> {
260 match name {
261 "contents" => Some("documents contained by this one"),
262 "part_of" => Some("the document that contains this one"),
263 "links" => Some("arbitrary cross-references to other documents"),
264 "link_of" => Some("documents that cross-reference this one"),
265 _ => None,
266 }
267 }
268
269 /// The configured relations.
270 pub fn relations(&self) -> &[Relation] {
271 &self.relations
272 }
273
274 /// The per-relation reference style override for `name`, if that relation is
275 /// configured and carries one. `None` means "inherit the workspace default"
276 /// — the caller falls back to its own default style.
277 pub fn style_for(&self, name: &str) -> Option<ReferenceStyle> {
278 self.relations
279 .iter()
280 .find(|r| r.name == name)
281 .and_then(|r| r.style)
282 }
283
284 /// Overlay per-relation reference styles by name (builder-style) — the
285 /// config-driven form of [`Relation::style`]. Each configured relation whose
286 /// name appears in `styles` adopts that style; relations absent from the map
287 /// keep whatever style they already carry (usually none → the workspace
288 /// default). Names in `styles` with no matching relation are ignored. This is
289 /// how a workspace's vocabulary picks up the `relations` block of its config
290 /// document (see `prov`'s `WorkspaceConfig::resolved_relation_styles`).
291 ///
292 /// `prov`'s `WorkspaceConfig::resolved_relation_styles`: `prov`'s `WorkspaceConfig::resolved_relation_styles`
293 pub fn with_styles(
294 mut self,
295 styles: &std::collections::BTreeMap<String, ReferenceStyle>,
296 ) -> Self {
297 for relation in &mut self.relations {
298 if let Some(style) = styles.get(&relation.name) {
299 relation.style = Some(*style);
300 }
301 }
302 self
303 }
304
305 /// The name of the spanning relation, if one is configured.
306 pub fn spanning_relation(&self) -> Option<&str> {
307 self.spanning.as_deref()
308 }
309
310 /// The name of the registry-pointer relation, if one is configured.
311 pub fn registry_relation(&self) -> Option<&str> {
312 self.registry.as_deref()
313 }
314
315 /// The name of the config-pointer relation, if one is configured.
316 pub fn config_relation(&self) -> Option<&str> {
317 self.config.as_deref()
318 }
319
320 /// The name of the deletion-log-pointer relation, if one is configured.
321 pub fn deletions_relation(&self) -> Option<&str> {
322 self.deletions.as_deref()
323 }
324
325 /// The name of the **legacy** recycle-bin-pointer relation, if one is
326 /// configured — the spelling [`deletions_relation`](Self::deletions_relation)
327 /// replaced, resolved only when a root declares no `deletions` pointer.
328 pub fn recycle_relation(&self) -> Option<&str> {
329 self.recycle.as_deref()
330 }
331
332 /// The name of the history-pointer relation, if one is configured.
333 pub fn history_relation(&self) -> Option<&str> {
334 self.history.as_deref()
335 }
336
337 /// The name of the about-pointer relation, if one is configured.
338 pub fn about_relation(&self) -> Option<&str> {
339 self.about.as_deref()
340 }
341
342 /// Extract every link declared by a document's metadata, tagged by relation.
343 pub fn edges(&self, meta: &fig::Value) -> Vec<Edge> {
344 let mut edges = Vec::new();
345 for relation in &self.relations {
346 let Some(value) = meta.get(relation.name.as_str()) else {
347 continue;
348 };
349 for target in crate::meta::link_strings(value) {
350 edges.push(Edge {
351 relation: relation.name.clone(),
352 target,
353 });
354 }
355 }
356 edges
357 }
358
359 /// The raw targets of the spanning relation — i.e. this node's children in
360 /// the canonical tree. Empty if no spanning relation is configured or the
361 /// field is absent.
362 pub fn children(&self, meta: &fig::Value) -> Vec<String> {
363 match self.spanning.as_deref().and_then(|name| meta.get(name)) {
364 Some(value) => crate::meta::link_strings(value),
365 None => Vec::new(),
366 }
367 }
368}
369
370// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
371#[cfg(all(test, feature = "yaml"))]
372mod tests {
373 use super::*;
374 use crate::document::Document;
375
376 fn doc(text: &str) -> Document {
377 Document::parse("index.md", text).unwrap()
378 }
379
380 #[test]
381 fn extracts_edges_tagged_by_relation() {
382 let d = doc("---\ncontents:\n- a.md\n- b.md\npart_of: ../root.md\n---\nbody\n");
383 let set = RelationSet::diaryx();
384 let edges = set.edges(&fig::Value::from(&d.meta));
385 assert_eq!(edges.len(), 3);
386 assert!(edges.contains(&Edge {
387 relation: "contents".into(),
388 target: "a.md".into()
389 }));
390 assert!(edges.contains(&Edge {
391 relation: "part_of".into(),
392 target: "../root.md".into()
393 }));
394 }
395
396 #[test]
397 fn children_reads_the_spanning_relation() {
398 let d = doc("---\ncontents:\n- a.md\n- b.md\n---\nbody\n");
399 let set = RelationSet::diaryx();
400 assert_eq!(
401 set.children(&fig::Value::from(&d.meta)),
402 vec!["a.md".to_string(), "b.md".to_string()]
403 );
404 assert_eq!(set.spanning_relation(), Some("contents"));
405 }
406
407 #[test]
408 fn diaryx_declares_registry_config_deletions_history_and_about_pointers() {
409 let set = RelationSet::diaryx();
410 assert_eq!(set.registry_relation(), Some("registry"));
411 assert_eq!(set.config_relation(), Some("config"));
412 assert_eq!(set.deletions_relation(), Some("deletions"));
413 assert_eq!(set.history_relation(), Some("history"));
414 assert_eq!(set.about_relation(), Some("about"));
415 // The spelling `deletions` replaced, still resolvable so a root written
416 // before the rename keeps working.
417 assert_eq!(set.recycle_relation(), Some("recycle_bin"));
418 // Each is a single-valued pointer relation in the vocabulary.
419 assert!(set.relations().iter().any(|r| r.name == "config"));
420 assert!(set.relations().iter().any(|r| r.name == "deletions"));
421 assert!(set.relations().iter().any(|r| r.name == "recycle_bin"));
422 assert!(set.relations().iter().any(|r| r.name == "history"));
423 assert!(set.relations().iter().any(|r| r.name == "about"));
424 // `about` is one-way: it declares no inverse, so nothing writes a
425 // back-link into the generated page (spec §4, generated prose).
426 let about = set.relations().iter().find(|r| r.name == "about").unwrap();
427 assert_eq!(about.inverse, None);
428 }
429
430 #[test]
431 fn without_drops_the_relation_but_never_the_pointer_mark() {
432 let d = doc("---\nlinks:\n- a.md\nregistry: registry.yaml\n---\nbody\n");
433 let set = RelationSet::diaryx().without("links").without("registry");
434
435 // Neither key is a link any more, so both read as ordinary carried
436 // content — that is what retracting a relation means.
437 assert!(set.edges(&fig::Value::from(&d.meta)).is_empty());
438 assert!(!set.relations().iter().any(|r| r.name == "links"));
439 // …but the registry is still findable, because the pointer is how a
440 // reader reaches the workspace's machinery at all.
441 assert_eq!(set.registry_relation(), Some("registry"));
442 // Removing a name the set does not have is a no-op, not a panic.
443 let untouched = RelationSet::diaryx().without("nonexistent");
444 assert_eq!(untouched.relations().len(), 10);
445 }
446
447 #[test]
448 fn diaryx_means_glosses_the_content_relations_only() {
449 assert_eq!(
450 RelationSet::diaryx_means("part_of"),
451 Some("the document that contains this one")
452 );
453 // The pointers are machinery a consumer words for itself, and an
454 // unknown name is not the preset's to describe.
455 assert_eq!(RelationSet::diaryx_means("registry"), None);
456 assert_eq!(RelationSet::diaryx_means("sections"), None);
457 // Every glossed name is in fact a relation the preset declares.
458 let set = RelationSet::diaryx();
459 for name in ["contents", "part_of", "links", "link_of"] {
460 assert!(RelationSet::diaryx_means(name).is_some(), "{name}");
461 assert!(set.relations().iter().any(|r| r.name == name), "{name}");
462 }
463 }
464
465 #[test]
466 fn with_styles_attaches_config_styles_by_name() {
467 use crate::link::{Addressing, LinkStyle, Wrapper};
468 use std::collections::BTreeMap;
469
470 let alias = ReferenceStyle {
471 wrapper: Wrapper::Wikilink,
472 addressing: Addressing::Alias,
473 label: false,
474 path_style: LinkStyle::default(),
475 };
476 let styles = BTreeMap::from([("contents".to_string(), alias)]);
477 let set = RelationSet::diaryx().with_styles(&styles);
478
479 // Named relation adopts the style; unnamed ones stay on the default.
480 assert_eq!(set.style_for("contents"), Some(alias));
481 assert_eq!(set.style_for("part_of"), None);
482 // A name with no matching relation is ignored, not an error.
483 let orphan = BTreeMap::from([("nonexistent".to_string(), alias)]);
484 assert!(
485 RelationSet::diaryx()
486 .with_styles(&orphan)
487 .style_for("contents")
488 .is_none()
489 );
490 }
491
492 #[test]
493 fn custom_vocabulary_is_honored() {
494 // Nothing diaryx-specific: organize by `part` / `whole`.
495 let set = RelationSet::new()
496 .with(Relation::many("part").inverse("whole"))
497 .with(Relation::one("whole").inverse("part"))
498 .spanning("part");
499 let d = doc("---\npart:\n- one.md\n- two.md\n---\nbody\n");
500 assert_eq!(
501 set.children(&fig::Value::from(&d.meta)),
502 vec!["one.md".to_string(), "two.md".to_string()]
503 );
504 }
505}