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///
98/// One declaration's worth: the workspace-wide one when the field has it, else
99/// the first of its scoped ones. A field prov 0.12 declares only `under:`
100/// indexes is still a declared field to every document — prov reads it in some
101/// region — and which declaration governs a given document, or whether any
102/// does, is [`prov::workspace::FieldScopes`]' question, asked by
103/// [`WorkspaceView::schema_for`](crate::WorkspaceView::schema_for) and not
104/// here. Classification is per workspace; the schema is per document.
105#[derive(Debug, Clone)]
106pub struct FieldFacet {
107    /// The frontmatter key.
108    pub name: String,
109    /// The vocabulary document its terms are checked against, when it names one.
110    /// `None` for a field that declares only a type.
111    pub vocabulary: Option<String>,
112    /// Whether an unknown term is rejected (`closed`) or merely unlisted.
113    pub values: OpenClosed,
114    /// Whether each term is a document in its own right rather than a row in a
115    /// flat store — in which case its terms are ordinary content, reachable
116    /// down the spanning tree as well as through this pointer.
117    pub reify: bool,
118    /// The index this declaration is scoped under, as written in the config —
119    /// `None` for the declaration that governs the whole workspace. `Some`
120    /// says the facet describes one region's declaration and the field may
121    /// have others.
122    pub under: Option<String>,
123}
124
125/// What a frontmatter key is to prov.
126///
127/// Exhaustive over a document's *top-level* keys: every key falls in exactly one
128/// of these, and [`Facet::Carried`] is the one that means "prov does not read
129/// this". A nested key takes its top-level ancestor's facet — see
130/// [`Facets::of`].
131#[derive(Debug, Clone)]
132pub enum Facet {
133    /// A link field: the targets are edges in the workspace graph.
134    Relation(RelationFacet),
135    /// The root's `prov:` block — workspace policy, inline.
136    Policy,
137    /// `id` — the document's stable identity. Minted and maintained by the
138    /// workspace, not typed.
139    Identity,
140    /// `title` — read back by prov for nominal references and for the generated
141    /// `about` page, but written by a person.
142    Title,
143    /// One of the four keys on the opaque-payload axis.
144    Payload(Payload),
145    /// The field the workspace's `updated:` config names — machine-stamped in
146    /// RFC 3339 UTC because prov reads it back to know when to rewrite it. The
147    /// *name* is the workspace's; a human-friendly date is a different,
148    /// user-owned field prov never touches.
149    Stamp,
150    /// Declared in `fields.<name>`: prov resolves its values against a
151    /// vocabulary, or at least knows their type.
152    Field(FieldFacet),
153    /// Carried by prov and never read by it. The default, and the majority of
154    /// an ordinary document.
155    Carried,
156}
157
158impl Facet {
159    /// Whether prov reads this key at all. `false` only for
160    /// [`Carried`](Facet::Carried).
161    pub fn read_by_prov(&self) -> bool {
162        !matches!(self, Facet::Carried)
163    }
164
165    /// Whether this is prov's own *structure* rather than something the document
166    /// says about itself — the line a frontend that separates the two draws.
167    ///
168    /// `contents`, `part_of`, `config`, `prov:`, `id`, `content_hash` are
169    /// structure. `title` is not, and neither is a declared field: prov reads
170    /// both, but a person wrote them, and putting `audience: public` behind the
171    /// same fold as `id` hides the thing the reader came for.
172    ///
173    /// A question, not a policy. Nothing in this crate acts on it.
174    pub fn structural(&self) -> bool {
175        !matches!(self, Facet::Title | Facet::Field(_) | Facet::Carried)
176    }
177
178    /// Whether the *workspace* maintains this value, so an editor should draw
179    /// the row and decline the edit rather than offer a text box.
180    ///
181    /// `id` is minted, `content_hash` is computed, the `updated` stamp is
182    /// written on save. Typing into any of the three does not change what it
183    /// will say after the next prov operation; it only makes the document
184    /// briefly wrong. This is exactly flower's `derived` set — see
185    /// [`managed_keys`](Facets::managed_keys).
186    pub fn managed(&self) -> bool {
187        matches!(
188            self,
189            Facet::Identity | Facet::Stamp | Facet::Payload(Payload::Digest)
190        )
191    }
192
193    /// The relation this key declares, when it is one — the test a frontend
194    /// applies before offering to follow a row.
195    pub fn relation(&self) -> Option<&RelationFacet> {
196        match self {
197            Facet::Relation(rel) => Some(rel),
198            _ => None,
199        }
200    }
201
202    /// A short, frontend-neutral name for the kind — for a badge, a filter, or a
203    /// status line that wants to say what a row is without a match arm.
204    pub fn kind(&self) -> &'static str {
205        match self {
206            Facet::Relation(rel) if rel.pointer => "pointer",
207            Facet::Relation(_) => "relation",
208            Facet::Policy => "policy",
209            Facet::Identity => "identity",
210            Facet::Title => "title",
211            Facet::Payload(_) => "payload",
212            Facet::Stamp => "stamp",
213            Facet::Field(_) => "field",
214            Facet::Carried => "carried",
215        }
216    }
217}
218
219/// The classifier: one workspace's answer to "what is this key?".
220///
221/// Cheap to build and cheap to hold — it is the config's vocabulary, resolved
222/// once, and every lookup is a map hit. Build it from the workspace config when
223/// there is one and take [`Facets::default`] when there is not: a lone document
224/// opened outside any workspace is still read with prov's built-in vocabulary,
225/// which is what makes `contents` mean `contents` in a file nobody has
226/// configured.
227#[derive(Debug, Clone)]
228pub struct Facets {
229    relations: RelationSet,
230    /// Per relation name, everything the vocabulary says about it — built once
231    /// so `of_key` is a lookup rather than a scan of `relations()`.
232    by_relation: BTreeMap<String, RelationFacet>,
233    fields: BTreeMap<String, FieldFacet>,
234    /// The workspace's stamped-`updated` field name; empty means the axis is off.
235    stamp: Option<String>,
236}
237
238impl Default for Facets {
239    /// prov's built-in vocabulary and nothing else — the right answer for a
240    /// document read outside a workspace, which is still a prov document.
241    fn default() -> Self {
242        Self::from_config(&WorkspaceConfig::default())
243    }
244}
245
246impl Facets {
247    /// Classify against a resolved workspace config.
248    pub fn from_config(config: &WorkspaceConfig) -> Self {
249        let relations = config.relation_set();
250        let mut by_relation = BTreeMap::new();
251        for relation in relations.relations() {
252            by_relation.insert(
253                relation.name.clone(),
254                relation_facet(relation, &relations, config),
255            );
256        }
257        // Every declared field, described by its workspace-wide declaration
258        // when it has one and its first scoped declaration otherwise: a field
259        // declared only under indexes is one prov reads, somewhere.
260        let fields = config
261            .fields
262            .iter()
263            .filter_map(|(name, declarations)| {
264                let spec = config.field(name).or_else(|| declarations.first())?;
265                Some((name.clone(), field_facet(name, spec)))
266            })
267            .collect();
268        Self {
269            relations,
270            by_relation,
271            fields,
272            stamp: (!config.updated.is_empty()).then(|| config.updated.clone()),
273        }
274    }
275
276    /// The relation vocabulary these facets read by — what [`crate::links`]
277    /// walks, and what a frontend hands prov when it resolves a target.
278    pub fn relations(&self) -> &RelationSet {
279        &self.relations
280    }
281
282    /// Classify a top-level key.
283    ///
284    /// Relations first, so a workspace that declares `fields.contents` — legal,
285    /// and a thing a confused config can say — still gets a link field for the
286    /// key prov will follow. The `fields` half only reaches keys the relation
287    /// vocabulary left alone.
288    pub fn of_key(&self, key: &str) -> Facet {
289        if let Some(relation) = self.by_relation.get(key) {
290            return Facet::Relation(relation.clone());
291        }
292        if self.stamp.as_deref() == Some(key) {
293            return Facet::Stamp;
294        }
295        match key {
296            POLICY_KEY => return Facet::Policy,
297            IDENTITY_KEY => return Facet::Identity,
298            TITLE_KEY => return Facet::Title,
299            CONTENT_KEY => return Facet::Payload(Payload::Content),
300            MANIFEST_KEY => return Facet::Payload(Payload::Manifest),
301            ATTACHMENT_KEY => return Facet::Payload(Payload::Marker),
302            CONTENT_HASH_KEY => return Facet::Payload(Payload::Digest),
303            _ => {}
304        }
305        match self.fields.get(key) {
306            Some(field) => Facet::Field(field.clone()),
307            None => Facet::Carried,
308        }
309    }
310
311    /// Classify a metadata path.
312    ///
313    /// The **first** segment decides, so `contents[2]` is the relation
314    /// `contents` and `prov.relations.see_also.inverse` is policy. That is not a
315    /// shortcut: a path's facet is a fact about which of prov's axes it belongs
316    /// to, and every segment below the first is a part of the same one. It also
317    /// matches how flower scopes its own managed sets, which are root keys
318    /// matched exactly — so a list built here goes into `set_demoted` meaning
319    /// what it meant on the way out.
320    ///
321    /// An empty path — the document itself — is [`Facet::Carried`]: the document
322    /// is not one of prov's keys.
323    pub fn of(&self, path: &[Seg]) -> Facet {
324        match path.first() {
325            Some(Seg::Key(key)) => self.of_key(key),
326            _ => Facet::Carried,
327        }
328    }
329
330    /// Every top-level key of `meta`, in document order, with its facet.
331    ///
332    /// Document order, not sorted: the order keys are written in is the
333    /// document's own and a lossless editor's whole point. A caller that wants
334    /// them grouped groups them.
335    pub fn classify(&self, meta: &Value) -> Vec<(String, Facet)> {
336        top_level_keys(meta)
337            .into_iter()
338            .map(|key| {
339                let facet = self.of_key(&key);
340                (key, facet)
341            })
342            .collect()
343    }
344
345    /// The keys present in `meta` that are prov's structure
346    /// ([`Facet::structural`]) — shaped for
347    /// [`Model::set_demoted`](flower_core::Model::set_demoted).
348    ///
349    /// Present in the document, not every key prov knows: demoting a key the
350    /// document does not have is harmless but tells a reader nothing, and the
351    /// list is short enough to be worth being exact about.
352    pub fn structural_keys(&self, meta: &Value) -> Vec<String> {
353        self.keys_where(meta, |facet| facet.structural())
354    }
355
356    /// The keys present in `meta` that the workspace maintains
357    /// ([`Facet::managed`]) — shaped for the `derived` argument of
358    /// [`Model::with_managed`](flower_core::Model::with_managed).
359    pub fn managed_keys(&self, meta: &Value) -> Vec<String> {
360        self.keys_where(meta, |facet| facet.managed())
361    }
362
363    /// Every key this workspace maintains, whether or not a given document
364    /// carries it — the same list as [`managed_keys`](Self::managed_keys), asked
365    /// without a document.
366    ///
367    /// The form a *constructor* needs: flower takes its derived set before the
368    /// first row list exists, which is before there is a parsed document to ask.
369    /// Naming a key the document does not have is inert (there is no row to mark
370    /// read-only), and naming one it gains later is the point — a document that
371    /// acquires an `id` should not become editable in the same breath.
372    pub fn managed_key_names(&self) -> Vec<String> {
373        let mut names = vec![IDENTITY_KEY.to_string(), CONTENT_HASH_KEY.to_string()];
374        names.extend(self.stamp.clone());
375        names
376    }
377
378    /// The keys present in `meta` that prov carries and never reads — the
379    /// complement a frontend showing "just this document's own values" wants.
380    pub fn carried_keys(&self, meta: &Value) -> Vec<String> {
381        self.keys_where(meta, |facet| !facet.read_by_prov())
382    }
383
384    fn keys_where(&self, meta: &Value, want: impl Fn(&Facet) -> bool) -> Vec<String> {
385        self.classify(meta)
386            .into_iter()
387            .filter(|(_, facet)| want(facet))
388            .map(|(key, _)| key)
389            .collect()
390    }
391}
392
393/// A relation's facet, with the pointer flag and the gloss resolved.
394fn relation_facet(
395    relation: &Relation,
396    relations: &RelationSet,
397    config: &WorkspaceConfig,
398) -> RelationFacet {
399    let name = relation.name.as_str();
400    let pointer = [
401        relations.registry_relation(),
402        relations.config_relation(),
403        relations.recycle_relation(),
404        relations.history_relation(),
405        relations.about_relation(),
406    ]
407    .into_iter()
408    .flatten()
409    .any(|p| p == name);
410    RelationFacet {
411        name: relation.name.clone(),
412        cardinality: relation.cardinality,
413        inverse: relation.inverse.clone(),
414        spanning: relations.spanning_relation() == Some(name),
415        pointer,
416        // The workspace's own gloss wins; prov's preset gloss is the fallback,
417        // so an undeclared `contents` reads as prov's `contents` rather than as
418        // a blank. A name the preset does not know has no fallback and stays
419        // `None` — better an absent gloss than an invented one.
420        means: config
421            .relation_defs
422            .get(name)
423            .and_then(|def| def.means.clone())
424            .or_else(|| RelationSet::diaryx_means(name).map(str::to_string)),
425    }
426}
427
428fn field_facet(name: &str, spec: &FieldSpec) -> FieldFacet {
429    FieldFacet {
430        name: name.to_string(),
431        vocabulary: spec.vocabulary.clone(),
432        values: spec.values,
433        reify: spec.reify,
434        under: spec.under.clone(),
435    }
436}
437
438/// The top-level mapping keys of a metadata tree, in document order. Empty for
439/// anything that is not a mapping — a document whose whole block is a list is
440/// legal input and has no keys to classify.
441fn top_level_keys(meta: &Value) -> Vec<String> {
442    let Some(entries) = meta.as_mapping() else {
443        return Vec::new();
444    };
445    entries
446        .iter()
447        .filter_map(|(key, _)| key.as_str().map(str::to_string))
448        .collect()
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use prov::{Document, FieldType, RelationDef};
455
456    const DOC: &str = "\
457---
458title: A Note
459id: ajp7eq
460contents:
461- '[Child](child.md)'
462part_of: '[Root](/README.md)'
463audience: public
464mood: rainy
465content_hash: sha256-abc
466---
467# Note
468";
469
470    fn meta_of(text: &str) -> Value {
471        let doc = Document::parse("note.md", text).expect("parse");
472        Value::from(&doc.meta)
473    }
474
475    fn workspace() -> WorkspaceConfig {
476        let mut config = WorkspaceConfig::default();
477        config.fields.insert(
478            "audience".to_string(),
479            vec![FieldSpec {
480                ty: None,
481                values: OpenClosed::Closed,
482                vocabulary: Some("audiences.yaml".to_string()),
483                reify: false,
484                default: None,
485                under: None,
486            }],
487        );
488        config.updated = "updated".to_string();
489        config
490    }
491
492    #[test]
493    fn separates_provs_own_keys_from_the_ones_it_only_carries() {
494        let facets = Facets::from_config(&workspace());
495        let meta = meta_of(DOC);
496
497        assert_eq!(
498            facets.structural_keys(&meta),
499            ["id", "contents", "part_of", "content_hash"],
500            "prov's structure, in document order"
501        );
502        assert_eq!(
503            facets.carried_keys(&meta),
504            ["mood"],
505            "only what prov never reads"
506        );
507        // `title` and a declared field are read by prov and are still not
508        // structure — the distinction the two lists exist to keep apart.
509        assert!(facets.of_key("title").read_by_prov());
510        assert!(!facets.of_key("title").structural());
511        assert!(facets.of_key("audience").read_by_prov());
512        assert!(!facets.of_key("audience").structural());
513    }
514
515    #[test]
516    fn the_workspace_maintains_id_the_digest_and_the_stamp() {
517        let facets = Facets::from_config(&workspace());
518        let meta = meta_of(DOC);
519        assert_eq!(facets.managed_keys(&meta), ["id", "content_hash"]);
520        // The same answer asked without a document, which is the form a
521        // constructor needs — and it names the stamp this workspace declared.
522        assert_eq!(
523            facets.managed_key_names(),
524            ["id", "content_hash", "updated"]
525        );
526        assert_eq!(
527            Facets::default().managed_key_names(),
528            ["id", "content_hash"],
529            "no declared stamp, no stamped key"
530        );
531        // The stamp's *name* is the workspace's, so it is only managed where the
532        // workspace declared one.
533        assert!(facets.of_key("updated").managed());
534        assert!(!Facets::default().of_key("updated").managed());
535    }
536
537    /// The point of reading the vocabulary rather than a list this crate keeps:
538    /// a workspace that retracts a relation gets an ordinary carried field, and
539    /// one that adds a relation gets a followable link, without a line here.
540    #[test]
541    fn the_vocabulary_is_the_workspaces_not_this_crates() {
542        let mut config = WorkspaceConfig::default();
543        config.relation_defs.insert(
544            "link_of".to_string(),
545            RelationDef {
546                off: true,
547                ..RelationDef::default()
548            },
549        );
550        config.relation_defs.insert(
551            "see_also".to_string(),
552            RelationDef {
553                cardinality: Some(Cardinality::Many),
554                means: Some("worth reading beside this".to_string()),
555                ..RelationDef::default()
556            },
557        );
558        let facets = Facets::from_config(&config);
559
560        assert!(
561            matches!(facets.of_key("link_of"), Facet::Carried),
562            "a retracted name is an ordinary field"
563        );
564        let see_also = facets
565            .of_key("see_also")
566            .relation()
567            .cloned()
568            .expect("a declared relation");
569        assert_eq!(see_also.means.as_deref(), Some("worth reading beside this"));
570        assert!(!see_also.spanning);
571
572        // The preset's own gloss stands in for a relation nobody declared.
573        let contents = facets.of_key("contents");
574        let contents = contents.relation().expect("contents is a relation");
575        assert!(contents.spanning, "contents is the backbone");
576        assert_eq!(
577            contents.means.as_deref(),
578            Some("documents contained by this one")
579        );
580    }
581
582    #[test]
583    fn a_pointer_relation_is_marked_as_machinery() {
584        let facets = Facets::default();
585        let config = facets.of_key("config");
586        let config = config.relation().expect("config is a relation");
587        assert!(config.pointer, "config points at machinery");
588        assert!(!config.spanning);
589        assert_eq!(Facet::Relation(config.clone()).kind(), "pointer");
590
591        let contents = facets.of_key("contents");
592        assert!(!contents.relation().expect("relation").pointer);
593    }
594
595    /// A path's facet is its top-level key's — which is also how flower scopes
596    /// the managed sets these lists feed.
597    #[test]
598    fn a_nested_path_takes_its_top_level_keys_facet() {
599        let facets = Facets::default();
600        let nested = [Seg::Key("contents".into()), Seg::Index(2)];
601        assert!(matches!(facets.of(&nested), Facet::Relation(_)));
602        assert!(matches!(
603            facets.of(&[Seg::Key("prov".into()), Seg::Key("spanning".into())]),
604            Facet::Policy
605        ));
606        assert!(matches!(facets.of(&[]), Facet::Carried), "the document");
607    }
608
609    #[test]
610    fn a_declared_field_carries_its_vocabulary() {
611        let mut config = workspace();
612        config.fields.insert(
613            "created".to_string(),
614            vec![FieldSpec {
615                ty: Some(FieldType::Str),
616                values: OpenClosed::default(),
617                vocabulary: None,
618                reify: false,
619                default: None,
620                under: None,
621            }],
622        );
623        let facets = Facets::from_config(&config);
624        match facets.of_key("audience") {
625            Facet::Field(field) => {
626                assert_eq!(field.vocabulary.as_deref(), Some("audiences.yaml"));
627                assert!(matches!(field.values, OpenClosed::Closed));
628            }
629            other => panic!("expected a declared field, got {other:?}"),
630        }
631        match facets.of_key("created") {
632            Facet::Field(field) => assert!(field.vocabulary.is_none()),
633            other => panic!("expected a declared field, got {other:?}"),
634        }
635    }
636
637    /// A field declared only under indexes is still a declared field — prov
638    /// reads it somewhere — and the facet says which region it describes.
639    #[test]
640    fn a_field_declared_only_under_indexes_is_still_a_declared_field() {
641        let mut config = workspace();
642        config.fields.insert(
643            "status".to_string(),
644            vec![FieldSpec {
645                ty: None,
646                values: OpenClosed::Closed,
647                vocabulary: Some("task-statuses.yaml".to_string()),
648                reify: false,
649                default: None,
650                under: Some("[[Tasks]]".to_string()),
651            }],
652        );
653        let facets = Facets::from_config(&config);
654        match facets.of_key("status") {
655            Facet::Field(field) => assert_eq!(field.under.as_deref(), Some("[[Tasks]]")),
656            other => panic!("expected a declared field, got {other:?}"),
657        }
658    }
659}