provui_core/facets.rs
1//! What a frontmatter key *is* to prov — the classification, offered as a
2//! question and never applied.
3//!
4//! A prov document's metadata block holds two kinds of thing side by side and
5//! looks the same either way: keys prov itself reads to build the workspace
6//! (`contents` is an edge, `id` is identity, `prov:` is policy) and keys prov
7//! merely carries (`mood: rainy`). A schema-free editor over that block has no
8//! way to tell them apart, so it draws `id` and `mood` as the same row and
9//! offers to let you type into both.
10//!
11//! [`Facets`] answers which is which. It is built from the resolved workspace
12//! config — the relation vocabulary, the `fields` declarations, the name of the
13//! stamped `updated` field — so the answer is *this workspace's*, not a list
14//! this crate invented, and a workspace that retracts `link_of` gets `link_of:
15//! Carried` without anything here knowing it happened.
16//!
17//! ## Why this only classifies
18//!
19//! An application over prov usually separates the two halves in its UI: prov's
20//! own structure goes in a sidebar, an inspector, or a footer, and the
21//! user-defined values get the form. That is a good design and it is not this
22//! crate's to make. The facts are general; the arrangement is a product
23//! decision, and each frontend's will differ — a mobile inspector, a terminal
24//! band and a settings sheet do not want the same split.
25//!
26//! So nothing here hides, demotes, reorders, or read-onlys a row. What it does
27//! is hand a frontend the lists it would need to do any of those:
28//! [`structural_keys`](Facets::structural_keys) and
29//! [`managed_keys`](Facets::managed_keys) are shaped to go straight into
30//! flower's [`set_demoted`](flower_core::Model::set_demoted) and
31//! [`with_managed`](flower_core::Model::with_managed) — one line for a frontend
32//! that wants diaryx's separation, and zero for one that wants a flat list.
33//!
34//! For the link half of the same question — *which* documents a relation field
35//! points at, and where each link sits — see [`crate::links`].
36
37use std::collections::BTreeMap;
38
39use fig::Value;
40use flower_core::Seg;
41use prov::{Cardinality, FieldSpec, OpenClosed, Relation, RelationSet, WorkspaceConfig};
42
43/// The root's embedded policy block — prov's `prov:` key, one of the two homes
44/// workspace policy lives in.
45pub const POLICY_KEY: &str = "prov";
46/// The document's stable identity.
47pub const IDENTITY_KEY: &str = "id";
48/// The name a nominal (`[[My File]]`) reference resolves against.
49pub const TITLE_KEY: &str = "title";
50/// A sidecar's pointer at the opaque payload whose bytes are its body.
51pub const CONTENT_KEY: &str = "content";
52/// A manifest node's pointer at the store listing the directory it claims.
53pub const MANIFEST_KEY: &str = "manifest";
54/// The declared-opacity marker: `true` says "read my payload as bytes, not as a
55/// document", and it wins over what the extension suggests.
56pub const ATTACHMENT_KEY: &str = "attachment";
57/// The recorded digest of the payload, maintained by prov's fixity pass.
58pub const CONTENT_HASH_KEY: &str = "content_hash";
59
60/// Which half of the opaque-payload axis a key is.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Payload {
63 /// `content` — the payload's path.
64 Content,
65 /// `manifest` — the store listing a whole claimed directory.
66 Manifest,
67 /// `attachment` — the declared-opacity marker.
68 Marker,
69 /// `content_hash` — the recorded digest.
70 Digest,
71}
72
73/// A relation field, with everything the vocabulary says about it.
74#[derive(Debug, Clone)]
75pub struct RelationFacet {
76 /// The frontmatter key.
77 pub name: String,
78 /// One target or a list of them.
79 pub cardinality: Cardinality,
80 /// The reciprocal field prov maintains, when there is one.
81 pub inverse: Option<String>,
82 /// The spanning containment backbone — the relation the workspace unfolds
83 /// along. At most one relation in a workspace is this.
84 pub spanning: bool,
85 /// Set when this relation is one of the five **pointers** the root uses to
86 /// find the workspace's own machinery (`config`, `registry`, `recycle_bin`,
87 /// `history`, `about`). Followed one way and only from the root: the target
88 /// is not content, carries no back-link, and is not in the spanning tree.
89 pub pointer: bool,
90 /// A human gloss of what the relation means, when the workspace declared one
91 /// — or prov's own for a preset relation it did not bother to declare.
92 /// Carried by prov and never read back; here so a frontend can show it.
93 pub means: Option<String>,
94}
95
96/// A field the workspace declared in `fields.<name>`.
97#[derive(Debug, Clone)]
98pub struct FieldFacet {
99 /// The frontmatter key.
100 pub name: String,
101 /// The vocabulary document its terms are checked against, when it names one.
102 /// `None` for a field that declares only a type.
103 pub vocabulary: Option<String>,
104 /// Whether an unknown term is rejected (`closed`) or merely unlisted.
105 pub values: OpenClosed,
106 /// Whether each term is a document in its own right rather than a row in a
107 /// flat store — in which case its terms are ordinary content, reachable
108 /// down the spanning tree as well as through this pointer.
109 pub reify: bool,
110}
111
112/// What a frontmatter key is to prov.
113///
114/// Exhaustive over a document's *top-level* keys: every key falls in exactly one
115/// of these, and [`Facet::Carried`] is the one that means "prov does not read
116/// this". A nested key takes its top-level ancestor's facet — see
117/// [`Facets::of`].
118#[derive(Debug, Clone)]
119pub enum Facet {
120 /// A link field: the targets are edges in the workspace graph.
121 Relation(RelationFacet),
122 /// The root's `prov:` block — workspace policy, inline.
123 Policy,
124 /// `id` — the document's stable identity. Minted and maintained by the
125 /// workspace, not typed.
126 Identity,
127 /// `title` — read back by prov for nominal references and for the generated
128 /// `about` page, but written by a person.
129 Title,
130 /// One of the four keys on the opaque-payload axis.
131 Payload(Payload),
132 /// The field the workspace's `updated:` config names — machine-stamped in
133 /// RFC 3339 UTC because prov reads it back to know when to rewrite it. The
134 /// *name* is the workspace's; a human-friendly date is a different,
135 /// user-owned field prov never touches.
136 Stamp,
137 /// Declared in `fields.<name>`: prov resolves its values against a
138 /// vocabulary, or at least knows their type.
139 Field(FieldFacet),
140 /// Carried by prov and never read by it. The default, and the majority of
141 /// an ordinary document.
142 Carried,
143}
144
145impl Facet {
146 /// Whether prov reads this key at all. `false` only for
147 /// [`Carried`](Facet::Carried).
148 pub fn read_by_prov(&self) -> bool {
149 !matches!(self, Facet::Carried)
150 }
151
152 /// Whether this is prov's own *structure* rather than something the document
153 /// says about itself — the line a frontend that separates the two draws.
154 ///
155 /// `contents`, `part_of`, `config`, `prov:`, `id`, `content_hash` are
156 /// structure. `title` is not, and neither is a declared field: prov reads
157 /// both, but a person wrote them, and putting `audience: public` behind the
158 /// same fold as `id` hides the thing the reader came for.
159 ///
160 /// A question, not a policy. Nothing in this crate acts on it.
161 pub fn structural(&self) -> bool {
162 !matches!(self, Facet::Title | Facet::Field(_) | Facet::Carried)
163 }
164
165 /// Whether the *workspace* maintains this value, so an editor should draw
166 /// the row and decline the edit rather than offer a text box.
167 ///
168 /// `id` is minted, `content_hash` is computed, the `updated` stamp is
169 /// written on save. Typing into any of the three does not change what it
170 /// will say after the next prov operation; it only makes the document
171 /// briefly wrong. This is exactly flower's `derived` set — see
172 /// [`managed_keys`](Facets::managed_keys).
173 pub fn managed(&self) -> bool {
174 matches!(
175 self,
176 Facet::Identity | Facet::Stamp | Facet::Payload(Payload::Digest)
177 )
178 }
179
180 /// The relation this key declares, when it is one — the test a frontend
181 /// applies before offering to follow a row.
182 pub fn relation(&self) -> Option<&RelationFacet> {
183 match self {
184 Facet::Relation(rel) => Some(rel),
185 _ => None,
186 }
187 }
188
189 /// A short, frontend-neutral name for the kind — for a badge, a filter, or a
190 /// status line that wants to say what a row is without a match arm.
191 pub fn kind(&self) -> &'static str {
192 match self {
193 Facet::Relation(rel) if rel.pointer => "pointer",
194 Facet::Relation(_) => "relation",
195 Facet::Policy => "policy",
196 Facet::Identity => "identity",
197 Facet::Title => "title",
198 Facet::Payload(_) => "payload",
199 Facet::Stamp => "stamp",
200 Facet::Field(_) => "field",
201 Facet::Carried => "carried",
202 }
203 }
204}
205
206/// The classifier: one workspace's answer to "what is this key?".
207///
208/// Cheap to build and cheap to hold — it is the config's vocabulary, resolved
209/// once, and every lookup is a map hit. Build it from the workspace config when
210/// there is one and take [`Facets::default`] when there is not: a lone document
211/// opened outside any workspace is still read with prov's built-in vocabulary,
212/// which is what makes `contents` mean `contents` in a file nobody has
213/// configured.
214#[derive(Debug, Clone)]
215pub struct Facets {
216 relations: RelationSet,
217 /// Per relation name, everything the vocabulary says about it — built once
218 /// so `of_key` is a lookup rather than a scan of `relations()`.
219 by_relation: BTreeMap<String, RelationFacet>,
220 fields: BTreeMap<String, FieldFacet>,
221 /// The workspace's stamped-`updated` field name; empty means the axis is off.
222 stamp: Option<String>,
223}
224
225impl Default for Facets {
226 /// prov's built-in vocabulary and nothing else — the right answer for a
227 /// document read outside a workspace, which is still a prov document.
228 fn default() -> Self {
229 Self::from_config(&WorkspaceConfig::default())
230 }
231}
232
233impl Facets {
234 /// Classify against a resolved workspace config.
235 pub fn from_config(config: &WorkspaceConfig) -> Self {
236 let relations = config.relation_set();
237 let mut by_relation = BTreeMap::new();
238 for relation in relations.relations() {
239 by_relation.insert(
240 relation.name.clone(),
241 relation_facet(relation, &relations, config),
242 );
243 }
244 let fields = config
245 .fields
246 .iter()
247 .map(|(name, spec)| (name.clone(), field_facet(name, spec)))
248 .collect();
249 Self {
250 relations,
251 by_relation,
252 fields,
253 stamp: (!config.updated.is_empty()).then(|| config.updated.clone()),
254 }
255 }
256
257 /// The relation vocabulary these facets read by — what [`crate::links`]
258 /// walks, and what a frontend hands prov when it resolves a target.
259 pub fn relations(&self) -> &RelationSet {
260 &self.relations
261 }
262
263 /// Classify a top-level key.
264 ///
265 /// Relations first, so a workspace that declares `fields.contents` — legal,
266 /// and a thing a confused config can say — still gets a link field for the
267 /// key prov will follow. The `fields` half only reaches keys the relation
268 /// vocabulary left alone.
269 pub fn of_key(&self, key: &str) -> Facet {
270 if let Some(relation) = self.by_relation.get(key) {
271 return Facet::Relation(relation.clone());
272 }
273 if self.stamp.as_deref() == Some(key) {
274 return Facet::Stamp;
275 }
276 match key {
277 POLICY_KEY => return Facet::Policy,
278 IDENTITY_KEY => return Facet::Identity,
279 TITLE_KEY => return Facet::Title,
280 CONTENT_KEY => return Facet::Payload(Payload::Content),
281 MANIFEST_KEY => return Facet::Payload(Payload::Manifest),
282 ATTACHMENT_KEY => return Facet::Payload(Payload::Marker),
283 CONTENT_HASH_KEY => return Facet::Payload(Payload::Digest),
284 _ => {}
285 }
286 match self.fields.get(key) {
287 Some(field) => Facet::Field(field.clone()),
288 None => Facet::Carried,
289 }
290 }
291
292 /// Classify a metadata path.
293 ///
294 /// The **first** segment decides, so `contents[2]` is the relation
295 /// `contents` and `prov.relations.see_also.inverse` is policy. That is not a
296 /// shortcut: a path's facet is a fact about which of prov's axes it belongs
297 /// to, and every segment below the first is a part of the same one. It also
298 /// matches how flower scopes its own managed sets, which are root keys
299 /// matched exactly — so a list built here goes into `set_demoted` meaning
300 /// what it meant on the way out.
301 ///
302 /// An empty path — the document itself — is [`Facet::Carried`]: the document
303 /// is not one of prov's keys.
304 pub fn of(&self, path: &[Seg]) -> Facet {
305 match path.first() {
306 Some(Seg::Key(key)) => self.of_key(key),
307 _ => Facet::Carried,
308 }
309 }
310
311 /// Every top-level key of `meta`, in document order, with its facet.
312 ///
313 /// Document order, not sorted: the order keys are written in is the
314 /// document's own and a lossless editor's whole point. A caller that wants
315 /// them grouped groups them.
316 pub fn classify(&self, meta: &Value) -> Vec<(String, Facet)> {
317 top_level_keys(meta)
318 .into_iter()
319 .map(|key| {
320 let facet = self.of_key(&key);
321 (key, facet)
322 })
323 .collect()
324 }
325
326 /// The keys present in `meta` that are prov's structure
327 /// ([`Facet::structural`]) — shaped for
328 /// [`Model::set_demoted`](flower_core::Model::set_demoted).
329 ///
330 /// Present in the document, not every key prov knows: demoting a key the
331 /// document does not have is harmless but tells a reader nothing, and the
332 /// list is short enough to be worth being exact about.
333 pub fn structural_keys(&self, meta: &Value) -> Vec<String> {
334 self.keys_where(meta, |facet| facet.structural())
335 }
336
337 /// The keys present in `meta` that the workspace maintains
338 /// ([`Facet::managed`]) — shaped for the `derived` argument of
339 /// [`Model::with_managed`](flower_core::Model::with_managed).
340 pub fn managed_keys(&self, meta: &Value) -> Vec<String> {
341 self.keys_where(meta, |facet| facet.managed())
342 }
343
344 /// Every key this workspace maintains, whether or not a given document
345 /// carries it — the same list as [`managed_keys`](Self::managed_keys), asked
346 /// without a document.
347 ///
348 /// The form a *constructor* needs: flower takes its derived set before the
349 /// first row list exists, which is before there is a parsed document to ask.
350 /// Naming a key the document does not have is inert (there is no row to mark
351 /// read-only), and naming one it gains later is the point — a document that
352 /// acquires an `id` should not become editable in the same breath.
353 pub fn managed_key_names(&self) -> Vec<String> {
354 let mut names = vec![IDENTITY_KEY.to_string(), CONTENT_HASH_KEY.to_string()];
355 names.extend(self.stamp.clone());
356 names
357 }
358
359 /// The keys present in `meta` that prov carries and never reads — the
360 /// complement a frontend showing "just this document's own values" wants.
361 pub fn carried_keys(&self, meta: &Value) -> Vec<String> {
362 self.keys_where(meta, |facet| !facet.read_by_prov())
363 }
364
365 fn keys_where(&self, meta: &Value, want: impl Fn(&Facet) -> bool) -> Vec<String> {
366 self.classify(meta)
367 .into_iter()
368 .filter(|(_, facet)| want(facet))
369 .map(|(key, _)| key)
370 .collect()
371 }
372}
373
374/// A relation's facet, with the pointer flag and the gloss resolved.
375fn relation_facet(
376 relation: &Relation,
377 relations: &RelationSet,
378 config: &WorkspaceConfig,
379) -> RelationFacet {
380 let name = relation.name.as_str();
381 let pointer = [
382 relations.registry_relation(),
383 relations.config_relation(),
384 relations.recycle_relation(),
385 relations.history_relation(),
386 relations.about_relation(),
387 ]
388 .into_iter()
389 .flatten()
390 .any(|p| p == name);
391 RelationFacet {
392 name: relation.name.clone(),
393 cardinality: relation.cardinality,
394 inverse: relation.inverse.clone(),
395 spanning: relations.spanning_relation() == Some(name),
396 pointer,
397 // The workspace's own gloss wins; prov's preset gloss is the fallback,
398 // so an undeclared `contents` reads as prov's `contents` rather than as
399 // a blank. A name the preset does not know has no fallback and stays
400 // `None` — better an absent gloss than an invented one.
401 means: config
402 .relation_defs
403 .get(name)
404 .and_then(|def| def.means.clone())
405 .or_else(|| RelationSet::diaryx_means(name).map(str::to_string)),
406 }
407}
408
409fn field_facet(name: &str, spec: &FieldSpec) -> FieldFacet {
410 FieldFacet {
411 name: name.to_string(),
412 vocabulary: spec.vocabulary.clone(),
413 values: spec.values,
414 reify: spec.reify,
415 }
416}
417
418/// The top-level mapping keys of a metadata tree, in document order. Empty for
419/// anything that is not a mapping — a document whose whole block is a list is
420/// legal input and has no keys to classify.
421fn top_level_keys(meta: &Value) -> Vec<String> {
422 let Some(entries) = meta.as_mapping() else {
423 return Vec::new();
424 };
425 entries
426 .iter()
427 .filter_map(|(key, _)| key.as_str().map(str::to_string))
428 .collect()
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use prov::{Document, FieldType, RelationDef};
435
436 const DOC: &str = "\
437---
438title: A Note
439id: ajp7eq
440contents:
441- '[Child](child.md)'
442part_of: '[Root](/README.md)'
443audience: public
444mood: rainy
445content_hash: sha256-abc
446---
447# Note
448";
449
450 fn meta_of(text: &str) -> Value {
451 let doc = Document::parse("note.md", text).expect("parse");
452 Value::from(&doc.meta)
453 }
454
455 fn workspace() -> WorkspaceConfig {
456 let mut config = WorkspaceConfig::default();
457 config.fields.insert(
458 "audience".to_string(),
459 FieldSpec {
460 ty: None,
461 values: OpenClosed::Closed,
462 vocabulary: Some("audiences.yaml".to_string()),
463 reify: false,
464 },
465 );
466 config.updated = "updated".to_string();
467 config
468 }
469
470 #[test]
471 fn separates_provs_own_keys_from_the_ones_it_only_carries() {
472 let facets = Facets::from_config(&workspace());
473 let meta = meta_of(DOC);
474
475 assert_eq!(
476 facets.structural_keys(&meta),
477 ["id", "contents", "part_of", "content_hash"],
478 "prov's structure, in document order"
479 );
480 assert_eq!(
481 facets.carried_keys(&meta),
482 ["mood"],
483 "only what prov never reads"
484 );
485 // `title` and a declared field are read by prov and are still not
486 // structure — the distinction the two lists exist to keep apart.
487 assert!(facets.of_key("title").read_by_prov());
488 assert!(!facets.of_key("title").structural());
489 assert!(facets.of_key("audience").read_by_prov());
490 assert!(!facets.of_key("audience").structural());
491 }
492
493 #[test]
494 fn the_workspace_maintains_id_the_digest_and_the_stamp() {
495 let facets = Facets::from_config(&workspace());
496 let meta = meta_of(DOC);
497 assert_eq!(facets.managed_keys(&meta), ["id", "content_hash"]);
498 // The same answer asked without a document, which is the form a
499 // constructor needs — and it names the stamp this workspace declared.
500 assert_eq!(
501 facets.managed_key_names(),
502 ["id", "content_hash", "updated"]
503 );
504 assert_eq!(
505 Facets::default().managed_key_names(),
506 ["id", "content_hash"],
507 "no declared stamp, no stamped key"
508 );
509 // The stamp's *name* is the workspace's, so it is only managed where the
510 // workspace declared one.
511 assert!(facets.of_key("updated").managed());
512 assert!(!Facets::default().of_key("updated").managed());
513 }
514
515 /// The point of reading the vocabulary rather than a list this crate keeps:
516 /// a workspace that retracts a relation gets an ordinary carried field, and
517 /// one that adds a relation gets a followable link, without a line here.
518 #[test]
519 fn the_vocabulary_is_the_workspaces_not_this_crates() {
520 let mut config = WorkspaceConfig::default();
521 config.relation_defs.insert(
522 "link_of".to_string(),
523 RelationDef {
524 off: true,
525 ..RelationDef::default()
526 },
527 );
528 config.relation_defs.insert(
529 "see_also".to_string(),
530 RelationDef {
531 cardinality: Some(Cardinality::Many),
532 means: Some("worth reading beside this".to_string()),
533 ..RelationDef::default()
534 },
535 );
536 let facets = Facets::from_config(&config);
537
538 assert!(
539 matches!(facets.of_key("link_of"), Facet::Carried),
540 "a retracted name is an ordinary field"
541 );
542 let see_also = facets
543 .of_key("see_also")
544 .relation()
545 .cloned()
546 .expect("a declared relation");
547 assert_eq!(see_also.means.as_deref(), Some("worth reading beside this"));
548 assert!(!see_also.spanning);
549
550 // The preset's own gloss stands in for a relation nobody declared.
551 let contents = facets.of_key("contents");
552 let contents = contents.relation().expect("contents is a relation");
553 assert!(contents.spanning, "contents is the backbone");
554 assert_eq!(
555 contents.means.as_deref(),
556 Some("documents contained by this one")
557 );
558 }
559
560 #[test]
561 fn a_pointer_relation_is_marked_as_machinery() {
562 let facets = Facets::default();
563 let config = facets.of_key("config");
564 let config = config.relation().expect("config is a relation");
565 assert!(config.pointer, "config points at machinery");
566 assert!(!config.spanning);
567 assert_eq!(Facet::Relation(config.clone()).kind(), "pointer");
568
569 let contents = facets.of_key("contents");
570 assert!(!contents.relation().expect("relation").pointer);
571 }
572
573 /// A path's facet is its top-level key's — which is also how flower scopes
574 /// the managed sets these lists feed.
575 #[test]
576 fn a_nested_path_takes_its_top_level_keys_facet() {
577 let facets = Facets::default();
578 let nested = [Seg::Key("contents".into()), Seg::Index(2)];
579 assert!(matches!(facets.of(&nested), Facet::Relation(_)));
580 assert!(matches!(
581 facets.of(&[Seg::Key("prov".into()), Seg::Key("spanning".into())]),
582 Facet::Policy
583 ));
584 assert!(matches!(facets.of(&[]), Facet::Carried), "the document");
585 }
586
587 #[test]
588 fn a_declared_field_carries_its_vocabulary() {
589 let mut config = workspace();
590 config.fields.insert(
591 "created".to_string(),
592 FieldSpec {
593 ty: Some(FieldType::Str),
594 values: OpenClosed::default(),
595 vocabulary: None,
596 reify: false,
597 },
598 );
599 let facets = Facets::from_config(&config);
600 match facets.of_key("audience") {
601 Facet::Field(field) => {
602 assert_eq!(field.vocabulary.as_deref(), Some("audiences.yaml"));
603 assert!(matches!(field.values, OpenClosed::Closed));
604 }
605 other => panic!("expected a declared field, got {other:?}"),
606 }
607 match facets.of_key("created") {
608 Facet::Field(field) => assert!(field.vocabulary.is_none()),
609 other => panic!("expected a declared field, got {other:?}"),
610 }
611 }
612}