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 /// Mark the named relation as the spanning (canonical tree) relation.
112 pub fn spanning(mut self, name: impl Into<String>) -> Self {
113 self.spanning = Some(name.into());
114 self
115 }
116
117 /// Mark the named relation as the **registry pointer**: the root document
118 /// links its ID registry through this relation, which is what makes the
119 /// registry *reachable* — workspace-critical state discovered by following
120 /// links from the root, like everything else, rather than hidden in an
121 /// app-private sidecar folder.
122 pub fn registry(mut self, name: impl Into<String>) -> Self {
123 self.registry = Some(name.into());
124 self
125 }
126
127 /// Mark the named relation as the **config pointer**: the root document links
128 /// its workspace-config document through this relation — the same
129 /// reachability move as the registry (§6), so workspace policy
130 /// (`link_format`, defaults, …) is a self-describing node discovered by
131 /// following links from the root, never an app-private sidecar. The config
132 /// document is optional and lazily created; its absence means all defaults.
133 pub fn config(mut self, name: impl Into<String>) -> Self {
134 self.config = Some(name.into());
135 self
136 }
137
138 /// Mark the named relation as the **recycle-bin pointer**: the root document
139 /// links its recycle-bin index through this relation — the same reachability
140 /// move as the registry and config (§6). A deleted document is not destroyed
141 /// but moved into the bin, and the bin's index (a self-describing member,
142 /// discovered by following this link from the root) records where it came
143 /// from so it can be restored. Making the bin *reachable* is what keeps it
144 /// honest: `check` validates it like any other member, and nothing about a
145 /// deletion is hidden in an app-private folder.
146 pub fn recycle(mut self, name: impl Into<String>) -> Self {
147 self.recycle = Some(name.into());
148 self
149 }
150
151 /// Mark the named relation as the **history pointer**: the root document links
152 /// its history-store index through this relation — the same reachability move
153 /// as the registry, config and recycle bin (§6). The store holds one immutable
154 /// event document per capture plus a content-addressed blob store, so a bad
155 /// sync merge can be rolled back file by file. Making it *reachable* is what
156 /// lets `check` validate it like any other member, and what keeps prov's own
157 /// safety net out of an app-private folder.
158 pub fn history(mut self, name: impl Into<String>) -> Self {
159 self.history = Some(name.into());
160 self
161 }
162
163 /// Mark the named relation as the **about pointer**: the root document links
164 /// its generated `about.md` through this relation — structurally the same
165 /// one-way move as the registry, config, recycle bin and history (§6), but a
166 /// distinct target kind (spec §4, *generated prose*), because the file is
167 /// entirely prose in the workspace's content format rather than a whole-file
168 /// record store.
169 ///
170 /// The pointer exists so *prov* can find the page to regenerate and validate
171 /// it, and so the file is reachable rather than loose in the tree. It is
172 /// deliberately **not** the human reader's way in: a person opening the
173 /// directory finds `about.md` by its name, needing no pointer, no parser and
174 /// no convention beyond being able to read a text file. That is the whole
175 /// point of the artifact, and why the default filename is load-bearing.
176 pub fn about(mut self, name: impl Into<String>) -> Self {
177 self.about = Some(name.into());
178 self
179 }
180
181 /// The diaryx vocabulary: `contents`/`part_of` containment (spanning),
182 /// `links`/`link_of` arbitrary cross-references, `registry` (the root's
183 /// pointer to its ID registry document), `config` (the root's pointer to its
184 /// workspace-config document), `recycle_bin` (the root's pointer to its
185 /// recycle-bin index), `history` (the root's pointer to its history
186 /// store), and `about` (the root's pointer to its generated `about.md`).
187 pub fn diaryx() -> Self {
188 Self::new()
189 .with(Relation::many("contents").inverse("part_of"))
190 .with(Relation::one("part_of").inverse("contents"))
191 .with(Relation::many("links").inverse("link_of"))
192 .with(Relation::many("link_of").inverse("links"))
193 .with(Relation::one("registry"))
194 .with(Relation::one("config"))
195 .with(Relation::one("recycle_bin"))
196 .with(Relation::one("history"))
197 .with(Relation::one("about"))
198 .spanning("contents")
199 .registry("registry")
200 .config("config")
201 .recycle("recycle_bin")
202 .history("history")
203 .about("about")
204 }
205
206 /// The configured relations.
207 pub fn relations(&self) -> &[Relation] {
208 &self.relations
209 }
210
211 /// The per-relation reference style override for `name`, if that relation is
212 /// configured and carries one. `None` means "inherit the workspace default"
213 /// — the caller falls back to its own default style.
214 pub fn style_for(&self, name: &str) -> Option<ReferenceStyle> {
215 self.relations
216 .iter()
217 .find(|r| r.name == name)
218 .and_then(|r| r.style)
219 }
220
221 /// Overlay per-relation reference styles by name (builder-style) — the
222 /// config-driven form of [`Relation::style`]. Each configured relation whose
223 /// name appears in `styles` adopts that style; relations absent from the map
224 /// keep whatever style they already carry (usually none → the workspace
225 /// default). Names in `styles` with no matching relation are ignored. This is
226 /// how a workspace's vocabulary picks up the `relations` block of its config
227 /// document (see `prov`'s `WorkspaceConfig::resolved_relation_styles`).
228 ///
229 /// `prov`'s `WorkspaceConfig::resolved_relation_styles`: `prov`'s `WorkspaceConfig::resolved_relation_styles`
230 pub fn with_styles(
231 mut self,
232 styles: &std::collections::BTreeMap<String, ReferenceStyle>,
233 ) -> Self {
234 for relation in &mut self.relations {
235 if let Some(style) = styles.get(&relation.name) {
236 relation.style = Some(*style);
237 }
238 }
239 self
240 }
241
242 /// The name of the spanning relation, if one is configured.
243 pub fn spanning_relation(&self) -> Option<&str> {
244 self.spanning.as_deref()
245 }
246
247 /// The name of the registry-pointer relation, if one is configured.
248 pub fn registry_relation(&self) -> Option<&str> {
249 self.registry.as_deref()
250 }
251
252 /// The name of the config-pointer relation, if one is configured.
253 pub fn config_relation(&self) -> Option<&str> {
254 self.config.as_deref()
255 }
256
257 /// The name of the recycle-bin-pointer relation, if one is configured.
258 pub fn recycle_relation(&self) -> Option<&str> {
259 self.recycle.as_deref()
260 }
261
262 /// The name of the history-pointer relation, if one is configured.
263 pub fn history_relation(&self) -> Option<&str> {
264 self.history.as_deref()
265 }
266
267 /// The name of the about-pointer relation, if one is configured.
268 pub fn about_relation(&self) -> Option<&str> {
269 self.about.as_deref()
270 }
271
272 /// Extract every link declared by a document's metadata, tagged by relation.
273 pub fn edges(&self, meta: &fig::Value) -> Vec<Edge> {
274 let mut edges = Vec::new();
275 for relation in &self.relations {
276 let Some(value) = meta.get(relation.name.as_str()) else {
277 continue;
278 };
279 for target in crate::meta::link_strings(value) {
280 edges.push(Edge {
281 relation: relation.name.clone(),
282 target,
283 });
284 }
285 }
286 edges
287 }
288
289 /// The raw targets of the spanning relation — i.e. this node's children in
290 /// the canonical tree. Empty if no spanning relation is configured or the
291 /// field is absent.
292 pub fn children(&self, meta: &fig::Value) -> Vec<String> {
293 match self.spanning.as_deref().and_then(|name| meta.get(name)) {
294 Some(value) => crate::meta::link_strings(value),
295 None => Vec::new(),
296 }
297 }
298}
299
300// These tests use YAML frontmatter fixtures, so they run under the `yaml` feature.
301#[cfg(all(test, feature = "yaml"))]
302mod tests {
303 use super::*;
304 use crate::document::Document;
305
306 fn doc(text: &str) -> Document {
307 Document::parse("index.md", text).unwrap()
308 }
309
310 #[test]
311 fn extracts_edges_tagged_by_relation() {
312 let d = doc("---\ncontents:\n- a.md\n- b.md\npart_of: ../root.md\n---\nbody\n");
313 let set = RelationSet::diaryx();
314 let edges = set.edges(&fig::Value::from(&d.meta));
315 assert_eq!(edges.len(), 3);
316 assert!(edges.contains(&Edge {
317 relation: "contents".into(),
318 target: "a.md".into()
319 }));
320 assert!(edges.contains(&Edge {
321 relation: "part_of".into(),
322 target: "../root.md".into()
323 }));
324 }
325
326 #[test]
327 fn children_reads_the_spanning_relation() {
328 let d = doc("---\ncontents:\n- a.md\n- b.md\n---\nbody\n");
329 let set = RelationSet::diaryx();
330 assert_eq!(
331 set.children(&fig::Value::from(&d.meta)),
332 vec!["a.md".to_string(), "b.md".to_string()]
333 );
334 assert_eq!(set.spanning_relation(), Some("contents"));
335 }
336
337 #[test]
338 fn diaryx_declares_registry_config_recycle_history_and_about_pointers() {
339 let set = RelationSet::diaryx();
340 assert_eq!(set.registry_relation(), Some("registry"));
341 assert_eq!(set.config_relation(), Some("config"));
342 assert_eq!(set.recycle_relation(), Some("recycle_bin"));
343 assert_eq!(set.history_relation(), Some("history"));
344 assert_eq!(set.about_relation(), Some("about"));
345 // Each is a single-valued pointer relation in the vocabulary.
346 assert!(set.relations().iter().any(|r| r.name == "config"));
347 assert!(set.relations().iter().any(|r| r.name == "recycle_bin"));
348 assert!(set.relations().iter().any(|r| r.name == "history"));
349 assert!(set.relations().iter().any(|r| r.name == "about"));
350 // `about` is one-way: it declares no inverse, so nothing writes a
351 // back-link into the generated page (spec §4, generated prose).
352 let about = set.relations().iter().find(|r| r.name == "about").unwrap();
353 assert_eq!(about.inverse, None);
354 }
355
356 #[test]
357 fn with_styles_attaches_config_styles_by_name() {
358 use crate::link::{Addressing, LinkStyle, Wrapper};
359 use std::collections::BTreeMap;
360
361 let alias = ReferenceStyle {
362 wrapper: Wrapper::Wikilink,
363 addressing: Addressing::Alias,
364 label: false,
365 path_style: LinkStyle::default(),
366 };
367 let styles = BTreeMap::from([("contents".to_string(), alias)]);
368 let set = RelationSet::diaryx().with_styles(&styles);
369
370 // Named relation adopts the style; unnamed ones stay on the default.
371 assert_eq!(set.style_for("contents"), Some(alias));
372 assert_eq!(set.style_for("part_of"), None);
373 // A name with no matching relation is ignored, not an error.
374 let orphan = BTreeMap::from([("nonexistent".to_string(), alias)]);
375 assert!(
376 RelationSet::diaryx()
377 .with_styles(&orphan)
378 .style_for("contents")
379 .is_none()
380 );
381 }
382
383 #[test]
384 fn custom_vocabulary_is_honored() {
385 // Nothing diaryx-specific: organize by `part` / `whole`.
386 let set = RelationSet::new()
387 .with(Relation::many("part").inverse("whole"))
388 .with(Relation::one("whole").inverse("part"))
389 .spanning("part");
390 let d = doc("---\npart:\n- one.md\n- two.md\n---\nbody\n");
391 assert_eq!(
392 set.children(&fig::Value::from(&d.meta)),
393 vec!["one.md".to_string(), "two.md".to_string()]
394 );
395 }
396}