Skip to main content

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}