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