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//! # The hold: a document's own "not yet"
52//!
53//! A gate says who a document is *for*. It cannot say the document is not
54//! *ready* — and the two are different facts with different lifetimes: an
55//! audience is a durable property of the text, while a draft is a state that
56//! ends. Writing "not yet" by removing the audience conflates them, and loses
57//! the audience in the process, so the format carries the second fact under
58//! its own key: an optional `hold`, naming a field.
59//!
60//! ```yaml
61//! exports:
62//!   letters:
63//!     gate: { field: audience, value: family }
64//!     hold: draft
65//! ```
66//!
67//! A document the gate admits that declares `true` under the hold field
68//! (`draft: true`) is **held**: it is not in the export, and the plan reports
69//! it as held rather than withheld, because the author *did* say it may
70//! leave — only not now. Only the literal `true` holds; `draft: false`, an
71//! absent field, and any other value do not. The name of the field is the
72//! workspace's to choose, since prov has no opinion about what a draft is
73//! called; what it fixes is the shape, which keeps the audit property intact:
74//! *"does this document leave?"* is still answerable from two named fields on
75//! that one document, with no list in the config to consult.
76//!
77//! The hold narrows and never widens, like a view: a document the gate holds
78//! back is withheld whatever its hold field says. And it fails closed like the
79//! gate: a `hold` that does not name a field is an unreadable export, not an
80//! export with no hold, because the author wrote down a bound on what leaves
81//! and a bound nobody can apply must not be dropped silently.
82//!
83//! # Why an export has no front page
84//!
85//! A diaryx site fronts its published set with an `index:` page. That key is
86//! not here: which page greets a reader is a *rendering* decision, and an
87//! export is a set — the OCFL/copy-out consumer has no front page, and a
88//! publish layer that wants one declares it in its own block, where its render
89//! exists. MoReq2010 keeps access control as its own service rather than a
90//! property of the classification scheme for the same reason `prov-views`
91//! keeps classification apart from aggregation: conjoined schemes hybridize.
92
93use prov_graph::meta::{Mapping, Value};
94use prov_views::humanize;
95
96/// The config block exports are declared in — a top-level axis beside
97/// `views:`, so every prov tool reads the same exports rather than each app
98/// namespacing its own.
99pub const EXPORTS_KEY: &str = "exports";
100
101/// The keys valid inside one `exports.<name>` entry.
102pub const EXPORT_KEYS: &[&str] = &["label", "gate", "hold", "view"];
103
104/// The one value that holds a document back under an export's `hold` field —
105/// compared against the field's scalar text, so a YAML `draft: true` and a
106/// TOML `draft = true` both read as it.
107pub const HOLD_VALUE: &str = "true";
108
109/// The keys valid inside a `gate:` mapping.
110pub const GATE_KEYS: &[&str] = &["field", "value"];
111
112/// The membership test an export runs on every reachable document: *does this
113/// document's `field` declare `value`?*
114///
115/// The whole gate is these two strings, and [`admits`](Self::admits) never
116/// reads anything but the one field — that locality is the audit property the
117/// format exists to keep (see the module docs).
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Gate {
120    /// The metadata field the document declares its membership in.
121    pub field: String,
122    /// The value that admits a document, matched exactly after trimming.
123    pub value: String,
124}
125
126impl Gate {
127    /// Read a `gate:` value: a mapping with a non-empty `field` and `value`.
128    ///
129    /// Anything else is `None` — there is no shorthand form and no default.
130    /// A gate that does not say both halves is not a gate, and guessing either
131    /// would admit documents nobody chose.
132    pub fn parse(value: &Value) -> Option<Self> {
133        let map = value.as_mapping()?;
134        Some(Gate {
135            field: non_empty(map.get("field"))?,
136            value: non_empty(map.get("value"))?,
137        })
138    }
139
140    /// The mapping this gate writes back as.
141    pub fn to_value(&self) -> Value {
142        let mut map = Mapping::new();
143        map.insert("field".into(), Value::String(self.field.clone()));
144        map.insert("value".into(), Value::String(self.value.clone()));
145        Value::Mapping(map)
146    }
147
148    /// The values `meta` declares under this gate's field, or `None` when the
149    /// field is absent altogether.
150    ///
151    /// The distinction matters for reporting: `None` is *undeclared* (the
152    /// default state of every document), where `Some(vec![])` is a field that
153    /// is present but empty — declared, and in no export. Both are outside the
154    /// gate; only the second is something the author wrote.
155    ///
156    /// Values are read the way a view groups them: the trimmed text of a
157    /// scalar, or of every scalar in a sequence. A mapping has no single text
158    /// and declares nothing; a nested sequence is not flattened.
159    pub fn declared_in(&self, meta: &Value) -> Option<Vec<String>> {
160        Some(scalar_texts(meta.get(&self.field)?))
161    }
162
163    /// Whether `meta` declares this gate's value — the membership test.
164    ///
165    /// Exact after trim, in both directions; see the module docs for why a
166    /// forgiving match is the fail-open direction.
167    pub fn admits(&self, meta: &Value) -> bool {
168        self.declared_in(meta)
169            .is_some_and(|declared| declared.iter().any(|v| v == self.value.trim()))
170    }
171}
172
173/// One export a workspace declares for itself: a gate that bounds what leaves,
174/// optionally arranged by a view.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct ExportSpec {
177    /// The key under `exports` — the export's public handle (a path segment,
178    /// an OCFL object prefix). Deliberately its own name rather than the gate
179    /// value's: the honest name for a readership is routinely one its members
180    /// should never read off a URL, and separating them lets two gates share
181    /// an arrangement or one gate carry two exports.
182    pub name: String,
183    /// What a person calls it. Absent falls back to the name, humanized.
184    pub label: Option<String>,
185    /// The gate whose admitted set bounds this export. Required: an export
186    /// that does not say what may leave is not an export.
187    pub gate: Gate,
188    /// The field a document declares `true` under to be held back from this
189    /// export even though the gate admits it — `draft`, typically. `None`
190    /// holds nothing back. See the module docs: a hold narrows the gate's set
191    /// and never widens it.
192    pub hold: Option<String>,
193    /// The [`ViewSpec`](prov_views::ViewSpec) naming this export's
194    /// arrangement, by its key under `views:`. `None` exports the gate's whole
195    /// admitted set. A view may narrow the set; it can never widen it — see
196    /// [`plan`](crate::plan()).
197    pub view: Option<String>,
198}
199
200impl ExportSpec {
201    /// Read one `exports.<name>` entry.
202    ///
203    /// Returns `None` when the entry is not a mapping, carries no readable
204    /// [`Gate`], or writes a `hold` that does not name a field. Dropping such
205    /// an entry is the fail-closed direction — an unreadable export
206    /// declaration exports nothing, where defaulting its gate would export a
207    /// set nobody chose, and ignoring its hold would let leave what the
208    /// author wrote down to keep. [`crate::diagnose_export`] is the half that
209    /// says *why*, so a malformed entry is reported rather than merely
210    /// dropped.
211    pub fn parse(name: &str, value: &Value) -> Option<Self> {
212        let map = value.as_mapping()?;
213        let gate = Gate::parse(map.get("gate")?)?;
214        let hold = match map.get("hold") {
215            None => None,
216            Some(field) => Some(non_empty(Some(field))?),
217        };
218        Some(ExportSpec {
219            name: name.to_string(),
220            label: non_empty(map.get("label")),
221            gate,
222            hold,
223            view: non_empty(map.get("view")),
224        })
225    }
226
227    /// Whether `meta` declares [`HOLD_VALUE`] under this export's hold field —
228    /// the "not yet" that keeps a gate-admitted document from leaving.
229    ///
230    /// Always `false` for an export with no hold. Read the way the gate reads
231    /// its own field, so a hold field is a scalar or a sequence of scalars
232    /// and a composite declares nothing.
233    pub fn holds(&self, meta: &Value) -> bool {
234        self.hold.as_ref().is_some_and(|field| {
235            meta.get(field)
236                .is_some_and(|value| scalar_texts(value).iter().any(|t| t == HOLD_VALUE))
237        })
238    }
239
240    /// The mapping this export writes back as. Absent options are omitted
241    /// rather than written empty, so an export declared from an app reads as
242    /// the small thing it is.
243    pub fn to_mapping(&self) -> Mapping {
244        let mut map = Mapping::new();
245        if let Some(label) = &self.label {
246            map.insert("label".into(), Value::String(label.clone()));
247        }
248        map.insert("gate".into(), self.gate.to_value());
249        if let Some(hold) = &self.hold {
250            map.insert("hold".into(), Value::String(hold.clone()));
251        }
252        if let Some(view) = &self.view {
253            map.insert("view".into(), Value::String(view.clone()));
254        }
255        map
256    }
257
258    /// What a person calls this export: its label, else its name humanized.
259    pub fn display_label(&self) -> String {
260        match &self.label {
261            Some(label) => label.clone(),
262            None => humanize(&self.name),
263        }
264    }
265}
266
267/// Read every `exports.<name>` entry out of a config surface's `exports:`
268/// block, in declaration order.
269pub fn exports_from(config: &Mapping) -> Vec<ExportSpec> {
270    let Some(exports) = config.get(EXPORTS_KEY).and_then(Value::as_mapping) else {
271        return Vec::new();
272    };
273    exports
274        .iter()
275        .filter_map(|(name, value)| ExportSpec::parse(name, value))
276        .collect()
277}
278
279/// The trimmed, non-empty text of a scalar, or of every scalar in a sequence —
280/// the same reading `prov-views` groups by, so a field means one thing to a
281/// gate and to a view.
282fn scalar_texts(value: &Value) -> Vec<String> {
283    match value {
284        Value::Sequence(items) => items.iter().filter_map(scalar_text).collect(),
285        other => scalar_text(other).into_iter().collect(),
286    }
287}
288
289/// One scalar's trimmed text, or `None` for a null, an empty string, or a
290/// composite. Numbers and booleans are rendered rather than skipped, exactly
291/// as a view groups them.
292fn scalar_text(value: &Value) -> Option<String> {
293    let text = match value {
294        Value::String(s) => s.trim().to_string(),
295        Value::Int(i) => i.to_string(),
296        Value::Float(f) => f.to_string(),
297        Value::Bool(b) => b.to_string(),
298        Value::Null | Value::Sequence(_) | Value::Mapping(_) => return None,
299    };
300    (!text.is_empty()).then_some(text)
301}
302
303/// A trimmed non-empty string from a config value, or `None`.
304pub(crate) fn non_empty(value: Option<&Value>) -> Option<String> {
305    let text = value?.as_str()?.trim();
306    (!text.is_empty()).then(|| text.to_string())
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    fn mapping(pairs: &[(&str, Value)]) -> Value {
314        let mut map = Mapping::new();
315        for (k, v) in pairs {
316            map.insert((*k).into(), v.clone());
317        }
318        Value::Mapping(map)
319    }
320
321    fn text(pairs: &[(&str, &str)]) -> Value {
322        let owned: Vec<(&str, Value)> = pairs
323            .iter()
324            .map(|(k, v)| (*k, Value::String((*v).to_string())))
325            .collect();
326        mapping(&owned)
327    }
328
329    fn gate(field: &str, value: &str) -> Value {
330        text(&[("field", field), ("value", value)])
331    }
332
333    fn meta(pairs: &[(&str, Value)]) -> Value {
334        mapping(pairs)
335    }
336
337    fn seq(items: &[&str]) -> Value {
338        Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
339    }
340
341    #[test]
342    fn an_export_reads_its_gate_view_and_label() {
343        let spec = ExportSpec::parse(
344            "letters",
345            &mapping(&[
346                ("label", Value::String("Letters home".into())),
347                ("gate", gate("audience", "family")),
348                ("view", Value::String("daily".into())),
349            ]),
350        )
351        .expect("an export");
352        assert_eq!(spec.gate.field, "audience");
353        assert_eq!(spec.gate.value, "family");
354        assert_eq!(spec.view.as_deref(), Some("daily"));
355        assert_eq!(spec.display_label(), "Letters home");
356    }
357
358    /// An entry that never says what may leave is not an export. Recording it
359    /// would put an exportable set in the list with no gate behind it.
360    #[test]
361    fn an_entry_without_a_gate_is_not_an_export() {
362        assert!(ExportSpec::parse("x", &text(&[("view", "daily")])).is_none());
363        assert!(ExportSpec::parse("x", &mapping(&[("gate", gate("audience", "  "))])).is_none());
364        assert!(ExportSpec::parse("x", &mapping(&[("gate", gate("  ", "family"))])).is_none());
365        // There is no shorthand: a bare string names half a gate at best.
366        assert!(
367            ExportSpec::parse("x", &mapping(&[("gate", Value::String("family".into()))])).is_none()
368        );
369        assert!(ExportSpec::parse("x", &Value::String("family".into())).is_none());
370    }
371
372    #[test]
373    fn an_export_round_trips_through_its_mapping() {
374        let spec = ExportSpec {
375            name: "letters".into(),
376            label: Some("Letters home".into()),
377            gate: Gate {
378                field: "audience".into(),
379                value: "family".into(),
380            },
381            hold: Some("draft".into()),
382            view: Some("daily".into()),
383        };
384        let back =
385            ExportSpec::parse("letters", &Value::Mapping(spec.to_mapping())).expect("an export");
386        assert_eq!(back, spec);
387
388        let minimal = ExportSpec {
389            name: "letters".into(),
390            label: None,
391            gate: Gate {
392                field: "audience".into(),
393                value: "family".into(),
394            },
395            hold: None,
396            view: None,
397        };
398        let map = minimal.to_mapping();
399        assert!(map.get("label").is_none(), "absent options are omitted");
400        assert!(map.get("hold").is_none());
401        assert!(map.get("view").is_none());
402        let back = ExportSpec::parse("letters", &Value::Mapping(map)).expect("an export");
403        assert_eq!(back, minimal);
404    }
405
406    #[test]
407    fn a_gate_admits_a_declared_value_scalar_or_sequence() {
408        let g = Gate {
409            field: "audience".into(),
410            value: "family".into(),
411        };
412        assert!(g.admits(&meta(&[("audience", Value::String("family".into()))])));
413        assert!(g.admits(&meta(&[("audience", seq(&["friends", "family"]))])));
414        assert!(!g.admits(&meta(&[("audience", seq(&["friends"]))])));
415    }
416
417    /// Closed by default — the property the whole crate exists for. A document
418    /// that declares nothing is in no export, and a declared-but-empty field
419    /// is equally outside; the two differ only in what a report says.
420    #[test]
421    fn an_undeclared_document_is_admitted_nowhere() {
422        let g = Gate {
423            field: "audience".into(),
424            value: "family".into(),
425        };
426        assert!(!g.admits(&meta(&[])));
427        assert!(!g.admits(&meta(&[("audience", Value::Null)])));
428        assert!(!g.admits(&meta(&[("audience", seq(&[]))])));
429
430        assert_eq!(g.declared_in(&meta(&[])), None, "undeclared");
431        assert_eq!(
432            g.declared_in(&meta(&[("audience", seq(&[]))])),
433            Some(vec![]),
434            "declared but empty — written, and still in no export"
435        );
436    }
437
438    /// Exact after trim. A forgiving match is the fail-open direction: the
439    /// written config would say less than the gate does. Casing drift is the
440    /// vocabulary lint's to report, not the gate's to forgive.
441    #[test]
442    fn matching_is_exact_after_trim() {
443        let g = Gate {
444            field: "audience".into(),
445            value: "family".into(),
446        };
447        assert!(g.admits(&meta(&[("audience", Value::String("  family  ".into()))])));
448        assert!(!g.admits(&meta(&[("audience", Value::String("Family".into()))])));
449        assert!(!g.admits(&meta(&[("audience", Value::String("FAMILY".into()))])));
450    }
451
452    /// A `hold` that does not name a field is not an export — the fail-closed
453    /// reading of a bound somebody wrote and nobody can apply. Absent is the
454    /// ordinary case and holds nothing.
455    #[test]
456    fn a_hold_must_name_a_field_or_the_entry_is_not_an_export() {
457        let with = |hold: Value| mapping(&[("gate", gate("audience", "family")), ("hold", hold)]);
458        assert_eq!(
459            ExportSpec::parse("x", &with(Value::String("draft".into()))).and_then(|s| s.hold),
460            Some("draft".to_string())
461        );
462        assert_eq!(
463            ExportSpec::parse("x", &mapping(&[("gate", gate("audience", "family"))]))
464                .map(|s| s.hold),
465            Some(None),
466            "absent: an export that holds nothing"
467        );
468        for bad in [
469            Value::String("  ".into()),
470            Value::Null,
471            Value::Bool(true),
472            seq(&["draft"]),
473        ] {
474            assert!(
475                ExportSpec::parse("x", &with(bad.clone())).is_none(),
476                "for {bad:?}"
477            );
478        }
479    }
480
481    /// The hold reads its field the way the gate reads its own: the literal
482    /// `true` as a boolean or as text, in a scalar or a sequence, and nothing
483    /// else — so `draft: false` is a document that may leave.
484    #[test]
485    fn a_hold_is_true_and_only_true() {
486        let holding = ExportSpec {
487            name: "letters".into(),
488            label: None,
489            gate: Gate {
490                field: "audience".into(),
491                value: "family".into(),
492            },
493            hold: Some("draft".into()),
494            view: None,
495        };
496        let draft = |v: Value| meta(&[("audience", Value::String("family".into())), ("draft", v)]);
497        assert!(holding.holds(&draft(Value::Bool(true))));
498        assert!(holding.holds(&draft(Value::String("true".into()))));
499        assert!(holding.holds(&draft(seq(&["true"]))));
500        assert!(!holding.holds(&draft(Value::Bool(false))));
501        assert!(!holding.holds(&draft(Value::String("yes".into()))));
502        assert!(!holding.holds(&draft(Value::Null)));
503        assert!(!holding.holds(&draft(meta(&[("since", Value::Bool(true))]))));
504        assert!(!holding.holds(&meta(&[("audience", Value::String("family".into()))])));
505
506        let unholding = ExportSpec {
507            hold: None,
508            ..holding
509        };
510        assert!(
511            !unholding.holds(&draft(Value::Bool(true))),
512            "no hold reads no field"
513        );
514    }
515
516    /// A composite value declares nothing: a mapping has no single text, and a
517    /// nested sequence is a shape no frontmatter field means to write. Skipped
518    /// rather than rendered, so a malformed value cannot spell a gate value by
519    /// accident.
520    #[test]
521    fn a_composite_value_declares_nothing() {
522        let g = Gate {
523            field: "audience".into(),
524            value: "family".into(),
525        };
526        assert!(!g.admits(&meta(&[(
527            "audience",
528            meta(&[("family", Value::Bool(true))])
529        )])));
530        assert!(!g.admits(&meta(&[(
531            "audience",
532            Value::Sequence(vec![seq(&["family"])])
533        )])));
534    }
535
536    /// Values are read the way a view groups them, so a field means one thing
537    /// to a gate and to a view — including the non-string scalars.
538    #[test]
539    fn a_non_string_scalar_is_matched_by_its_text() {
540        let g = Gate {
541            field: "tier".into(),
542            value: "5".into(),
543        };
544        assert!(g.admits(&meta(&[("tier", Value::Int(5))])));
545    }
546
547    #[test]
548    fn exports_read_in_declaration_order() {
549        let mut exports = Mapping::new();
550        exports.insert(
551            "letters".into(),
552            mapping(&[("gate", gate("audience", "family"))]),
553        );
554        exports.insert(
555            "notes".into(),
556            mapping(&[("gate", gate("audience", "public"))]),
557        );
558        let mut config = Mapping::new();
559        config.insert(EXPORTS_KEY.into(), Value::Mapping(exports));
560
561        let specs = exports_from(&config);
562        assert_eq!(
563            specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
564            ["letters", "notes"]
565        );
566    }
567
568    #[test]
569    fn a_label_falls_back_to_the_humanized_name() {
570        let spec = ExportSpec::parse(
571            "letters_home",
572            &mapping(&[("gate", gate("audience", "family"))]),
573        )
574        .expect("an export");
575        assert_eq!(spec.display_label(), "Letters home");
576    }
577}