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/// 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}