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