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}