provui_core/schema.rs
1//! The prov → flower schema adapter, for a workspace's *content* documents.
2//!
3//! prov detects a workspace's controlled vocabularies and relations by resolving
4//! its config; flower renders and validates. This module is the seam between them:
5//! it turns a resolved [`WorkspaceConfig`] (plus the vocabularies its controlled
6//! fields point at) into a generic [`flower_core::Schema`]. flower-core never
7//! learns the word "prov"; this crate owns the translation.
8//!
9//! - Each `fields.<name>` → a rule carrying its declared type. A field that also
10//! names a vocabulary gets a [`Constraint::Enum`]; one that only declares a
11//! type gets a typed rule with no constraint — enough for the editor to render
12//! the right widget (a `date` field gets a date picker) without claiming any
13//! value is illegal. Both a scalar-at-key rule and an each-item rule are
14//! emitted, so the field is governed whether written as a single value
15//! (`audience: public`) or a list (`audience: [public, private]`).
16//! - Each relation → a [`Constraint::Reference`]; the spanning relation is
17//! flagged `spanning: true`, a many-relation also governs each item (a list of
18//! links). The relation's `means:` gloss travels with it, so a row can say
19//! what `part_of` is for rather than leaving a reader to infer it.
20//! - prov's own **kernel** keys → [`kernel_rules`]. `title`, `id`, the
21//! opaque-payload axis and the root's inline `prov:` policy block are prov
22//! vocabulary exactly as much as `contents` is, and a document schema that
23//! governed only the *declared* fields would leave the keys prov always reads
24//! as untyped text boxes. They come **last**, so a workspace that declares a
25//! field of the same name shadows them.
26//!
27//! For the *config* document rather than the content documents, see
28//! [`crate::config_schema`].
29
30use std::collections::BTreeMap;
31
32use flower_core::schema::{Constraint, FieldRule, Schema};
33use flower_core::{Cardinality, FieldType, Icon, PathPat, Presentation, SegPat, Term, Tint};
34use prov::{Cardinality as ProvCardinality, OpenClosed, Vocabulary, WorkspaceConfig};
35
36use crate::facets::{self, Facets};
37use crate::rules::{path, text, toggle};
38
39/// Build a flower [`Schema`] from a resolved prov workspace config and the
40/// vocabularies its controlled fields point at (keyed by field name). Vocabularies
41/// the caller could not load are simply absent, yielding an enum with no offered
42/// terms — still a rule (so a closed field with no store rejects everything, which
43/// is the honest signal that its vocabulary is missing).
44pub fn schema_from_config(
45 config: &WorkspaceConfig,
46 vocabularies: &BTreeMap<String, Vocabulary>,
47) -> Schema {
48 Schema::new(document_rules(config, vocabularies))
49}
50
51/// [`schema_from_config`]'s rules, before they become a schema — the
52/// composition point for an application overlay, and the peer of
53/// [`config_rules`](crate::config_schema::config_rules) one document over.
54///
55/// A [`Schema`] resolves a path by first match wins, so an app that governs its
56/// own frontmatter keys *prepends* its rules to these. See [`crate::rules`] for
57/// why the order is that way round.
58pub fn document_rules(
59 config: &WorkspaceConfig,
60 vocabularies: &BTreeMap<String, Vocabulary>,
61) -> Vec<FieldRule> {
62 let mut rules = Vec::new();
63
64 // Field declarations → a typed rule, carrying an Enum constraint when the
65 // field also names a vocabulary.
66 for (field, spec) in &config.fields {
67 // prov's declared type wins. A controlled field that declares none is
68 // text, because that is what a vocabulary term is.
69 let ty = spec
70 .ty
71 .or_else(|| spec.vocabulary.as_ref().map(|_| FieldType::Str));
72 // Only a field with a vocabulary constrains its values; a type-only
73 // field renders as its type and rejects nothing.
74 let constraint = spec.vocabulary.as_ref().map(|_| Constraint::Enum {
75 values: vocabularies.get(field).map(vocab_terms).unwrap_or_default(),
76 closed: matches!(spec.values, OpenClosed::Closed),
77 });
78 let icon = if constraint.is_some() {
79 Icon::Tag
80 } else {
81 icon_for(ty)
82 };
83 let rule = |at: PathPat| {
84 FieldRule::new(at)
85 .ty(ty)
86 .constraint_opt(constraint.clone())
87 .present(Presentation::default().icon(icon.clone()))
88 };
89 rules.push(rule(PathPat::key(field.clone())));
90 rules.push(rule(PathPat::each_item_of(field.clone())));
91 }
92
93 // Relations → Reference constraints. The spanning relation is the containment
94 // backbone; the rest are overlay links.
95 let facets = Facets::from_config(config);
96 let relations = config.relation_set();
97 let spanning = relations.spanning_relation().map(str::to_string);
98 for rel in relations.relations() {
99 let is_spanning = spanning.as_deref() == Some(rel.name.as_str());
100 // The vocabulary's own gloss, or prov's for a preset relation nobody
101 // declared. Carried by prov and never read back — which is exactly why
102 // it is worth putting on the row: it is documentation that travels with
103 // the data, and a reader who has it does not have to guess.
104 let means = facets
105 .of_key(&rel.name)
106 .relation()
107 .and_then(|r| r.means.clone());
108 let cardinality = match rel.cardinality {
109 ProvCardinality::One => Cardinality::One,
110 ProvCardinality::Many => Cardinality::Many,
111 };
112 let reference = |at: PathPat| {
113 FieldRule::new(at)
114 .ty(FieldType::Ref)
115 .constraint(Constraint::Reference {
116 relation: rel.name.clone(),
117 cardinality,
118 spanning: is_spanning,
119 })
120 .present(
121 Presentation::default()
122 .icon(Icon::Link)
123 .tint(is_spanning.then_some(Tint::Accent))
124 .description_opt(means.clone()),
125 )
126 };
127 rules.push(reference(PathPat::key(rel.name.clone())));
128 // A many-relation is a list of links: also govern each item.
129 if matches!(rel.cardinality, ProvCardinality::Many) {
130 rules.push(reference(PathPat::each_item_of(rel.name.clone())));
131 }
132 }
133
134 // Last, so a workspace that declares `fields.title` shadows prov's own rule
135 // for it rather than being shadowed by it.
136 rules.extend(kernel_rules(config));
137 rules
138}
139
140/// prov's own frontmatter keys — the ones every document may carry whether or
141/// not the workspace declared anything.
142///
143/// The `fields` block says what *this* workspace controls; the kernel is what
144/// prov reads regardless (spec §1). Without these, `id` and `content_hash`
145/// arrive at a schema-driven editor as anonymous text boxes beside `mood`, and
146/// the root's whole `prov:` policy block arrives as an untyped nested map — a
147/// document schema that knows about `audience` and not about `id` has the story
148/// backwards.
149///
150/// Two of them are more than presentation:
151///
152/// - **`attachment`** is a boolean, and typing `attachment: yes` where prov
153/// wants `true` is a marker prov does not honour.
154/// - **`prov:`** is the *same vocabulary as the config document*, nested one
155/// level (spec §3: "the identical keys sit at top level" in a config
156/// document, with no `prov:` wrapper). So it is governed by
157/// [`config_rules`](crate::config_schema::config_rules) with every pattern
158/// prefixed — one vocabulary, stated once, reaching both of its homes. A
159/// workspace that inlines its policy gets the same pickers as one that keeps
160/// a `prov.yaml`.
161///
162/// What is **not** here is a `Consequence` on any of it. These keys are managed
163/// rather than costly — see [`Facet::managed`](crate::Facet::managed), which is
164/// the question a frontend asks before offering an edit at all.
165pub fn kernel_rules(config: &WorkspaceConfig) -> Vec<FieldRule> {
166 // A row that says what the key is for. `described` is the local shorthand
167 // for "the builder in `rules`, plus the one sentence a reader needs".
168 let described = |mut rule: FieldRule, why: &str| {
169 rule.present = std::mem::take(&mut rule.present).description(why);
170 rule
171 };
172 let mut rules = vec![
173 described(
174 text(path(&[facets::TITLE_KEY]), "Title", Icon::Text),
175 "The name a nominal reference resolves against.",
176 ),
177 described(
178 text(path(&[facets::IDENTITY_KEY]), "Identity", Icon::Lock),
179 "Minted by the workspace; references depend on it.",
180 ),
181 described(
182 text(path(&[facets::CONTENT_KEY]), "Payload", Icon::Link),
183 "The opaque file whose bytes are this node's body.",
184 ),
185 described(
186 text(path(&[facets::MANIFEST_KEY]), "Manifest", Icon::Link),
187 "The store listing every opaque file under the directory this node claims.",
188 ),
189 described(
190 toggle(path(&[facets::ATTACHMENT_KEY]), "Opaque payload"),
191 "Read the payload as bytes, not as a document.",
192 ),
193 described(
194 text(
195 path(&[facets::CONTENT_HASH_KEY]),
196 "Content digest",
197 Icon::Lock,
198 ),
199 "Recorded by prov's fixity pass; not typed.",
200 ),
201 ];
202
203 // The stamped field, under whatever name this workspace gave it. Absent
204 // when the workspace disabled the axis, which is the default — there is no
205 // key to govern, and inventing `updated` would govern a field somebody else
206 // owns.
207 if !config.updated.is_empty() {
208 rules.push(described(
209 text(path(&[&config.updated]), "Last updated", Icon::Clock),
210 "Stamped in RFC 3339 UTC when the content changes; prov reads it back.",
211 ));
212 }
213
214 // The root's inline policy block: the config document's vocabulary, one
215 // level down.
216 rules.extend(nested_under(
217 facets::POLICY_KEY,
218 crate::config_schema::config_rules(config),
219 ));
220 rules
221}
222
223/// Re-root a rule set one key deeper — `spanning` becomes `prov.spanning`.
224///
225/// The mechanical half of "one vocabulary, two homes". Prefixing the *pattern*
226/// rather than restating the rules is what keeps the two homes from drifting: a
227/// term added to the config schema reaches the inline block in the same commit,
228/// because it is the same list.
229fn nested_under(key: &str, rules: Vec<FieldRule>) -> Vec<FieldRule> {
230 rules
231 .into_iter()
232 .map(|mut rule| {
233 let mut segments = vec![SegPat::Key(key.to_string())];
234 segments.extend(rule.at.0);
235 rule.at = PathPat(segments);
236 rule
237 })
238 .collect()
239}
240
241/// The icon a field's declared type suggests. Only a hint — flower picks the
242/// widget from `ty` itself; this is what the row is labelled with.
243fn icon_for(ty: Option<FieldType>) -> Icon {
244 use fig::ExtKind::{LocalDate, LocalDateTime, LocalTime, OffsetDateTime};
245 match ty {
246 Some(FieldType::Extended(OffsetDateTime | LocalDateTime | LocalDate | LocalTime)) => {
247 Icon::Clock
248 }
249 Some(FieldType::Bool) => Icon::Toggle,
250 Some(FieldType::Ref) => Icon::Link,
251 _ => Icon::Text,
252 }
253}
254
255/// Translate a prov vocabulary's terms into flower [`Term`]s. prov owns the term
256/// keys, each term's `means` (a human gloss), and its `retired` flag; the rest of
257/// a term's payload is carried by prov and not surfaced here.
258fn vocab_terms(vocab: &Vocabulary) -> Vec<Term> {
259 vocab
260 .terms
261 .iter()
262 .map(|(name, term)| {
263 Term::value(name.clone())
264 .description_opt(term.means.clone())
265 .retired(term.retired)
266 })
267 .collect()
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 // `.enum_constraint()`/`.reference()` are an extension trait now that
274 // `FieldRule` is fig-schema's generic type.
275 use flower_core::{FieldRuleExt, Seg};
276
277 fn audience_config() -> (WorkspaceConfig, BTreeMap<String, Vocabulary>) {
278 let mut config = WorkspaceConfig::default();
279 config.fields.insert(
280 "audience".to_string(),
281 prov::FieldSpec {
282 ty: None,
283 values: OpenClosed::Closed,
284 vocabulary: Some("audiences.yaml".to_string()),
285 reify: false,
286 },
287 );
288 // A type with no vocabulary — nothing to validate against, but the
289 // editor still needs to know it is a date.
290 config.fields.insert(
291 "created".to_string(),
292 prov::FieldSpec {
293 ty: Some(prov::FieldType::Extended(prov::ExtKind::LocalDate)),
294 values: OpenClosed::default(),
295 vocabulary: None,
296 reify: false,
297 },
298 );
299
300 let mut terms = BTreeMap::new();
301 terms.insert(
302 "public".to_string(),
303 prov::Term {
304 id: None,
305 means: Some("Anyone".to_string()),
306 retired: false,
307 },
308 );
309 terms.insert(
310 "private".to_string(),
311 prov::Term {
312 id: None,
313 means: None,
314 retired: false,
315 },
316 );
317 let mut vocabs = BTreeMap::new();
318 vocabs.insert(
319 "audience".to_string(),
320 Vocabulary {
321 field: "audience".to_string(),
322 values: OpenClosed::Closed,
323 terms,
324 },
325 );
326 (config, vocabs)
327 }
328
329 #[test]
330 fn a_closed_field_becomes_a_closed_enum_over_each_item() {
331 let (config, vocabs) = audience_config();
332 let schema = schema_from_config(&config, &vocabs);
333
334 // The list-item form is governed (an `audience:` sequence item).
335 let rule = schema
336 .rule_for(&[Seg::Key("audience".into()), Seg::Index(0)])
337 .expect("an each-item rule for audience");
338 let (terms, closed) = rule.enum_constraint().expect("an enum constraint");
339 assert!(closed, "the field declares `values: closed`");
340 assert!(terms.iter().any(|t| t.value == "public"));
341 assert!(terms.iter().any(|t| t.value == "private"));
342 // The scalar form is governed too.
343 assert!(
344 schema
345 .rule_for(&[Seg::Key("audience".into())])
346 .and_then(|r| r.enum_constraint())
347 .is_some()
348 );
349 }
350
351 /// The point of prov's `type` axis: a field nothing controls still reaches
352 /// the editor with its shape, so `created` renders as a date rather than a
353 /// text box — including when it is empty and there is no value to guess from.
354 #[test]
355 fn a_typed_field_without_a_vocabulary_yields_a_typed_unconstrained_rule() {
356 let (config, vocabs) = audience_config();
357 let schema = schema_from_config(&config, &vocabs);
358
359 let rule = schema
360 .rule_for(&[Seg::Key("created".into())])
361 .expect("a rule for created");
362 assert_eq!(
363 rule.ty,
364 Some(FieldType::Extended(fig::ExtKind::LocalDate)),
365 "the declared type reaches the editor"
366 );
367 assert!(
368 rule.constraint.is_none(),
369 "a type is not a claim about which values are legal"
370 );
371 assert_eq!(rule.present.icon, Some(Icon::Clock));
372 }
373
374 /// prov's kernel keys are prov vocabulary too. Without these a
375 /// schema-driven editor draws `id` as an anonymous text box beside `mood`.
376 #[test]
377 fn the_keys_prov_always_reads_are_governed_even_when_nothing_is_declared() {
378 let schema = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
379
380 for (key, icon) in [
381 ("title", Icon::Text),
382 ("id", Icon::Lock),
383 ("content", Icon::Link),
384 ("content_hash", Icon::Lock),
385 ] {
386 let rule = schema
387 .rule_for(&[Seg::Key(key.into())])
388 .unwrap_or_else(|| panic!("a rule for {key}"));
389 assert_eq!(rule.present.icon.as_ref(), Some(&icon), "{key}");
390 assert!(rule.present.description.is_some(), "{key} says what it is");
391 }
392
393 // The one that is more than presentation: `attachment: yes` is a marker
394 // prov does not honour, and a typed rule is what stops it.
395 let marker = schema
396 .rule_for(&[Seg::Key("attachment".into())])
397 .expect("a rule for attachment");
398 assert_eq!(marker.ty, Some(FieldType::Bool));
399 }
400
401 /// The stamped field is named by the workspace, so it is governed only where
402 /// the workspace named one — inventing `updated` would govern a key someone
403 /// else owns.
404 #[test]
405 fn the_stamped_field_is_governed_under_the_name_the_workspace_gave_it() {
406 let bare = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
407 assert!(bare.rule_for(&[Seg::Key("modified".into())]).is_none());
408
409 let config = WorkspaceConfig {
410 updated: "modified".to_string(),
411 ..WorkspaceConfig::default()
412 };
413 let schema = schema_from_config(&config, &BTreeMap::new());
414 let rule = schema
415 .rule_for(&[Seg::Key("modified".into())])
416 .expect("the workspace's own stamp name");
417 assert_eq!(rule.present.icon, Some(Icon::Clock));
418 }
419
420 /// One vocabulary, two homes: the root's inline `prov:` block is the config
421 /// document's keys nested one level, so it gets the config document's rules
422 /// with the pattern prefixed rather than a second copy of them.
423 #[test]
424 fn the_roots_inline_policy_block_is_governed_by_the_config_documents_rules() {
425 let schema = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
426
427 let inline = schema
428 .rule_for(&[Seg::Key("prov".into()), Seg::Key("fixity".into())])
429 .expect("prov.fixity");
430 let (terms, closed) = inline.enum_constraint().expect("a picker, not a text box");
431 assert!(closed);
432 assert!(terms.iter().any(|t| t.value == "on"));
433
434 // The same key at top level is the config *document*'s, and is not
435 // governed here — a content document has no bare `fixity`.
436 assert!(schema.rule_for(&[Seg::Key("fixity".into())]).is_none());
437 }
438
439 /// A workspace that declares a field of a kernel key's name wins: the
440 /// kernel rules go last, and first match wins.
441 #[test]
442 fn a_declared_field_shadows_the_kernel_rule_of_the_same_name() {
443 let mut config = WorkspaceConfig::default();
444 config.fields.insert(
445 "title".to_string(),
446 prov::FieldSpec {
447 ty: None,
448 values: OpenClosed::Closed,
449 vocabulary: Some("titles.yaml".to_string()),
450 reify: false,
451 },
452 );
453 let schema = schema_from_config(&config, &BTreeMap::new());
454 let rule = schema
455 .rule_for(&[Seg::Key("title".into())])
456 .expect("a rule for title");
457 assert!(
458 rule.enum_constraint().is_some(),
459 "the workspace's declaration, not prov's kernel rule"
460 );
461 }
462
463 /// The gloss prov carries and never reads is exactly what a row should say.
464 #[test]
465 fn a_relations_gloss_travels_onto_its_row() {
466 let schema = schema_from_config(&WorkspaceConfig::default(), &BTreeMap::new());
467 let rule = schema
468 .rule_for(&[Seg::Key("part_of".into())])
469 .expect("a rule for part_of");
470 assert_eq!(
471 rule.present.description.as_deref(),
472 Some("the document that contains this one")
473 );
474 }
475
476 #[test]
477 fn the_spanning_relation_becomes_a_spanning_reference() {
478 let (config, vocabs) = audience_config();
479 let schema = schema_from_config(&config, &vocabs);
480
481 // prov's default relation set spans on `contents`.
482 let rule = schema
483 .rule_for(&[Seg::Key("contents".into())])
484 .expect("a rule for contents");
485 match &rule.constraint {
486 Some(Constraint::Reference {
487 relation, spanning, ..
488 }) => {
489 assert_eq!(relation, "contents");
490 assert!(*spanning, "contents is the spanning backbone");
491 }
492 other => panic!("expected a spanning reference, got {other:?}"),
493 }
494
495 // An overlay relation (`part_of`) is a non-spanning reference.
496 let part_of = schema
497 .rule_for(&[Seg::Key("part_of".into())])
498 .expect("a rule for part_of");
499 assert_eq!(part_of.reference(), Some("part_of"));
500 assert!(matches!(
501 part_of.constraint,
502 Some(Constraint::Reference {
503 spanning: false,
504 ..
505 })
506 ));
507 }
508}