Skip to main content

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 == "attachments"));
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}