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}