Skip to main content

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}