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