Skip to main content

prov_exports/
spec.rs

1//! The export format: what a workspace declares under `exports.<name>`.
2//!
3//! # Why an export is not a view
4//!
5//! A view answers *how documents are arranged*; an export answers *which
6//! documents may leave the workspace*. The temptation is to collapse the two —
7//! both select documents by the value of a declared field. Three things stop
8//! it, and they are the reasons this is a crate beside `prov-views` rather
9//! than a `where:` idiom inside it.
10//!
11//! A wrong view is a wrong grouping you fix in the picker; a wrong export is a
12//! file in hands it was never meant for. A view with no `under:` covers the
13//! whole workspace, while a document that declares nothing is in **no**
14//! export — open-by-default against closed-by-default, and one primitive
15//! cannot hold both. And the gate value is written *in the document*, so it
16//! travels with the file and still means what it meant, where view membership
17//! is a property of the workspace and cannot be.
18//!
19//! So a gate is not a *kind* of filter. It is a *position*: the domain every
20//! view runs over once the corpus leaves the workspace. Inside the workspace
21//! the gate field is an ordinary field, and a view can group by it like any
22//! other.
23//!
24//! ```yaml
25//! exports:
26//!   letters:
27//!     label: Letters home
28//!     gate: { field: audience, value: family }
29//!     view: daily
30//! ```
31//!
32//! # One field, one value
33//!
34//! A [`Gate`] is exactly a field name and a value: a document is in the export
35//! iff its own metadata declares that value under that field. Not a list of
36//! values, not a `where:` condition — deliberately. The property that makes an
37//! export auditable is that *"does this document leave?"* is answerable by
38//! reading one field on that one document; a condition makes it a proof about
39//! the pipeline, and an any-of list makes it depend on a set in the config
40//! rather than a value you can grep for. This is the same discipline that
41//! keeps `where:` a closed predicate set and `sort:` unshipped: the shapes a
42//! format admits are its point of no return.
43//!
44//! Matching is **exact after trimming**. `audience: Family` does not pass
45//! `value: family` — admitting it would let the written config say less than
46//! the gate does, which is the fail-open direction. Casing drift between
47//! documents is precisely what a closed vocabulary on the gate field already
48//! diagnoses (`fields.<name>.vocabulary`, checked by `check`), so the typo is
49//! *reported* where a forgiving match would silently forgive it.
50//!
51//! # Why an export has no front page
52//!
53//! A diaryx site fronts its published set with an `index:` page. That key is
54//! not here: which page greets a reader is a *rendering* decision, and an
55//! export is a set — the OCFL/copy-out consumer has no front page, and a
56//! publish layer that wants one declares it in its own block, where its render
57//! exists. MoReq2010 keeps access control as its own service rather than a
58//! property of the classification scheme for the same reason `prov-views`
59//! keeps classification apart from aggregation: conjoined schemes hybridize.
60
61use prov_graph::meta::{Mapping, Value};
62use prov_views::humanize;
63
64/// The config block exports are declared in — a top-level axis beside
65/// `views:`, so every prov tool reads the same exports rather than each app
66/// namespacing its own.
67pub const EXPORTS_KEY: &str = "exports";
68
69/// The keys valid inside one `exports.<name>` entry.
70pub const EXPORT_KEYS: &[&str] = &["label", "gate", "view"];
71
72/// The keys valid inside a `gate:` mapping.
73pub const GATE_KEYS: &[&str] = &["field", "value"];
74
75/// The membership test an export runs on every reachable document: *does this
76/// document's `field` declare `value`?*
77///
78/// The whole gate is these two strings, and [`admits`](Self::admits) never
79/// reads anything but the one field — that locality is the audit property the
80/// format exists to keep (see the module docs).
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Gate {
83    /// The metadata field the document declares its membership in.
84    pub field: String,
85    /// The value that admits a document, matched exactly after trimming.
86    pub value: String,
87}
88
89impl Gate {
90    /// Read a `gate:` value: a mapping with a non-empty `field` and `value`.
91    ///
92    /// Anything else is `None` — there is no shorthand form and no default.
93    /// A gate that does not say both halves is not a gate, and guessing either
94    /// would admit documents nobody chose.
95    pub fn parse(value: &Value) -> Option<Self> {
96        let map = value.as_mapping()?;
97        Some(Gate {
98            field: non_empty(map.get("field"))?,
99            value: non_empty(map.get("value"))?,
100        })
101    }
102
103    /// The mapping this gate writes back as.
104    pub fn to_value(&self) -> Value {
105        let mut map = Mapping::new();
106        map.insert("field".into(), Value::String(self.field.clone()));
107        map.insert("value".into(), Value::String(self.value.clone()));
108        Value::Mapping(map)
109    }
110
111    /// The values `meta` declares under this gate's field, or `None` when the
112    /// field is absent altogether.
113    ///
114    /// The distinction matters for reporting: `None` is *undeclared* (the
115    /// default state of every document), where `Some(vec![])` is a field that
116    /// is present but empty — declared, and in no export. Both are outside the
117    /// gate; only the second is something the author wrote.
118    ///
119    /// Values are read the way a view groups them: the trimmed text of a
120    /// scalar, or of every scalar in a sequence. A mapping has no single text
121    /// and declares nothing; a nested sequence is not flattened.
122    pub fn declared_in(&self, meta: &Value) -> Option<Vec<String>> {
123        Some(scalar_texts(meta.get(&self.field)?))
124    }
125
126    /// Whether `meta` declares this gate's value — the membership test.
127    ///
128    /// Exact after trim, in both directions; see the module docs for why a
129    /// forgiving match is the fail-open direction.
130    pub fn admits(&self, meta: &Value) -> bool {
131        self.declared_in(meta)
132            .is_some_and(|declared| declared.iter().any(|v| v == self.value.trim()))
133    }
134}
135
136/// One export a workspace declares for itself: a gate that bounds what leaves,
137/// optionally arranged by a view.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ExportSpec {
140    /// The key under `exports` — the export's public handle (a path segment,
141    /// an OCFL object prefix). Deliberately its own name rather than the gate
142    /// value's: the honest name for a readership is routinely one its members
143    /// should never read off a URL, and separating them lets two gates share
144    /// an arrangement or one gate carry two exports.
145    pub name: String,
146    /// What a person calls it. Absent falls back to the name, humanized.
147    pub label: Option<String>,
148    /// The gate whose admitted set bounds this export. Required: an export
149    /// that does not say what may leave is not an export.
150    pub gate: Gate,
151    /// The [`ViewSpec`](prov_views::ViewSpec) naming this export's
152    /// arrangement, by its key under `views:`. `None` exports the gate's whole
153    /// admitted set. A view may narrow the set; it can never widen it — see
154    /// [`plan`](crate::plan()).
155    pub view: Option<String>,
156}
157
158impl ExportSpec {
159    /// Read one `exports.<name>` entry.
160    ///
161    /// Returns `None` when the entry is not a mapping or carries no readable
162    /// [`Gate`]. Dropping such an entry is the fail-closed direction — an
163    /// unreadable export declaration exports nothing, where defaulting its
164    /// gate would export a set nobody chose. [`crate::diagnose_export`] is the
165    /// half that says *why*, so a malformed entry is reported rather than
166    /// merely dropped.
167    pub fn parse(name: &str, value: &Value) -> Option<Self> {
168        let map = value.as_mapping()?;
169        let gate = Gate::parse(map.get("gate")?)?;
170        Some(ExportSpec {
171            name: name.to_string(),
172            label: non_empty(map.get("label")),
173            gate,
174            view: non_empty(map.get("view")),
175        })
176    }
177
178    /// The mapping this export writes back as. Absent options are omitted
179    /// rather than written empty, so an export declared from an app reads as
180    /// the small thing it is.
181    pub fn to_mapping(&self) -> Mapping {
182        let mut map = Mapping::new();
183        if let Some(label) = &self.label {
184            map.insert("label".into(), Value::String(label.clone()));
185        }
186        map.insert("gate".into(), self.gate.to_value());
187        if let Some(view) = &self.view {
188            map.insert("view".into(), Value::String(view.clone()));
189        }
190        map
191    }
192
193    /// What a person calls this export: its label, else its name humanized.
194    pub fn display_label(&self) -> String {
195        match &self.label {
196            Some(label) => label.clone(),
197            None => humanize(&self.name),
198        }
199    }
200}
201
202/// Read every `exports.<name>` entry out of a config surface's `exports:`
203/// block, in declaration order.
204pub fn exports_from(config: &Mapping) -> Vec<ExportSpec> {
205    let Some(exports) = config.get(EXPORTS_KEY).and_then(Value::as_mapping) else {
206        return Vec::new();
207    };
208    exports
209        .iter()
210        .filter_map(|(name, value)| ExportSpec::parse(name, value))
211        .collect()
212}
213
214/// The trimmed, non-empty text of a scalar, or of every scalar in a sequence —
215/// the same reading `prov-views` groups by, so a field means one thing to a
216/// gate and to a view.
217fn scalar_texts(value: &Value) -> Vec<String> {
218    match value {
219        Value::Sequence(items) => items.iter().filter_map(scalar_text).collect(),
220        other => scalar_text(other).into_iter().collect(),
221    }
222}
223
224/// One scalar's trimmed text, or `None` for a null, an empty string, or a
225/// composite. Numbers and booleans are rendered rather than skipped, exactly
226/// as a view groups them.
227fn scalar_text(value: &Value) -> Option<String> {
228    let text = match value {
229        Value::String(s) => s.trim().to_string(),
230        Value::Int(i) => i.to_string(),
231        Value::Float(f) => f.to_string(),
232        Value::Bool(b) => b.to_string(),
233        Value::Null | Value::Sequence(_) | Value::Mapping(_) => return None,
234    };
235    (!text.is_empty()).then_some(text)
236}
237
238/// A trimmed non-empty string from a config value, or `None`.
239pub(crate) fn non_empty(value: Option<&Value>) -> Option<String> {
240    let text = value?.as_str()?.trim();
241    (!text.is_empty()).then(|| text.to_string())
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn mapping(pairs: &[(&str, Value)]) -> Value {
249        let mut map = Mapping::new();
250        for (k, v) in pairs {
251            map.insert((*k).into(), v.clone());
252        }
253        Value::Mapping(map)
254    }
255
256    fn text(pairs: &[(&str, &str)]) -> Value {
257        let owned: Vec<(&str, Value)> = pairs
258            .iter()
259            .map(|(k, v)| (*k, Value::String((*v).to_string())))
260            .collect();
261        mapping(&owned)
262    }
263
264    fn gate(field: &str, value: &str) -> Value {
265        text(&[("field", field), ("value", value)])
266    }
267
268    fn meta(pairs: &[(&str, Value)]) -> Value {
269        mapping(pairs)
270    }
271
272    fn seq(items: &[&str]) -> Value {
273        Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
274    }
275
276    #[test]
277    fn an_export_reads_its_gate_view_and_label() {
278        let spec = ExportSpec::parse(
279            "letters",
280            &mapping(&[
281                ("label", Value::String("Letters home".into())),
282                ("gate", gate("audience", "family")),
283                ("view", Value::String("daily".into())),
284            ]),
285        )
286        .expect("an export");
287        assert_eq!(spec.gate.field, "audience");
288        assert_eq!(spec.gate.value, "family");
289        assert_eq!(spec.view.as_deref(), Some("daily"));
290        assert_eq!(spec.display_label(), "Letters home");
291    }
292
293    /// An entry that never says what may leave is not an export. Recording it
294    /// would put an exportable set in the list with no gate behind it.
295    #[test]
296    fn an_entry_without_a_gate_is_not_an_export() {
297        assert!(ExportSpec::parse("x", &text(&[("view", "daily")])).is_none());
298        assert!(ExportSpec::parse("x", &mapping(&[("gate", gate("audience", "  "))])).is_none());
299        assert!(ExportSpec::parse("x", &mapping(&[("gate", gate("  ", "family"))])).is_none());
300        // There is no shorthand: a bare string names half a gate at best.
301        assert!(
302            ExportSpec::parse("x", &mapping(&[("gate", Value::String("family".into()))])).is_none()
303        );
304        assert!(ExportSpec::parse("x", &Value::String("family".into())).is_none());
305    }
306
307    #[test]
308    fn an_export_round_trips_through_its_mapping() {
309        let spec = ExportSpec {
310            name: "letters".into(),
311            label: Some("Letters home".into()),
312            gate: Gate {
313                field: "audience".into(),
314                value: "family".into(),
315            },
316            view: Some("daily".into()),
317        };
318        let back =
319            ExportSpec::parse("letters", &Value::Mapping(spec.to_mapping())).expect("an export");
320        assert_eq!(back, spec);
321
322        let minimal = ExportSpec {
323            name: "letters".into(),
324            label: None,
325            gate: Gate {
326                field: "audience".into(),
327                value: "family".into(),
328            },
329            view: None,
330        };
331        let map = minimal.to_mapping();
332        assert!(map.get("label").is_none(), "absent options are omitted");
333        assert!(map.get("view").is_none());
334        let back = ExportSpec::parse("letters", &Value::Mapping(map)).expect("an export");
335        assert_eq!(back, minimal);
336    }
337
338    #[test]
339    fn a_gate_admits_a_declared_value_scalar_or_sequence() {
340        let g = Gate {
341            field: "audience".into(),
342            value: "family".into(),
343        };
344        assert!(g.admits(&meta(&[("audience", Value::String("family".into()))])));
345        assert!(g.admits(&meta(&[("audience", seq(&["friends", "family"]))])));
346        assert!(!g.admits(&meta(&[("audience", seq(&["friends"]))])));
347    }
348
349    /// Closed by default — the property the whole crate exists for. A document
350    /// that declares nothing is in no export, and a declared-but-empty field
351    /// is equally outside; the two differ only in what a report says.
352    #[test]
353    fn an_undeclared_document_is_admitted_nowhere() {
354        let g = Gate {
355            field: "audience".into(),
356            value: "family".into(),
357        };
358        assert!(!g.admits(&meta(&[])));
359        assert!(!g.admits(&meta(&[("audience", Value::Null)])));
360        assert!(!g.admits(&meta(&[("audience", seq(&[]))])));
361
362        assert_eq!(g.declared_in(&meta(&[])), None, "undeclared");
363        assert_eq!(
364            g.declared_in(&meta(&[("audience", seq(&[]))])),
365            Some(vec![]),
366            "declared but empty — written, and still in no export"
367        );
368    }
369
370    /// Exact after trim. A forgiving match is the fail-open direction: the
371    /// written config would say less than the gate does. Casing drift is the
372    /// vocabulary lint's to report, not the gate's to forgive.
373    #[test]
374    fn matching_is_exact_after_trim() {
375        let g = Gate {
376            field: "audience".into(),
377            value: "family".into(),
378        };
379        assert!(g.admits(&meta(&[("audience", Value::String("  family  ".into()))])));
380        assert!(!g.admits(&meta(&[("audience", Value::String("Family".into()))])));
381        assert!(!g.admits(&meta(&[("audience", Value::String("FAMILY".into()))])));
382    }
383
384    /// A composite value declares nothing: a mapping has no single text, and a
385    /// nested sequence is a shape no frontmatter field means to write. Skipped
386    /// rather than rendered, so a malformed value cannot spell a gate value by
387    /// accident.
388    #[test]
389    fn a_composite_value_declares_nothing() {
390        let g = Gate {
391            field: "audience".into(),
392            value: "family".into(),
393        };
394        assert!(!g.admits(&meta(&[(
395            "audience",
396            meta(&[("family", Value::Bool(true))])
397        )])));
398        assert!(!g.admits(&meta(&[(
399            "audience",
400            Value::Sequence(vec![seq(&["family"])])
401        )])));
402    }
403
404    /// Values are read the way a view groups them, so a field means one thing
405    /// to a gate and to a view — including the non-string scalars.
406    #[test]
407    fn a_non_string_scalar_is_matched_by_its_text() {
408        let g = Gate {
409            field: "tier".into(),
410            value: "5".into(),
411        };
412        assert!(g.admits(&meta(&[("tier", Value::Int(5))])));
413    }
414
415    #[test]
416    fn exports_read_in_declaration_order() {
417        let mut exports = Mapping::new();
418        exports.insert(
419            "letters".into(),
420            mapping(&[("gate", gate("audience", "family"))]),
421        );
422        exports.insert(
423            "notes".into(),
424            mapping(&[("gate", gate("audience", "public"))]),
425        );
426        let mut config = Mapping::new();
427        config.insert(EXPORTS_KEY.into(), Value::Mapping(exports));
428
429        let specs = exports_from(&config);
430        assert_eq!(
431            specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
432            ["letters", "notes"]
433        );
434    }
435
436    #[test]
437    fn a_label_falls_back_to_the_humanized_name() {
438        let spec = ExportSpec::parse(
439            "letters_home",
440            &mapping(&[("gate", gate("audience", "family"))]),
441        )
442        .expect("an export");
443        assert_eq!(spec.display_label(), "Letters home");
444    }
445}