Skip to main content

prov_config/
config.rs

1//! Workspace configuration — the typed policy a standalone/CLI workspace reads
2//! from its **config document** (the `config`-relation target from the root,
3//! DESIGN §6's reachability move applied to policy) and from its root's
4//! `prov:` frontmatter block.
5//!
6//! Programmatic embedders never need this: they configure the [`Workspace`]
7//! directly through the builder (`.link_style`, `.identity`, …), which is why
8//! the type-level identity/index choice lives there. `WorkspaceConfig` is the
9//! **data** shape that lets a workspace configure *itself* — so the same tool
10//! serves a Diaryx-style vault and an Obsidian-style one purely by what the
11//! config declares:
12//!
13//! - [`WorkspaceConfig::paths_only`] — path links, identity off (pure paths).
14//! - [`WorkspaceConfig::stable_ids`] — stable IDs minted lazily (registry +
15//!   backlinks), portable links for the path-based parts.
16//!
17//! The vocabulary (`docs/config-vocab.md`) is one namespace of keys with two
18//! homes: nested under `prov:` in the root's frontmatter (the description
19//! home) or at the top level of the dedicated config document (the policy home).
20//! [`apply`](WorkspaceConfig::apply) reads either shape; unset keys keep their
21//! default, and layering root block then config document gives the precedence
22//! *config document > root `prov:` block > default*.
23//!
24//! [`Workspace`]: crate::workspace::Workspace
25
26use std::collections::BTreeMap;
27
28use fig::ExtKind;
29use fig_schema::FieldType;
30
31use crate::textdist::nearest;
32use prov_exports::{ExportIssueKind, ExportSpec};
33pub use prov_fixity::Fixity;
34use prov_graph::content::ContentFormat;
35use prov_graph::document::EmbedStyle;
36use prov_graph::link::{Addressing, LinkStyle, Notation, PathStyle, ReferenceStyle};
37use prov_graph::meta::{Mapping, Value};
38use prov_graph::relation::{Cardinality, Relation, RelationSet};
39use prov_identity::{Registration, Trigger};
40use prov_views::{ViewIssueKind, ViewSpec};
41
42/// Where a document's stable id is persisted. Defined in `prov-graph`, because
43/// it is the one identity setting that changes what a link *resolves to* — a
44/// reader has to know whether frontmatter is a place an id can be found.
45pub use prov_graph::identity::IdStorage;
46
47/// The config-vocabulary version stamped as `spec` and recognized on read — a
48/// marker so a foreign tool (or a future prov) knows which vocabulary it is
49/// looking at. Bumped only on an incompatible reshape.
50pub const SPEC_VERSION: i64 = 1;
51
52/// The root-frontmatter key under which workspace policy is nested. A root
53/// document's frontmatter mixes structural links, identity, and user-owned
54/// fields with the occasional policy setting; nesting policy under this one key
55/// keeps the two apart, so config is unambiguous to read *and* to lint, and an
56/// unrecognized *sibling* is never mistaken for a misspelled setting. The
57/// dedicated config document needs no such wrapper — the whole document is policy
58/// (`docs/config-vocab.md`, "The two homes").
59pub const ROOT_CONFIG_KEY: &str = "prov";
60
61/// A per-relation reference-style override, as declared in a config's
62/// `relations` block. Each axis is optional and inherits the workspace default
63/// ([`WorkspaceConfig::reference_style`]) when absent — so a block need only name
64/// the axes it changes. This is the config form of
65/// [`Relation::style`](prov_graph::relation::Relation::style), and what lets links
66/// going "down" (`contents`) differ from links going "up" (`part_of`).
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub struct RelationStyleConfig {
69    /// The notation override (`markdown` / `wikilink` / `bare`).
70    pub notation: Option<Notation>,
71    /// The path-resolution override (`root` / `relative`).
72    pub path_style: Option<PathStyle>,
73    /// The addressing override (`path` / `id` / `alias`).
74    pub target: Option<Addressing>,
75    /// The `id`-wikilink label override.
76    pub label: Option<bool>,
77}
78
79/// A relation *definition* declared in a config's `relations` block — the
80/// structural half of an entry, parallel to the reference-style half
81/// ([`RelationStyleConfig`]). This is what makes a workspace's vocabulary
82/// **self-describing** (DESIGN §1, the `prov/1` spec): a foreign reader learns
83/// the graph — which fields are relations, their inverse, their cardinality —
84/// from the document itself rather than assuming prov's `contents`/`part_of`
85/// preset. Each field is optional; a `relations` entry may carry only style, only
86/// definition, or both.
87#[derive(Debug, Clone, Default, PartialEq, Eq)]
88pub struct RelationDef {
89    /// How many targets the field may hold (`one` / `many`). `None` leaves the
90    /// relation's cardinality to whatever the built [`RelationSet`] defaults it to
91    /// (`many`, the permissive choice) when this def creates the relation.
92    pub cardinality: Option<Cardinality>,
93    /// The reciprocal relation's field name, bidirectionally maintained.
94    pub inverse: Option<String>,
95    /// A free-form, human-facing gloss of what the relation means. prov never
96    /// reads this back (DESIGN §2, tier 3) — it is documentation that travels with
97    /// the data so a person reading the frontmatter learns the vocabulary too.
98    pub means: Option<String>,
99}
100
101/// Whether a controlled `fields` vocabulary is *open* (folksonomy — unknown
102/// values are allowed, only near-misses warn) or *closed* (every value must be a
103/// known term; an unknown value is an error). See the `fields` block and
104/// [`crate::vocabulary`].
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
106pub enum OpenClosed {
107    /// Unknown values allowed; `check` warns only on a probable typo of a known
108    /// term (casing/spelling drift).
109    #[default]
110    Open,
111    /// Every value must resolve to a known term; an unknown value is a hard
112    /// `check` finding. The right posture for a safety-critical vocabulary (a
113    /// diaryx `audience`, where a typo is a disclosure bug).
114    Closed,
115}
116
117impl OpenClosed {
118    /// Parse the `values` config spelling; unknown → `None`.
119    pub fn from_config_str(value: &str) -> Option<Self> {
120        match value {
121            "open" => Some(Self::Open),
122            "closed" => Some(Self::Closed),
123            _ => None,
124        }
125    }
126
127    /// The `values` config spelling.
128    pub fn as_config_str(self) -> &'static str {
129        match self {
130            Self::Open => "open",
131            Self::Closed => "closed",
132        }
133    }
134}
135
136/// A field declaration — an entry in the `fields` block. It promotes a
137/// frontmatter field (`tags`, `audience`, `created`) that prov would otherwise
138/// merely carry (DESIGN §2, tier 3) into something prov and its frontends know
139/// the shape of. Two independent things can be declared, and a field needs at
140/// least one of them to be worth an entry:
141///
142/// - **A type** ([`ty`](Self::ty)) — what the value *is*. Pure data shape,
143///   decidable from the value alone, so it is spelled in `fig-schema`'s
144///   vocabulary rather than one prov invents.
145/// - **A vocabulary** ([`vocabulary`](Self::vocabulary)) — which values are
146///   *legal*, turning the field into a resolvable reference prov keeps
147///   consistent: every value is checked against the vocabulary document the
148///   pointer reaches.
149///
150/// They compose (a closed vocabulary of strings is both), but neither implies
151/// the other: `created` is a date with no vocabulary, and a vocabulary field
152/// needs no declared type.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct FieldSpec {
155    /// The type the field's values are expected to take, if declared. Drives
156    /// type-directed parsing and widget choice in a frontend (a `date` field
157    /// gets a date picker); prov itself carries it without interpreting it.
158    pub ty: Option<FieldType>,
159    /// Whether the value set is open (folksonomy) or closed (must be known).
160    /// Meaningful only alongside a [`vocabulary`](Self::vocabulary).
161    pub values: OpenClosed,
162    /// The pointer (a link) to the vocabulary document listing this field's legal
163    /// terms — resolved like the `registry`/`config` pointers (DESIGN §6). `None`
164    /// for a field that declares a type but no controlled vocabulary.
165    pub vocabulary: Option<String>,
166    /// Whether each term is reified as its own node (rich: backlinks, a prose
167    /// body, stable id) rather than a bare key in a flat registry. A hint to
168    /// tooling; prov validates membership either way.
169    pub reify: bool,
170}
171
172/// The config spellings of [`FieldType`], in the order a diagnostic offers them.
173///
174/// A deliberate subset of `fig-schema`'s type vocabulary: the kinds a *document
175/// field* can meaningfully declare. `fig`'s remaining extended kinds
176/// (`EnumLiteral`, `CharLiteral`, `NumberSpecial`) are artifacts of particular
177/// serializations — ZON, JSON5 — rather than things a workspace declares about
178/// its own metadata, so they get no spelling here.
179pub const FIELD_TYPES: &[&str] = &[
180    "str",
181    "bool",
182    "int",
183    "float",
184    "date",
185    "datetime",
186    "local-datetime",
187    "time",
188    "ref",
189    "map",
190    "seq",
191];
192
193/// Parse a `fields.<name>.type` spelling into a [`FieldType`]; unknown → `None`.
194///
195/// A free function rather than an inherent method because [`FieldType`] is
196/// `fig-schema`'s type, not prov's — but the shape mirrors
197/// [`OpenClosed::from_config_str`] and its siblings, since this is the same kind
198/// of config-vocabulary translation.
199///
200/// The date/time spellings map onto `fig`'s extended scalars, which round-trip
201/// as a format's *native* date where the format has one (a TOML `1979-05-27`
202/// stays a date rather than becoming a quoted string) and as plain unquoted text
203/// where it does not (YAML frontmatter, where the same value reads back as a
204/// string — harmless, since a rule is matched by path, not by value type).
205pub fn field_type_from_config_str(value: &str) -> Option<FieldType> {
206    Some(match value {
207        "str" => FieldType::Str,
208        "bool" => FieldType::Bool,
209        "int" => FieldType::Int,
210        "float" => FieldType::Float,
211        // An instant carrying its offset — the archivally honest default, and
212        // what `updated:` stamps.
213        "datetime" => FieldType::Extended(ExtKind::OffsetDateTime),
214        "local-datetime" => FieldType::Extended(ExtKind::LocalDateTime),
215        "date" => FieldType::Extended(ExtKind::LocalDate),
216        "time" => FieldType::Extended(ExtKind::LocalTime),
217        "ref" => FieldType::Ref,
218        "map" => FieldType::Map,
219        "seq" => FieldType::Seq,
220        _ => return None,
221    })
222}
223
224/// The `fields.<name>.type` spelling of a [`FieldType`], or `None` for a type
225/// with no config spelling (see [`FIELD_TYPES`]) — such a type is dropped on
226/// serialization rather than written as something that would not read back.
227pub fn field_type_as_config_str(ty: FieldType) -> Option<&'static str> {
228    Some(match ty {
229        FieldType::Str => "str",
230        FieldType::Bool => "bool",
231        FieldType::Int => "int",
232        FieldType::Float => "float",
233        FieldType::Ref => "ref",
234        FieldType::Map => "map",
235        FieldType::Seq => "seq",
236        FieldType::Extended(ExtKind::OffsetDateTime) => "datetime",
237        FieldType::Extended(ExtKind::LocalDateTime) => "local-datetime",
238        FieldType::Extended(ExtKind::LocalDate) => "date",
239        FieldType::Extended(ExtKind::LocalTime) => "time",
240        FieldType::Null | FieldType::Extended(_) => return None,
241    })
242}
243
244/// Whether the workspace keeps a **history store** — one immutable event
245/// document per capture, plus content-addressed pre-image blobs — and how a
246/// capture is triggered.
247///
248/// The feature is a safety net for *structural* damage introduced by an external
249/// sync transport: a rename, move or delete touches several files at once, and a
250/// transport reconciling bytes with no idea about prov's graph can produce a
251/// clean-looking merge that is semantically broken. An event is a consistent cut
252/// across every file it captured together, so restoring one puts the set back.
253///
254/// Default **off**, unlike `recycle_bin`: history adds ongoing storage the user
255/// has not asked for, and a manual-only trigger buys nothing until the user is in
256/// the habit anyway. It is also the wrong tool when the transport is **git**,
257/// which already stores every pre-image, dedupes by content, and reconciles
258/// concurrent histories — the feature earns its keep on Dropbox, Syncthing,
259/// iCloud, a synced network share.
260///
261/// `off` gates *capture* only. The read and recovery verbs work regardless:
262/// recovery must never be gated behind re-enabling a setting, least of all on the
263/// machine that just suffered the damage.
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
265pub enum History {
266    /// No history store is maintained (`off`, the default). `history-capture`
267    /// refuses; an existing store is still readable, restorable and validated.
268    #[default]
269    Off,
270    /// Captures happen when the user asks (`manual`) — `prov history-capture`,
271    /// run by hand or by a pre-sync script the user wires up themselves. prov
272    /// does not run the sync, so there is no event for it to hook.
273    Manual,
274}
275
276/// Whether the workspace generates **`about.md`** — a short prose page,
277/// specialized against this workspace's own configuration, that tells a reader
278/// with no prior knowledge how to read *this* directory.
279///
280/// The gap it closes is narrow and specific. A prov workspace already explains
281/// its *structure* — the links are in the documents, visibly — but not its
282/// *conventions*: what the links mean, how they are spelled, which files are in
283/// the tree and which are not. Those live in the config, which is machine-facing
284/// and assumes the reader already knows what its keys mean. So a person who
285/// opens the directory with no prior knowledge cannot today learn to read it
286/// *from* the directory; they must obtain `docs/spec.md`, which is a dependency
287/// on an institution surviving — exactly the dependency the project refuses
288/// everywhere else.
289///
290/// The page is **not** a vendored copy of the spec. It is the spec *specialized*
291/// against this configuration: every rule resolved to a concrete fact, every
292/// branch this workspace does not take deleted. Where the spec says "the block
293/// is fenced by `---`, `;;;`, or ```` ```fig ````," the generated page says
294/// "every file here opens with a `---` line." Nothing is lost operationally, and
295/// the sentence is about *this directory* rather than about prov.
296///
297/// Default **on**, unlike [`History`]: it costs a few hundred bytes and one
298/// file, and a workspace that explains itself to a stranger by default is the
299/// whole thesis — making it opt-in concedes it.
300#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
301pub enum About {
302    /// No page is generated and the root declares no `about` pointer (`off`).
303    Off,
304    /// Generate the page describing the workspace's **structure** (`structure`,
305    /// the default): the root and the spine; how a file is fenced; how a
306    /// reference is written and what else is read; the relation vocabulary; what
307    /// is machinery and not in the tree; the id, checksum and deletion
308    /// conventions.
309    #[default]
310    Structure,
311}
312
313impl About {
314    /// Whether a page is generated at all.
315    pub fn generates(self) -> bool {
316        matches!(self, About::Structure)
317    }
318
319    /// Parse the `about` config spelling; unknown → `None`.
320    pub fn from_config_str(value: &str) -> Option<Self> {
321        match value {
322            "off" => Some(Self::Off),
323            "structure" => Some(Self::Structure),
324            _ => None,
325        }
326    }
327
328    /// The `about` config spelling.
329    pub fn as_config_str(self) -> &'static str {
330        match self {
331            Self::Off => "off",
332            Self::Structure => "structure",
333        }
334    }
335}
336
337impl History {
338    /// Whether `history-capture` is permitted to write a new event.
339    pub fn captures(self) -> bool {
340        matches!(self, History::Manual)
341    }
342
343    /// Parse the `history` config spelling; unknown → `None`.
344    pub fn from_config_str(value: &str) -> Option<Self> {
345        match value {
346            "off" => Some(Self::Off),
347            "manual" => Some(Self::Manual),
348            _ => None,
349        }
350    }
351
352    /// The `history` config spelling.
353    pub fn as_config_str(self) -> &'static str {
354        match self {
355            Self::Off => "off",
356            Self::Manual => "manual",
357        }
358    }
359}
360
361/// The workspace-wide policy a config declares.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct WorkspaceConfig {
364    /// When a document earns a stable ID — the identity registration triggers.
365    pub identity: Registration,
366    /// The default reference **notation** (`markdown` / `wikilink` / `bare`).
367    /// Overridden per relation by [`Relation::style`](prov_graph::relation::Relation::style).
368    pub notation: Notation,
369    /// The default **path resolution** for path targets (`root` / `relative` /
370    /// Ignored for id/alias targets.
371    pub path_style: PathStyle,
372    /// The default reference **addressing** (`path` / `id` / `alias`).
373    pub reference_target: Addressing,
374    /// Whether an id/alias reference carries a `|Title` label.
375    pub reference_label: bool,
376    /// Per-relation reference-style overrides, keyed by relation name — the
377    /// config form of [`Relation::style`](prov_graph::relation::Relation::style).
378    /// Each entry overlays the workspace default for that relation only, letting
379    /// `contents` (down) and `part_of` (up) carry different styles. Empty means
380    /// every relation inherits the default. Resolve with
381    /// [`resolved_relation_styles`](Self::resolved_relation_styles).
382    pub relation_styles: BTreeMap<String, RelationStyleConfig>,
383    /// The name of the **spanning** relation — the single-parent containment tree
384    /// that is the workspace's discovery spine (DESIGN §3). `None` leaves it to
385    /// the built vocabulary's default. Declaring it in config is what lets a
386    /// non-diaryx vocabulary name its own spine.
387    pub spanning: Option<String>,
388    /// Per-relation structural **definitions**, keyed by relation name — the
389    /// self-describing half of the `relations` block (cardinality, inverse,
390    /// human gloss). Empty means the workspace uses its built-in vocabulary
391    /// (diaryx) unchanged. Consumed by [`relation_set`](Self::relation_set).
392    pub relation_defs: BTreeMap<String, RelationDef>,
393    /// Controlled-vocabulary field declarations, keyed by frontmatter field name
394    /// (`tags`, `audience`). Empty means no field is controlled — every such
395    /// field is ordinary carried content (DESIGN §2, tier 3).
396    pub fields: BTreeMap<String, FieldSpec>,
397    /// The views the workspace declares, in declaration order — the second way
398    /// through the same documents the spine already holds ("the entries under
399    /// `Daily`, by month"). Empty means the workspace declares none, which is
400    /// not the same as having none to offer: a frontend is free to derive a
401    /// lens from a `fields` declaration, and a *declared* view is the workspace
402    /// overriding that.
403    ///
404    /// prov reads them and never acts on one. A view has no invariant to keep,
405    /// so nothing in `check` can be violated by a wrong one — it is carried
406    /// here so that every tool over the workspace reads the same views, rather
407    /// than each app namespacing its own block and agreeing by convention.
408    /// Executing one is `prov-views`.
409    pub views: Vec<ViewSpec>,
410    /// The exports the workspace declares, in declaration order — the named,
411    /// closed-by-default sets that may *leave* it, each bounded by a gate and
412    /// optionally arranged by one of [`views`](Self::views). Empty means
413    /// nothing is declared exportable, which is the default state of a
414    /// workspace and of every document in it.
415    ///
416    /// Carried here for the same reason `views` is — one axis every tool
417    /// reads — but unlike a view an export *has* an invariant, and it lives
418    /// with the planner in `prov-exports`: a plan's entries are a subset of
419    /// what the gate admits, whatever the named view says.
420    pub exports: Vec<ExportSpec>,
421    /// Where a document's stable ID is persisted — registry, frontmatter shadow,
422    /// or both (DESIGN §5). Independent of the `identity` trigger.
423    pub id_storage: IdStorage,
424    /// The metadata format new documents get when they inherit no parent block
425    /// — a *default* for authoring, never a workspace constraint (§7).
426    pub default_embed_format: fig::Format,
427    /// How that metadata is *embedded* — delimiters, a fenced code block, an
428    /// HTML island, or a separate sidecar. Together with `default_embed_format`
429    /// it selects the carrier a fresh root/document is authored in; recorded so
430    /// the workspace is self-describing about its embedding convention. Like
431    /// `default_embed_format`, an authoring default rather than a constraint:
432    /// existing documents keep whatever carrier they already have.
433    pub embed_style: EmbedStyle,
434    /// The body-prose grammar the workspace is authored in (Markdown/Djot/HTML)
435    /// — the format `render` and code-aware link scanning assume, and the
436    /// intended default for new documents.
437    pub content_format: ContentFormat,
438    /// Whether a `delete` moves the document to the **recycle bin** (recoverable)
439    /// rather than destroying it. On by default — the safe posture for archival
440    /// use, where a deletion should never be silently unrecoverable — and opt-out
441    /// per workspace for those who genuinely want a hard delete as the default.
442    pub recycle_bin: bool,
443    /// How far content-checksum (fixity) coverage extends — attachments only (the
444    /// default), attachments plus document bodies, or off.
445    pub fixity: Fixity,
446    /// Whether the workspace keeps a **history store** of captured pre-images —
447    /// the safety net for structural damage an external sync transport introduces.
448    /// Off by default; see [`History`].
449    pub history: History,
450    /// Whether the workspace generates **`about.md`**, the prose page that tells
451    /// a stranger how to read this directory. On by default; see [`About`].
452    pub about: About,
453    /// The frontmatter field `prov edit` stamps with the current time when a
454    /// document's content changes — the machine-maintained "last updated" field.
455    /// Empty (the default) disables it. The *name* is yours (`updated`,
456    /// `modified`, `lastmod`); the *value* is always machine-standard (RFC 3339
457    /// UTC), because prov reads it back to know when to rewrite it. A
458    /// human-friendly date is a *different*, user-owned field prov never
459    /// touches (see DESIGN §2, "does prov read it back?").
460    pub updated: String,
461    /// What this workspace calls **itself** — the qualifier a cross-workspace
462    /// reference (`id:<workspace>/<id>`) names it by. Empty (the default) means
463    /// the workspace is anonymous: it can still *hold* foreign references, but
464    /// no reference can be recognized as pointing back at it.
465    ///
466    /// This is the one piece of cross-workspace linking that is genuinely a fact
467    /// about the archive, so it is the one piece that lives in its config. Where
468    /// some *other* workspace can be found is a property of a device, not of
469    /// this workspace, and deliberately has no config key — see
470    /// [`Target::Foreign`](prov_graph::graph::Target::Foreign).
471    ///
472    /// Must be [well-formed](is_valid_workspace_id): a malformed value is
473    /// reported by [`diagnose`] and ignored rather than half-honored.
474    pub workspace_id: String,
475}
476
477/// Whether `name` is a usable workspace self-name.
478///
479/// Re-exported at the path it has always had, but *defined* beside the grammar
480/// it is a constraint on: every clause of it is dictated by how an
481/// `id:<workspace>/<id>` target parses, which is `prov-graph`'s business, not
482/// policy this crate gets a say in.
483pub use prov_graph::link::is_valid_workspace_id;
484
485impl Default for WorkspaceConfig {
486    /// The standalone default: portable markdown-root path links, identity
487    /// available lazily (IDs minted only on a durable link-by-id or publish, §4),
488    /// and path addressing (id-linking is opt-in).
489    fn default() -> Self {
490        Self {
491            identity: Registration::LAZY,
492            notation: Notation::Markdown,
493            path_style: PathStyle::Root,
494            reference_target: Addressing::Path,
495            reference_label: false,
496            relation_styles: BTreeMap::new(),
497            spanning: None,
498            relation_defs: BTreeMap::new(),
499            fields: BTreeMap::new(),
500            views: Vec::new(),
501            exports: Vec::new(),
502            id_storage: IdStorage::Frontmatter,
503            default_embed_format: fig::Format::Yaml,
504            embed_style: EmbedStyle::Delimited,
505            content_format: ContentFormat::Markdown,
506            recycle_bin: true,
507            fixity: Fixity::Payloads,
508            history: History::Off,
509            about: About::Structure,
510            updated: String::new(),
511            workspace_id: String::new(),
512        }
513    }
514}
515
516impl WorkspaceConfig {
517    /// Diaryx-style: path links, no identity — nothing mints an ID, so the
518    /// workspace is addressed purely by path (the Adam's-Archive shape).
519    pub fn paths_only() -> Self {
520        Self {
521            identity: Registration::OFF,
522            id_storage: IdStorage::Registry,
523            ..Self::default()
524        }
525    }
526
527    /// Obsidian-style: stable IDs minted lazily (link-by-id or publish), and
528    /// prov authors structural links *by* id — so a move rewrites nothing,
529    /// the registry keeps them resolving. Portable path links for the rest.
530    pub fn stable_ids() -> Self {
531        Self {
532            identity: Registration::LAZY,
533            reference_target: Addressing::Id,
534            id_storage: IdStorage::Registry,
535            ..Self::default()
536        }
537    }
538
539    /// The fused path [`LinkStyle`] this config's notation + path resolution
540    /// select — what the [`Workspace`](crate::workspace::Workspace) builder's
541    /// `link_style` expects for authoring structural path links.
542    pub fn link_format(&self) -> LinkStyle {
543        LinkStyle::from_axes(self.notation, self.path_style)
544    }
545
546    /// The effective workspace-default [`ReferenceStyle`] — the fallback for any
547    /// relation without its own override, composed from the four reference axes.
548    pub fn reference_style(&self) -> ReferenceStyle {
549        ReferenceStyle {
550            wrapper: self.notation.wrapper(),
551            addressing: self.reference_target,
552            label: self.reference_label,
553            path_style: LinkStyle::from_axes(self.notation, self.path_style),
554        }
555        .normalized()
556    }
557
558    /// The declared per-relation overrides resolved to full [`ReferenceStyle`]s,
559    /// each partial overlaid on the workspace default ([`reference_style`]) and
560    /// normalized. Feed the result to
561    /// [`RelationSet::with_styles`](prov_graph::relation::RelationSet::with_styles) to
562    /// build the workspace's relation vocabulary from a config. Empty when no
563    /// relation declares an override — every relation then inherits the default.
564    ///
565    /// [`reference_style`]: Self::reference_style
566    pub fn resolved_relation_styles(&self) -> BTreeMap<String, ReferenceStyle> {
567        let base = self.reference_style();
568        let base_notation = Notation::from_wrapper(base.wrapper, base.path_style);
569        let base_path = base.path_style.axes().1;
570        self.relation_styles
571            .iter()
572            .map(|(name, over)| {
573                let notation = over.notation.unwrap_or(base_notation);
574                let path = over.path_style.unwrap_or(base_path);
575                let style = ReferenceStyle {
576                    wrapper: notation.wrapper(),
577                    addressing: over.target.unwrap_or(base.addressing),
578                    label: over.label.unwrap_or(base.label),
579                    path_style: LinkStyle::from_axes(notation, path),
580                }
581                .normalized();
582                (name.clone(), style)
583            })
584            .collect()
585    }
586
587    /// Build this workspace's relation vocabulary — the self-describing path
588    /// (DESIGN §1, the `prov/1` spec). When [`relation_defs`](Self::relation_defs)
589    /// is **empty**, this is the diaryx preset
590    /// ([`RelationSet::diaryx`](prov_graph::relation::RelationSet::diaryx)) unchanged —
591    /// graceful degradation, so a minimal vault that spells out nothing keeps
592    /// working. When it declares definitions, the vocabulary is built from them,
593    /// and the structural pointer relations (`registry`/`config`/`recycle_bin`)
594    /// are preserved so those pointers stay reachable regardless. An explicit
595    /// `spanning` always wins; per-relation reference styles are overlaid last.
596    pub fn relation_set(&self) -> RelationSet {
597        let mut set = if self.relation_defs.is_empty() {
598            RelationSet::diaryx()
599        } else {
600            let mut s = RelationSet::new();
601            for (name, def) in &self.relation_defs {
602                let mut rel = match def.cardinality.unwrap_or(Cardinality::Many) {
603                    Cardinality::One => Relation::one(name),
604                    Cardinality::Many => Relation::many(name),
605                };
606                if let Some(inverse) = &def.inverse {
607                    rel = rel.inverse(inverse);
608                }
609                s = s.with(rel);
610            }
611            // Keep the structural pointer relations reachable even under a fully
612            // custom vocabulary — but never shadow one the user already declared.
613            for pointer in ["registry", "config", "recycle_bin", "history", "about"] {
614                if !s.relations().iter().any(|r| r.name == pointer) {
615                    s = s.with(Relation::one(pointer));
616                }
617            }
618            s.registry("registry")
619                .config("config")
620                .recycle("recycle_bin")
621                .history("history")
622                .about("about")
623        };
624        if let Some(spanning) = &self.spanning {
625            set = set.spanning(spanning);
626        }
627        set.with_styles(&self.resolved_relation_styles())
628    }
629
630    /// Whether a *mutation* under this config could mint a new stable ID — so a
631    /// caller that will land one must bootstrap a registry document *first*
632    /// (before the change set that would otherwise strand the id→path map with no
633    /// home). Two ways an op mints: an **eager** identity policy stamps every
634    /// created document, and any **id-registering reference style** (the workspace
635    /// default, or a single relation's override — e.g. `part_of: id` in a split)
636    /// registers a link's target when a `link` fires.
637    ///
638    /// This is the single home for a judgment the CLI previously recomputed at
639    /// every mutation command (`new`, `attach`, `mv --in`, `reparent`,
640    /// `duplicate`, `init`'s adoption pass), each an identical copy of the same
641    /// three-line `link_registers && fires_on(Link) || fires_on(Create)` — the
642    /// kind of duplicated policy that drifts silently. It lives here because every
643    /// term it needs is a fact about the config.
644    pub fn mints_on_mutation(&self) -> bool {
645        let link_registers = self.reference_style().registers()
646            || self
647                .resolved_relation_styles()
648                .values()
649                .any(|s| s.registers());
650        (link_registers && self.identity.fires_on(Trigger::Link))
651            || self.identity.fires_on(Trigger::Create)
652    }
653
654    /// Overlay the recognized keys present in `meta` onto this config; absent
655    /// keys keep their current value. `meta` is either a root's `prov:` block
656    /// or a config document's top-level mapping — the same nested shape. Apply the
657    /// root block first, then the config document, so the config document wins.
658    pub fn apply(&mut self, meta: &Value) {
659        if let Some(v) = meta
660            .get("content_format")
661            .and_then(Value::as_str)
662            .and_then(ContentFormat::from_config_str)
663        {
664            self.content_format = v;
665        }
666        if let Some(md) = meta.get("metadata") {
667            if let Some(v) = md
668                .get("format")
669                .and_then(Value::as_str)
670                .and_then(format_from_str)
671            {
672                self.default_embed_format = v;
673            }
674            if let Some(v) = md
675                .get("embed")
676                .and_then(Value::as_str)
677                .and_then(EmbedStyle::from_config_str)
678            {
679                self.embed_style = v;
680            }
681        }
682        if let Some(rf) = meta.get("references") {
683            if let Some(v) = rf
684                .get("notation")
685                .and_then(Value::as_str)
686                .and_then(Notation::from_config_str)
687            {
688                self.notation = v;
689            }
690            if let Some(v) = rf
691                .get("path_style")
692                .and_then(Value::as_str)
693                .and_then(PathStyle::from_config_str)
694            {
695                self.path_style = v;
696            }
697            if let Some(v) = rf
698                .get("target")
699                .and_then(Value::as_str)
700                .and_then(Addressing::from_config_str)
701            {
702                self.reference_target = v;
703            }
704            if let Some(v) = rf.get("label").and_then(Value::as_bool) {
705                self.reference_label = v;
706            }
707        }
708        // The spanning relation (self-description, §3): a top-level field name.
709        if let Some(v) = meta.get("spanning").and_then(Value::as_str) {
710            self.spanning = Some(v.to_string());
711        }
712        // What the workspace calls itself. A malformed name is ignored here and
713        // reported by `diagnose` — honoring half of it would mean a reference
714        // that round-trips through a name prov cannot actually write.
715        if let Some(v) = meta
716            .get("workspace_id")
717            .and_then(Value::as_str)
718            .filter(|v| is_valid_workspace_id(v))
719        {
720            self.workspace_id = v.to_string();
721        }
722        // Per-relation entries carry two orthogonal halves in one block:
723        // *style* overrides (`notation`/`path_style`/`target`/`label`) and
724        // structural *definitions* (`cardinality`/`inverse`/`means`).
725        if let Some(relations) = meta.get("relations").and_then(Value::as_mapping) {
726            for (name, spec) in relations {
727                let entry = self.relation_styles.entry(name.clone()).or_default();
728                if let Some(v) = spec
729                    .get("notation")
730                    .and_then(Value::as_str)
731                    .and_then(Notation::from_config_str)
732                {
733                    entry.notation = Some(v);
734                }
735                if let Some(v) = spec
736                    .get("path_style")
737                    .and_then(Value::as_str)
738                    .and_then(PathStyle::from_config_str)
739                {
740                    entry.path_style = Some(v);
741                }
742                if let Some(v) = spec
743                    .get("target")
744                    .and_then(Value::as_str)
745                    .and_then(Addressing::from_config_str)
746                {
747                    entry.target = Some(v);
748                }
749                if let Some(v) = spec.get("label").and_then(Value::as_bool) {
750                    entry.label = Some(v);
751                }
752                // The structural half — only recorded when at least one def key is
753                // present, so a style-only entry does not synthesize an empty def.
754                let cardinality = spec
755                    .get("cardinality")
756                    .and_then(Value::as_str)
757                    .and_then(cardinality_from_str);
758                let inverse = spec
759                    .get("inverse")
760                    .and_then(Value::as_str)
761                    .map(str::to_string);
762                let means = spec
763                    .get("means")
764                    .and_then(Value::as_str)
765                    .map(str::to_string);
766                if cardinality.is_some() || inverse.is_some() || means.is_some() {
767                    let def = self.relation_defs.entry(name.clone()).or_default();
768                    if cardinality.is_some() {
769                        def.cardinality = cardinality;
770                    }
771                    if inverse.is_some() {
772                        def.inverse = inverse;
773                    }
774                    if means.is_some() {
775                        def.means = means;
776                    }
777                }
778            }
779        }
780        // Field declarations: `fields: { <field>: { type, values, vocabulary, reify } }`.
781        if let Some(fields) = meta.get("fields").and_then(Value::as_mapping) {
782            for (name, spec) in fields {
783                let vocabulary = spec
784                    .get("vocabulary")
785                    .and_then(Value::as_str)
786                    .map(str::to_string);
787                let ty = spec
788                    .get("type")
789                    .and_then(Value::as_str)
790                    .and_then(field_type_from_config_str);
791                // An entry that declares neither a type nor a vocabulary says
792                // nothing about the field that prov or a frontend could act on;
793                // recording it would only claim the field is described when it
794                // isn't. (`diagnose` reports the malformed spelling that most
795                // often causes this.)
796                if ty.is_none() && vocabulary.is_none() {
797                    continue;
798                }
799                let values = spec
800                    .get("values")
801                    .and_then(Value::as_str)
802                    .and_then(OpenClosed::from_config_str)
803                    .unwrap_or_default();
804                let reify = spec.get("reify").and_then(Value::as_bool).unwrap_or(false);
805                self.fields.insert(
806                    name.clone(),
807                    FieldSpec {
808                        ty,
809                        values,
810                        vocabulary,
811                        reify,
812                    },
813                );
814            }
815        }
816        // View declarations: `views: { <name>: { group, by, under, nest, … } }`.
817        //
818        // Merged per entry, exactly as `fields` is and for the same reason: a
819        // vault config that declares one view must not wipe the ones the app's
820        // defaults supplied. A later surface redeclaring a name replaces that
821        // view whole — a view is small and its keys interlock (`by` means
822        // nothing without `group`), so merging *within* one would produce
823        // hybrids no surface wrote.
824        if let Some(views) = meta.get(prov_views::VIEWS_KEY).and_then(Value::as_mapping) {
825            for (name, value) in views {
826                let Some(spec) = ViewSpec::parse(name, value) else {
827                    continue;
828                };
829                match self.views.iter_mut().find(|v| v.name == spec.name) {
830                    Some(existing) => *existing = spec,
831                    None => self.views.push(spec),
832                }
833            }
834        }
835        // Export declarations: `exports: { <name>: { gate, view, … } }`.
836        // Merged per entry like `views` — and replacement is whole for a
837        // sharper reason than key interlock: an export half-merged across two
838        // surfaces would bound what leaves with a gate neither surface wrote.
839        // An entry `parse` cannot make a gate of is dropped (fail closed — it
840        // exports nothing) and `diagnose` is where the reason surfaces.
841        if let Some(exports) = meta
842            .get(prov_exports::EXPORTS_KEY)
843            .and_then(Value::as_mapping)
844        {
845            for (name, value) in exports {
846                let Some(spec) = ExportSpec::parse(name, value) else {
847                    continue;
848                };
849                match self.exports.iter_mut().find(|e| e.name == spec.name) {
850                    Some(existing) => *existing = spec,
851                    None => self.exports.push(spec),
852                }
853            }
854        }
855        if let Some(v) = meta
856            .get("id_storage")
857            .and_then(Value::as_str)
858            .and_then(IdStorage::from_config_str)
859        {
860            self.id_storage = v;
861        }
862        if let Some(v) = meta.get("updated").and_then(Value::as_str) {
863            self.updated = v.to_string();
864        }
865        if let Some(v) = meta
866            .get("identity")
867            .and_then(Value::as_str)
868            .and_then(registration_from_str)
869        {
870            self.identity = v;
871        }
872        if let Some(v) = meta
873            .get("fixity")
874            .and_then(Value::as_str)
875            .and_then(Fixity::from_config_str)
876        {
877            self.fixity = v;
878        }
879        if let Some(v) = meta.get("recycle_bin").and_then(Value::as_bool) {
880            self.recycle_bin = v;
881        }
882        if let Some(v) = meta
883            .get("history")
884            .and_then(Value::as_str)
885            .and_then(History::from_config_str)
886        {
887            self.history = v;
888        }
889        if let Some(v) = meta
890            .get("about")
891            .and_then(Value::as_str)
892            .and_then(About::from_config_str)
893        {
894            self.about = v;
895        }
896    }
897
898    /// A fresh config with `meta`'s recognized keys applied over the defaults.
899    pub fn from_meta(meta: &Value) -> Self {
900        let mut config = Self::default();
901        config.apply(meta);
902        config
903    }
904
905    /// This config as config-document metadata keys (the nested vocabulary,
906    /// `docs/config-vocab.md`). Emitted at the top level of the config document;
907    /// the same mapping nests under `prov:` in a root's frontmatter.
908    pub fn to_mapping(&self) -> Mapping {
909        let mut map = Mapping::new();
910        map.insert("spec".into(), Value::Int(SPEC_VERSION));
911        map.insert(
912            "content_format".into(),
913            Value::String(self.content_format.as_config_str().into()),
914        );
915
916        let mut metadata = Mapping::new();
917        metadata.insert(
918            "format".into(),
919            Value::String(format_str(self.default_embed_format).into()),
920        );
921        metadata.insert(
922            "embed".into(),
923            Value::String(self.embed_style.as_config_str().into()),
924        );
925        map.insert("metadata".into(), Value::Mapping(metadata));
926
927        let mut references = Mapping::new();
928        references.insert(
929            "notation".into(),
930            Value::String(self.notation.as_config_str().into()),
931        );
932        references.insert(
933            "path_style".into(),
934            Value::String(self.path_style.as_config_str().into()),
935        );
936        references.insert(
937            "target".into(),
938            Value::String(self.reference_target.as_config_str().into()),
939        );
940        references.insert("label".into(), Value::Bool(self.reference_label));
941        map.insert("references".into(), Value::Mapping(references));
942
943        if let Some(spanning) = &self.spanning {
944            map.insert("spanning".into(), Value::String(spanning.clone()));
945        }
946
947        // One `relations` block carries both halves of each entry — style
948        // overrides and structural definitions — so the union of the two maps'
949        // keys is emitted, each entry merging whichever halves it has.
950        if !self.relation_styles.is_empty() || !self.relation_defs.is_empty() {
951            let mut names: Vec<&String> = self
952                .relation_styles
953                .keys()
954                .chain(self.relation_defs.keys())
955                .collect();
956            names.sort();
957            names.dedup();
958            let mut relations = Mapping::new();
959            for name in names {
960                let mut spec = Mapping::new();
961                if let Some(over) = self.relation_styles.get(name) {
962                    if let Some(n) = over.notation {
963                        spec.insert("notation".into(), Value::String(n.as_config_str().into()));
964                    }
965                    if let Some(p) = over.path_style {
966                        spec.insert("path_style".into(), Value::String(p.as_config_str().into()));
967                    }
968                    if let Some(t) = over.target {
969                        spec.insert("target".into(), Value::String(t.as_config_str().into()));
970                    }
971                    if let Some(l) = over.label {
972                        spec.insert("label".into(), Value::Bool(l));
973                    }
974                }
975                if let Some(def) = self.relation_defs.get(name) {
976                    if let Some(c) = def.cardinality {
977                        spec.insert(
978                            "cardinality".into(),
979                            Value::String(cardinality_str(c).into()),
980                        );
981                    }
982                    if let Some(inv) = &def.inverse {
983                        spec.insert("inverse".into(), Value::String(inv.clone()));
984                    }
985                    if let Some(m) = &def.means {
986                        spec.insert("means".into(), Value::String(m.clone()));
987                    }
988                }
989                relations.insert(name.clone(), Value::Mapping(spec));
990            }
991            map.insert("relations".into(), Value::Mapping(relations));
992        }
993
994        if !self.fields.is_empty() {
995            let mut fields = Mapping::new();
996            for (name, spec) in &self.fields {
997                let mut entry = Mapping::new();
998                if let Some(ty) = spec.ty.and_then(field_type_as_config_str) {
999                    entry.insert("type".into(), Value::String(ty.into()));
1000                }
1001                // `values` describes a vocabulary, so it is only meaningful — and
1002                // only written — alongside one.
1003                if let Some(vocabulary) = &spec.vocabulary {
1004                    entry.insert(
1005                        "values".into(),
1006                        Value::String(spec.values.as_config_str().into()),
1007                    );
1008                    entry.insert("vocabulary".into(), Value::String(vocabulary.clone()));
1009                }
1010                if spec.reify {
1011                    entry.insert("reify".into(), Value::Bool(true));
1012                }
1013                fields.insert(name.clone(), Value::Mapping(entry));
1014            }
1015            map.insert("fields".into(), Value::Mapping(fields));
1016        }
1017
1018        if !self.views.is_empty() {
1019            let mut views = Mapping::new();
1020            for spec in &self.views {
1021                views.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
1022            }
1023            map.insert(prov_views::VIEWS_KEY.into(), Value::Mapping(views));
1024        }
1025
1026        if !self.exports.is_empty() {
1027            let mut exports = Mapping::new();
1028            for spec in &self.exports {
1029                exports.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
1030            }
1031            map.insert(prov_exports::EXPORTS_KEY.into(), Value::Mapping(exports));
1032        }
1033
1034        map.insert(
1035            "id_storage".into(),
1036            Value::String(self.id_storage.as_config_str().into()),
1037        );
1038        map.insert("updated".into(), Value::String(self.updated.clone()));
1039        map.insert(
1040            "identity".into(),
1041            Value::String(registration_str(self.identity).into()),
1042        );
1043        map.insert(
1044            "fixity".into(),
1045            Value::String(self.fixity.as_config_str().into()),
1046        );
1047        map.insert("recycle_bin".into(), Value::Bool(self.recycle_bin));
1048        map.insert(
1049            "history".into(),
1050            Value::String(self.history.as_config_str().into()),
1051        );
1052        map.insert(
1053            "about".into(),
1054            Value::String(self.about.as_config_str().into()),
1055        );
1056        map.insert(
1057            "workspace_id".into(),
1058            Value::String(self.workspace_id.clone()),
1059        );
1060        map
1061    }
1062}
1063
1064// ── Config linting (`docs/config-vocab.md`, "Linting") ──────────────────────
1065
1066/// A key in a config surface that [`WorkspaceConfig::apply`] would silently
1067/// ignore — surfaced so a setting that never takes effect becomes visible rather
1068/// than staying invisible. `apply` keeps the current value whenever a key is
1069/// unrecognized or its value fails to parse; that robustness is what makes a
1070/// typo (`notaton`) or a bad value (`fixity: alll`) vanish without a word.
1071#[derive(Debug, Clone, PartialEq, Eq)]
1072pub struct ConfigIssue {
1073    /// The offending key, dotted from the block root (`references.notation`).
1074    pub key: String,
1075    /// What is wrong with it.
1076    pub kind: ConfigIssueKind,
1077}
1078
1079/// The two ways a config key goes unread. See [`ConfigIssue`].
1080#[derive(Debug, Clone, PartialEq, Eq)]
1081pub enum ConfigIssueKind {
1082    /// `key` is not a recognized axis but closely resembles `suggestion` — almost
1083    /// certainly a misspelling. An unrecognized key that resembles *no* axis at
1084    /// its level is deliberately **not** reported: a config surface can carry
1085    /// user-owned fields prov never reads (DESIGN §2), so flagging every
1086    /// unknown key would be noise.
1087    UnknownKey { suggestion: String },
1088    /// `key` is a recognized axis but `value` is not a spelling prov
1089    /// understands, so `apply` kept the default. `expected` lists the accepted
1090    /// spellings (advisory help; mirrors the axis's parser).
1091    InvalidValue {
1092        value: String,
1093        expected: Vec<String>,
1094    },
1095    /// The `spanning` relation's declared `inverse` is a relation whose
1096    /// cardinality is `many`, which cannot form the single-parent containment
1097    /// tree the spanning relation requires (DESIGN §3). `key` is `spanning`;
1098    /// `inverse` is the offending child→parent relation.
1099    SpanningNotSingleParent { inverse: String },
1100    /// A view declares `nest:` but groups by a field the workspace declares
1101    /// multi-valued (`fields.<field>.type: seq`).
1102    ///
1103    /// Nesting files a record into the single-parent spanning relation, so a
1104    /// document carrying two values for `field` has two homes and nothing can
1105    /// choose between them. The *grouping* is fine — one document under several
1106    /// groups is what a view is for — so only the filing half is reported.
1107    NestNotSingleValued { field: String },
1108    /// `workspace_id` holds a name that cannot be written as the qualifier of an
1109    /// `id:<workspace>/<id>` reference — it contains `/`, `:` or whitespace, or
1110    /// is not a string at all. `apply` ignored it, so the workspace stayed
1111    /// anonymous.
1112    ///
1113    /// An **empty** value is not this: it is the explicit spelling of anonymous,
1114    /// the way an empty `updated` spells that feature off.
1115    ///
1116    /// Unlike [`InvalidValue`](Self::InvalidValue) there is no list of accepted
1117    /// spellings to offer: the name is the user's to choose and only its *shape*
1118    /// is constrained.
1119    MalformedWorkspaceId { value: String },
1120}
1121
1122/// Top-level config keys (block names + scalar axes + the `spec` marker).
1123const TOP_KEYS: &[&str] = &[
1124    "spec",
1125    "content_format",
1126    "metadata",
1127    "references",
1128    "relations",
1129    "spanning",
1130    "fields",
1131    "views",
1132    "exports",
1133    "id_storage",
1134    "updated",
1135    "workspace_id",
1136    "identity",
1137    "fixity",
1138    "recycle_bin",
1139    "history",
1140    "about",
1141];
1142/// Keys inside the `metadata:` block.
1143const METADATA_KEYS: &[&str] = &["format", "embed"];
1144/// The reference-style keys valid in the `references:` block and in each
1145/// `relations.<name>` entry.
1146const REFERENCE_KEYS: &[&str] = &["notation", "path_style", "target", "label"];
1147/// The structural definition keys valid only in a `relations.<name>` entry
1148/// (`means` is free-form and never near-miss-matched, like `updated`).
1149const RELATION_DEF_KEYS: &[&str] = &["cardinality", "inverse", "means"];
1150/// Keys inside each `fields.<name>` entry.
1151const FIELD_KEYS: &[&str] = &["type", "values", "vocabulary", "reify"];
1152
1153/// If `meta` declares a `spec` newer than [`SPEC_VERSION`] — the version this
1154/// build understands — the declared version. The signal that prov may be
1155/// silently ignoring settings a newer prov wrote. `None` when `spec` is
1156/// absent, not an integer, or within range. Shared by `check` (a
1157/// `Finding::ConfigSpecAhead`) and the CLI's proactive config warning, so the
1158/// version comparison lives in one place.
1159pub fn spec_ahead(meta: &Value) -> Option<i64> {
1160    match meta.get("spec") {
1161        Some(Value::Int(v)) if *v > SPEC_VERSION => Some(*v),
1162        _ => None,
1163    }
1164}
1165
1166/// Diagnose a config surface (a root's `prov:` block or a config document's
1167/// top-level mapping): one [`ConfigIssue`] per key `apply` would silently ignore.
1168/// Recognized keys are checked for a value prov can parse; unrecognized keys
1169/// are reported only when they closely resemble a real axis at their level (a
1170/// likely typo). Returns empty for a clean config.
1171pub fn diagnose(meta: &Value) -> Vec<ConfigIssue> {
1172    let mut issues = Vec::new();
1173    let Some(map) = meta.as_mapping() else {
1174        return issues;
1175    };
1176    for (key, value) in map {
1177        match key.as_str() {
1178            "spec" => {} // version marker — not a policy axis
1179            "content_format" => {
1180                enum_axis(
1181                    &mut issues,
1182                    key,
1183                    value,
1184                    |s| ContentFormat::from_config_str(s).is_some(),
1185                    &["markdown", "djot", "html"],
1186                );
1187            }
1188            "id_storage" => {
1189                enum_axis(
1190                    &mut issues,
1191                    key,
1192                    value,
1193                    |s| IdStorage::from_config_str(s).is_some(),
1194                    &["registry", "frontmatter", "both"],
1195                );
1196            }
1197            "identity" => {
1198                enum_axis(
1199                    &mut issues,
1200                    key,
1201                    value,
1202                    |s| registration_from_str(s).is_some(),
1203                    &["none", "lazy", "eager"],
1204                );
1205            }
1206            "fixity" => {
1207                enum_axis(
1208                    &mut issues,
1209                    key,
1210                    value,
1211                    |s| Fixity::from_config_str(s).is_some(),
1212                    &["off", "attachments", "all"],
1213                );
1214            }
1215            "recycle_bin" => bool_axis(&mut issues, key, value),
1216            "history" => {
1217                enum_axis(
1218                    &mut issues,
1219                    key,
1220                    value,
1221                    |s| History::from_config_str(s).is_some(),
1222                    &["off", "manual"],
1223                );
1224            }
1225            "about" => {
1226                enum_axis(
1227                    &mut issues,
1228                    key,
1229                    value,
1230                    |s| About::from_config_str(s).is_some(),
1231                    &["off", "structure"],
1232                );
1233            }
1234            "updated" => {} // free-form field name
1235            // A name the user chose, constrained only in shape — it has to
1236            // survive being written as the qualifier of an `id:<ws>/<id>`
1237            // target. A non-string is malformed for the same reason.
1238            //
1239            // The empty string is *not*: it is the explicit spelling of the
1240            // default (anonymous), exactly as an empty `updated` spells the
1241            // stamping feature off. `to_mapping` writes it that way, so
1242            // flagging it would make prov's own serialized default fail its own
1243            // diagnosis.
1244            "workspace_id" => {
1245                let ok = match value.as_str() {
1246                    Some(s) => s.is_empty() || is_valid_workspace_id(s),
1247                    None => false,
1248                };
1249                if !ok {
1250                    issues.push(ConfigIssue {
1251                        key: key.clone(),
1252                        kind: ConfigIssueKind::MalformedWorkspaceId {
1253                            value: value_summary(value),
1254                        },
1255                    });
1256                }
1257            }
1258            "spanning" => {
1259                // A relation name — must be a string; its coherence with the
1260                // relations block is a cross-relation check below.
1261                if value.as_str().is_none() {
1262                    issues.push(ConfigIssue {
1263                        key: key.clone(),
1264                        kind: ConfigIssueKind::InvalidValue {
1265                            value: value_summary(value),
1266                            expected: vec!["a relation name".into()],
1267                        },
1268                    });
1269                }
1270            }
1271            "metadata" => diagnose_metadata(&mut issues, value),
1272            "references" => diagnose_reference_block(&mut issues, "references", value),
1273            "relations" => diagnose_relations(&mut issues, value),
1274            "fields" => diagnose_fields(&mut issues, value),
1275            "views" => diagnose_views(&mut issues, value, map),
1276            "exports" => diagnose_exports(&mut issues, value, map),
1277            other => {
1278                if let Some(suggestion) = nearest(other, TOP_KEYS) {
1279                    issues.push(unknown(key.clone(), suggestion));
1280                }
1281            }
1282        }
1283    }
1284    diagnose_spanning_invariant(&mut issues, map);
1285    issues
1286}
1287
1288/// The single-parent invariant (DESIGN §3): if `spanning` names a declared
1289/// relation whose declared `inverse` is itself declared with `cardinality: many`,
1290/// that inverse cannot be the child→parent side of a tree — reported so an
1291/// incoherent vocabulary is caught at author time rather than surfacing as a
1292/// runtime `DuplicateContainment` finding. Absence (an undeclared inverse, or a
1293/// spanning relation built into the vocabulary rather than declared) is left
1294/// alone — only a *declared contradiction* is flagged, never under-specification.
1295fn diagnose_spanning_invariant(issues: &mut Vec<ConfigIssue>, map: &Mapping) {
1296    let Some(spanning) = map.get("spanning").and_then(Value::as_str) else {
1297        return;
1298    };
1299    let Some(relations) = map.get("relations").and_then(Value::as_mapping) else {
1300        return;
1301    };
1302    let Some(inverse) = relations
1303        .get(spanning)
1304        .and_then(Value::as_mapping)
1305        .and_then(|r| r.get("inverse"))
1306        .and_then(Value::as_str)
1307    else {
1308        return;
1309    };
1310    let inverse_cardinality = relations
1311        .get(inverse)
1312        .and_then(Value::as_mapping)
1313        .and_then(|r| r.get("cardinality"))
1314        .and_then(Value::as_str);
1315    if inverse_cardinality == Some("many") {
1316        issues.push(ConfigIssue {
1317            key: "spanning".into(),
1318            kind: ConfigIssueKind::SpanningNotSingleParent {
1319                inverse: inverse.to_string(),
1320            },
1321        });
1322    }
1323}
1324
1325/// Diagnose the `metadata:` block.
1326fn diagnose_metadata(issues: &mut Vec<ConfigIssue>, value: &Value) {
1327    let Some(map) = value.as_mapping() else {
1328        return block_shape_issue(issues, "metadata", value);
1329    };
1330    for (key, v) in map {
1331        let dotted = format!("metadata.{key}");
1332        match key.as_str() {
1333            "format" => enum_axis(
1334                issues,
1335                &dotted,
1336                v,
1337                |s| format_from_str(s).is_some(),
1338                &embed_format_spellings(),
1339            ),
1340            "embed" => enum_axis(
1341                issues,
1342                &dotted,
1343                v,
1344                |s| EmbedStyle::from_config_str(s).is_some(),
1345                &[
1346                    "delimited",
1347                    "code_block",
1348                    "html_script",
1349                    "html_code",
1350                    "separate",
1351                ],
1352            ),
1353            other => {
1354                if let Some(sug) = nearest(other, METADATA_KEYS) {
1355                    issues.push(unknown(dotted, format!("metadata.{sug}")));
1356                }
1357            }
1358        }
1359    }
1360}
1361
1362/// Diagnose a `references:`-shaped block (the workspace default or a
1363/// `relations.<name>` entry), `prefix` dotting the reported keys.
1364fn diagnose_reference_block(issues: &mut Vec<ConfigIssue>, prefix: &str, value: &Value) {
1365    let Some(map) = value.as_mapping() else {
1366        return block_shape_issue(issues, prefix, value);
1367    };
1368    for (key, v) in map {
1369        let dotted = format!("{prefix}.{key}");
1370        match key.as_str() {
1371            "notation" => enum_axis(
1372                issues,
1373                &dotted,
1374                v,
1375                |s| Notation::from_config_str(s).is_some(),
1376                &["markdown", "wikilink", "bare"],
1377            ),
1378            "path_style" => enum_axis(
1379                issues,
1380                &dotted,
1381                v,
1382                |s| PathStyle::from_config_str(s).is_some(),
1383                &["root", "relative"],
1384            ),
1385            "target" => enum_axis(
1386                issues,
1387                &dotted,
1388                v,
1389                |s| Addressing::from_config_str(s).is_some(),
1390                &["path", "id", "alias"],
1391            ),
1392            "label" => bool_axis(issues, &dotted, v),
1393            other => {
1394                if let Some(sug) = nearest(other, REFERENCE_KEYS) {
1395                    issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1396                }
1397            }
1398        }
1399    }
1400}
1401
1402/// Diagnose the `relations:` block — a mapping of relation name to an entry that
1403/// may carry both reference-style keys and structural definition keys.
1404fn diagnose_relations(issues: &mut Vec<ConfigIssue>, value: &Value) {
1405    let Some(map) = value.as_mapping() else {
1406        return block_shape_issue(issues, "relations", value);
1407    };
1408    for (name, spec) in map {
1409        diagnose_relation_entry(issues, name, spec);
1410    }
1411}
1412
1413/// Diagnose one `relations.<name>` entry: the reference-style axes
1414/// ([`REFERENCE_KEYS`]) plus the structural definition keys
1415/// ([`RELATION_DEF_KEYS`]). `means` is free-form and accepted without check;
1416/// `cardinality` is enum-checked; `inverse` must be a string. An unknown key is
1417/// reported only when it near-misses a valid key at this level.
1418fn diagnose_relation_entry(issues: &mut Vec<ConfigIssue>, name: &str, value: &Value) {
1419    let prefix = format!("relations.{name}");
1420    let Some(map) = value.as_mapping() else {
1421        return block_shape_issue(issues, &prefix, value);
1422    };
1423    for (key, v) in map {
1424        let dotted = format!("{prefix}.{key}");
1425        match key.as_str() {
1426            "notation" => enum_axis(
1427                issues,
1428                &dotted,
1429                v,
1430                |s| Notation::from_config_str(s).is_some(),
1431                &["markdown", "wikilink", "bare"],
1432            ),
1433            "path_style" => enum_axis(
1434                issues,
1435                &dotted,
1436                v,
1437                |s| PathStyle::from_config_str(s).is_some(),
1438                &["root", "relative"],
1439            ),
1440            "target" => enum_axis(
1441                issues,
1442                &dotted,
1443                v,
1444                |s| Addressing::from_config_str(s).is_some(),
1445                &["path", "id", "alias"],
1446            ),
1447            "label" => bool_axis(issues, &dotted, v),
1448            "cardinality" => enum_axis(
1449                issues,
1450                &dotted,
1451                v,
1452                |s| cardinality_from_str(s).is_some(),
1453                &["one", "many"],
1454            ),
1455            "inverse" => {
1456                if v.as_str().is_none() {
1457                    issues.push(ConfigIssue {
1458                        key: dotted,
1459                        kind: ConfigIssueKind::InvalidValue {
1460                            value: value_summary(v),
1461                            expected: vec!["a relation name".into()],
1462                        },
1463                    });
1464                }
1465            }
1466            "means" => {} // free-form human gloss — carried, not read (§2)
1467            other => {
1468                let mut valid: Vec<&str> = REFERENCE_KEYS.to_vec();
1469                valid.extend_from_slice(RELATION_DEF_KEYS);
1470                if let Some(sug) = nearest(other, &valid) {
1471                    issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1472                }
1473            }
1474        }
1475    }
1476}
1477
1478/// Diagnose the `fields:` block — a mapping of frontmatter field name to a field
1479/// declaration (`type` / `values` / `vocabulary` / `reify`).
1480fn diagnose_fields(issues: &mut Vec<ConfigIssue>, value: &Value) {
1481    let Some(map) = value.as_mapping() else {
1482        return block_shape_issue(issues, "fields", value);
1483    };
1484    for (name, spec) in map {
1485        let prefix = format!("fields.{name}");
1486        let Some(entry) = spec.as_mapping() else {
1487            block_shape_issue(issues, &prefix, spec);
1488            continue;
1489        };
1490        for (key, v) in entry {
1491            let dotted = format!("{prefix}.{key}");
1492            match key.as_str() {
1493                "type" => enum_axis(
1494                    issues,
1495                    &dotted,
1496                    v,
1497                    |s| field_type_from_config_str(s).is_some(),
1498                    FIELD_TYPES,
1499                ),
1500                "values" => enum_axis(
1501                    issues,
1502                    &dotted,
1503                    v,
1504                    |s| OpenClosed::from_config_str(s).is_some(),
1505                    &["open", "closed"],
1506                ),
1507                "vocabulary" => {
1508                    if v.as_str().is_none() {
1509                        issues.push(ConfigIssue {
1510                            key: dotted,
1511                            kind: ConfigIssueKind::InvalidValue {
1512                                value: value_summary(v),
1513                                expected: vec!["a link to a vocabulary document".into()],
1514                            },
1515                        });
1516                    }
1517                }
1518                "reify" => bool_axis(issues, &dotted, v),
1519                other => {
1520                    if let Some(sug) = nearest(other, FIELD_KEYS) {
1521                        issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1522                    }
1523                }
1524            }
1525        }
1526    }
1527}
1528
1529/// Diagnose the `views:` block — a mapping of view name to a view declaration.
1530///
1531/// The judgment is `prov-views`' (one definition of what a view is, shared with
1532/// the crate that executes one); this is the translation into config-issue
1533/// vocabulary, plus the near-miss suggestion, which needs the edit distance
1534/// every other config near-miss already uses.
1535fn diagnose_views(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
1536    let Some(map) = value.as_mapping() else {
1537        return block_shape_issue(issues, "views", value);
1538    };
1539    for (name, spec) in map {
1540        let prefix = format!("views.{name}");
1541        diagnose_nest_is_fileable(issues, &prefix, spec, surface);
1542        for issue in prov_views::diagnose_view(name, spec) {
1543            let dotted = match issue.key.as_str() {
1544                "" => prefix.clone(),
1545                key => format!("{prefix}.{key}"),
1546            };
1547            let expected = || issue.kind.expected().iter().map(|s| (*s).into()).collect();
1548            match &issue.kind {
1549                ViewIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
1550                ViewIssueKind::NoGrouping => issues.push(ConfigIssue {
1551                    key: dotted,
1552                    kind: ConfigIssueKind::InvalidValue {
1553                        value: spec
1554                            .get("group")
1555                            .map_or_else(|| "(absent)".to_string(), value_summary),
1556                        expected: vec![
1557                            "a field name, or a list of field names to try in order".into(),
1558                        ],
1559                    },
1560                }),
1561                ViewIssueKind::BadGrain => issues.push(ConfigIssue {
1562                    key: dotted.clone(),
1563                    kind: ConfigIssueKind::InvalidValue {
1564                        value: spec
1565                            .get(&issue.key)
1566                            .map_or_else(|| "(absent)".to_string(), value_summary),
1567                        expected: expected(),
1568                    },
1569                }),
1570                ViewIssueKind::NoCondition => issues.push(ConfigIssue {
1571                    key: dotted,
1572                    kind: ConfigIssueKind::InvalidValue {
1573                        value: spec
1574                            .get("where")
1575                            .map_or_else(|| "(absent)".to_string(), value_summary),
1576                        expected: expected(),
1577                    },
1578                }),
1579                // Unlike a stray *top-level* key — which may be a user-owned
1580                // field prov never reads (DESIGN §2) — a stray key inside a
1581                // `views.<name>` entry is inside a block prov defines
1582                // completely, so a near-miss is the only thing it can be.
1583                ViewIssueKind::UnknownKey => {
1584                    if let Some(sug) = nearest(&issue.key, prov_views::VIEW_KEYS) {
1585                        issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1586                    }
1587                }
1588            }
1589        }
1590    }
1591}
1592
1593/// Flag a `nest:` on a view that groups by a field the workspace declares
1594/// **multi-valued** (`fields.<name>.type: seq`).
1595///
1596/// `nest` files a record into the spanning relation, which is single-parent, so
1597/// a document with two values for the grouping field has two homes and no way
1598/// to choose between them. Grouping by such a field is perfectly good — that is
1599/// the whole point of a view — so this flags only the *filing* half.
1600///
1601/// Reported rather than left to bite later because `nest:` is a description a
1602/// frontend acts on, so the failure surfaces at the moment someone creates a
1603/// document, which is the worst time to discover it. `ViewSpec::nest_route`
1604/// returns `None` for the same case at runtime, so the two agree.
1605///
1606/// Only fires when `fields` and `views` are declared in the **same config
1607/// surface**: `diagnose` lints one surface at a time and cannot see the merged
1608/// config, which is the same bound every other cross-key check here has.
1609fn diagnose_nest_is_fileable(
1610    issues: &mut Vec<ConfigIssue>,
1611    prefix: &str,
1612    spec: &Value,
1613    surface: &Mapping,
1614) {
1615    if spec.get("nest").is_none() {
1616        return;
1617    }
1618    let Some(fields) = surface.get("fields").and_then(Value::as_mapping) else {
1619        return;
1620    };
1621    let Some(view) = prov_views::ViewSpec::parse("", spec) else {
1622        return;
1623    };
1624    // Any key in the chain being multi-valued is enough: the chain picks
1625    // whichever is filled in, so a document could reach the `seq` one.
1626    let multi: Vec<&String> = view
1627        .group
1628        .keys
1629        .iter()
1630        .filter(|key| {
1631            fields
1632                .get(*key)
1633                .and_then(|f| f.get("type"))
1634                .and_then(Value::as_str)
1635                .and_then(field_type_from_config_str)
1636                == Some(FieldType::Seq)
1637        })
1638        .collect();
1639    if let Some(field) = multi.first() {
1640        issues.push(ConfigIssue {
1641            key: format!("{prefix}.nest"),
1642            kind: ConfigIssueKind::NestNotSingleValued {
1643                field: (*field).clone(),
1644            },
1645        });
1646    }
1647}
1648
1649/// Diagnose the `exports:` block — a mapping of export name to an export
1650/// declaration.
1651///
1652/// The judgment is `prov-exports`' (one definition of what an export is,
1653/// shared with the crate that plans one); this is the translation into
1654/// config-issue vocabulary, plus the near-miss suggestion. The stakes of the
1655/// translation are asymmetric here: a dropped export publishes *nothing*, so
1656/// every fatal issue below is a declaration someone wrote that silently does
1657/// not exist until this report says so.
1658fn diagnose_exports(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
1659    let Some(map) = value.as_mapping() else {
1660        return block_shape_issue(issues, "exports", value);
1661    };
1662    for (name, spec) in map {
1663        let prefix = format!("exports.{name}");
1664        diagnose_export_view_is_declared(issues, &prefix, spec, surface);
1665        for issue in prov_exports::diagnose_export(name, spec) {
1666            match &issue.kind {
1667                ExportIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
1668                ExportIssueKind::NoGate => issues.push(ConfigIssue {
1669                    key: format!("{prefix}.gate"),
1670                    kind: ConfigIssueKind::InvalidValue {
1671                        value: spec
1672                            .get("gate")
1673                            .map_or_else(|| "(absent)".to_string(), value_summary),
1674                        expected: vec![
1675                            "a mapping with `field` and `value` — the field a document \
1676                             declares its membership in, and the value that admits it"
1677                                .into(),
1678                        ],
1679                    },
1680                }),
1681                // A stray key inside an `exports.<name>` entry (or its gate)
1682                // is inside a block prov defines completely, so a near-miss is
1683                // the only thing it can be — same reasoning as `views`.
1684                ExportIssueKind::UnknownKey => {
1685                    if let Some(sug) = nearest(&issue.key, prov_exports::EXPORT_KEYS) {
1686                        issues.push(unknown(
1687                            format!("{prefix}.{}", issue.key),
1688                            format!("{prefix}.{sug}"),
1689                        ));
1690                    }
1691                }
1692                ExportIssueKind::GateUnknownKey => {
1693                    if let Some(sug) = nearest(&issue.key, prov_exports::GATE_KEYS) {
1694                        issues.push(unknown(
1695                            format!("{prefix}.gate.{}", issue.key),
1696                            format!("{prefix}.gate.{sug}"),
1697                        ));
1698                    }
1699                }
1700            }
1701        }
1702    }
1703}
1704
1705/// Flag an export arranged by a view its own surface does not declare.
1706///
1707/// The runtime refuses such an export outright (`prov-exports` fails closed
1708/// rather than falling back to the gate's whole set), so this is the
1709/// author-time half: reported here, the typo is fixed before the first
1710/// preview; unreported, it surfaces as a refusal at the moment someone tries
1711/// to publish, which is the worst time.
1712///
1713/// Only fires when `views` and `exports` are declared in the **same config
1714/// surface** — `diagnose` lints one surface at a time, the same bound every
1715/// other cross-key check here has.
1716fn diagnose_export_view_is_declared(
1717    issues: &mut Vec<ConfigIssue>,
1718    prefix: &str,
1719    spec: &Value,
1720    surface: &Mapping,
1721) {
1722    let Some(named) = spec.get("view").and_then(Value::as_str).map(str::trim) else {
1723        return;
1724    };
1725    let Some(views) = surface
1726        .get(prov_views::VIEWS_KEY)
1727        .and_then(Value::as_mapping)
1728    else {
1729        return;
1730    };
1731    if named.is_empty() || views.contains_key(named) {
1732        return;
1733    }
1734    let declared: Vec<String> = views.keys().cloned().collect();
1735    issues.push(ConfigIssue {
1736        key: format!("{prefix}.view"),
1737        kind: ConfigIssueKind::InvalidValue {
1738            value: named.to_string(),
1739            expected: declared,
1740        },
1741    });
1742}
1743
1744/// Flag a block key whose value is not a mapping (e.g. `references: markdown`).
1745fn block_shape_issue(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
1746    issues.push(ConfigIssue {
1747        key: key.to_string(),
1748        kind: ConfigIssueKind::InvalidValue {
1749            value: value_summary(value),
1750            expected: vec!["a block of keys".into()],
1751        },
1752    });
1753}
1754
1755/// Check an enum-valued axis, pushing an `InvalidValue` (with the accepted
1756/// spellings) when the written value does not parse.
1757fn enum_axis(
1758    issues: &mut Vec<ConfigIssue>,
1759    key: &str,
1760    value: &Value,
1761    parses: impl Fn(&str) -> bool,
1762    expected: &[&str],
1763) {
1764    if !value.as_str().is_some_and(parses) {
1765        issues.push(ConfigIssue {
1766            key: key.to_string(),
1767            kind: ConfigIssueKind::InvalidValue {
1768                value: value_summary(value),
1769                expected: expected.iter().map(|s| s.to_string()).collect(),
1770            },
1771        });
1772    }
1773}
1774
1775/// Check a bool-valued axis.
1776fn bool_axis(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
1777    if value.as_bool().is_none() {
1778        issues.push(ConfigIssue {
1779            key: key.to_string(),
1780            kind: ConfigIssueKind::InvalidValue {
1781                value: value_summary(value),
1782                expected: vec!["true".into(), "false".into()],
1783            },
1784        });
1785    }
1786}
1787
1788fn unknown(key: String, suggestion: String) -> ConfigIssue {
1789    ConfigIssue {
1790        key,
1791        kind: ConfigIssueKind::UnknownKey { suggestion },
1792    }
1793}
1794
1795/// The `metadata.format` spellings compiled into this build (yaml is always
1796/// available; the rest are feature-gated, matching [`format_from_str`]).
1797fn embed_format_spellings() -> Vec<&'static str> {
1798    // `mut` is used only when a format feature below is compiled in.
1799    #[allow(unused_mut)]
1800    let mut v = vec!["yaml"];
1801    #[cfg(feature = "json")]
1802    v.push("json");
1803    #[cfg(feature = "toml")]
1804    v.push("toml");
1805    #[cfg(feature = "fig-lang")]
1806    v.push("fig");
1807    v
1808}
1809
1810/// A short, human-readable rendering of a config value for a diagnostic message.
1811fn value_summary(value: &Value) -> String {
1812    match value {
1813        Value::String(s) => s.clone(),
1814        Value::Bool(b) => b.to_string(),
1815        Value::Int(i) => i.to_string(),
1816        Value::Float(f) => f.to_string(),
1817        _ => "(non-scalar)".to_string(),
1818    }
1819}
1820
1821/// Parse a `metadata.format` config value (`yaml`/`json`/`toml`/`fig`) into a
1822/// metadata [`fig::Format`], honoring the compiled-in formats — the public form of
1823/// [`format_from_str`], for callers that name a frontmatter language from outside
1824/// the config parser (the CLI's `convert … metadata.format …`).
1825pub fn metadata_format_from_str(value: &str) -> Option<fig::Format> {
1826    format_from_str(value)
1827}
1828
1829/// The `metadata.format` config spelling for a metadata [`fig::Format`] — the
1830/// public form of [`format_str`], and the inverse of [`metadata_format_from_str`].
1831pub fn metadata_format_str(format: fig::Format) -> &'static str {
1832    format_str(format)
1833}
1834
1835/// Parse the `metadata.format` config value into a metadata format (only the
1836/// compiled-in formats are recognized; others → `None`, keeping the default).
1837fn format_from_str(value: &str) -> Option<fig::Format> {
1838    match value {
1839        "yaml" | "yml" => Some(fig::Format::Yaml),
1840        #[cfg(feature = "json")]
1841        "json" => Some(fig::Format::Json),
1842        #[cfg(feature = "toml")]
1843        "toml" => Some(fig::Format::Toml),
1844        #[cfg(feature = "fig-lang")]
1845        "fig" => Some(fig::Format::Fig),
1846        _ => None,
1847    }
1848}
1849
1850/// The `metadata.format` config spelling for a metadata format.
1851fn format_str(format: fig::Format) -> &'static str {
1852    match format {
1853        #[cfg(feature = "json")]
1854        fig::Format::Json => "json",
1855        #[cfg(feature = "toml")]
1856        fig::Format::Toml => "toml",
1857        #[cfg(feature = "fig-lang")]
1858        fig::Format::Fig => "fig",
1859        _ => "yaml",
1860    }
1861}
1862
1863/// Parse a relation `cardinality` config value (`one`/`many`); unknown → `None`.
1864fn cardinality_from_str(value: &str) -> Option<Cardinality> {
1865    match value {
1866        "one" => Some(Cardinality::One),
1867        "many" => Some(Cardinality::Many),
1868        _ => None,
1869    }
1870}
1871
1872/// The `cardinality` config spelling for a [`Cardinality`].
1873fn cardinality_str(cardinality: Cardinality) -> &'static str {
1874    match cardinality {
1875        Cardinality::One => "one",
1876        Cardinality::Many => "many",
1877    }
1878}
1879
1880/// Parse the `identity` config value into a registration trigger set. `none` is
1881/// the canonical spelling for "identity off" (see `docs/config-vocab.md`), but
1882/// `off` is accepted as a synonym so the two never diverge: it is the word the
1883/// CLI's `--identity` flag and every other "off" axis (`fixity: off`) use, and a
1884/// user who reaches for it must not be told it is invalid.
1885fn registration_from_str(value: &str) -> Option<Registration> {
1886    match value {
1887        "none" | "off" => Some(Registration::OFF),
1888        "lazy" => Some(Registration::LAZY),
1889        "eager" => Some(Registration::EAGER),
1890        _ => None,
1891    }
1892}
1893
1894/// The `identity` config spelling for a registration trigger set. A custom
1895/// combination (not one of the three presets) is reported as its nearest name.
1896fn registration_str(registration: Registration) -> &'static str {
1897    match registration {
1898        Registration::OFF => "none",
1899        Registration::EAGER => "eager",
1900        _ => "lazy",
1901    }
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906    use super::*;
1907    use prov_identity::Trigger;
1908
1909    /// A config surface as a `Value::Mapping` from `(key, value)` pairs, values
1910    /// inferred as bools where they parse.
1911    fn config_doc(pairs: &[(&str, &str)]) -> Value {
1912        let mut map = Mapping::new();
1913        for (k, v) in pairs {
1914            let value = match *v {
1915                "true" => Value::Bool(true),
1916                "false" => Value::Bool(false),
1917                other => Value::String(other.into()),
1918            };
1919            map.insert((*k).into(), value);
1920        }
1921        Value::Mapping(map)
1922    }
1923
1924    // Uses YAML frontmatter fixtures, so it runs under the `yaml` feature.
1925    #[test]
1926    #[cfg(feature = "yaml")]
1927    fn relation_set_builds_a_custom_vocabulary_and_falls_back_to_diaryx() {
1928        use prov_graph::document::Document;
1929
1930        fn doc(text: &str) -> Document {
1931            Document::parse("index.md", text).unwrap()
1932        }
1933
1934        // No relation defs → the diaryx preset unchanged (graceful degradation).
1935        let default_set = WorkspaceConfig::default().relation_set();
1936        assert_eq!(default_set.spanning_relation(), Some("contents"));
1937        assert_eq!(default_set.registry_relation(), Some("registry"));
1938
1939        // Declared defs → a self-described `part`/`whole` vocabulary, still with
1940        // the structural pointer relations preserved.
1941        let config = WorkspaceConfig {
1942            spanning: Some("part".into()),
1943            relation_defs: BTreeMap::from([
1944                (
1945                    "part".to_string(),
1946                    RelationDef {
1947                        cardinality: Some(Cardinality::Many),
1948                        inverse: Some("whole".to_string()),
1949                        means: None,
1950                    },
1951                ),
1952                (
1953                    "whole".to_string(),
1954                    RelationDef {
1955                        cardinality: Some(Cardinality::One),
1956                        inverse: Some("part".to_string()),
1957                        means: None,
1958                    },
1959                ),
1960            ]),
1961            ..WorkspaceConfig::default()
1962        };
1963        let set = config.relation_set();
1964        assert_eq!(set.spanning_relation(), Some("part"));
1965        let d = doc("---\npart:\n- one.md\n- two.md\n---\nbody\n");
1966        assert_eq!(
1967            set.children(&fig::Value::from(&d.meta)),
1968            vec!["one.md".to_string(), "two.md".to_string()]
1969        );
1970        // Pointer relations survive a custom vocabulary so registry/config/bin
1971        // stay reachable.
1972        assert_eq!(set.registry_relation(), Some("registry"));
1973        assert!(set.relations().iter().any(|r| r.name == "recycle_bin"));
1974        assert_eq!(set.history_relation(), Some("history"));
1975        assert!(set.relations().iter().any(|r| r.name == "history"));
1976        assert_eq!(set.about_relation(), Some("about"));
1977        assert!(set.relations().iter().any(|r| r.name == "about"));
1978    }
1979
1980    #[test]
1981    fn presets_encode_the_two_styles() {
1982        // Diaryx: no identity, path addressing. Obsidian: identity + id addressing.
1983        assert_eq!(WorkspaceConfig::paths_only().identity, Registration::OFF);
1984        assert_eq!(
1985            WorkspaceConfig::paths_only().reference_target,
1986            Addressing::Path
1987        );
1988        assert!(
1989            WorkspaceConfig::stable_ids()
1990                .identity
1991                .fires_on(Trigger::Link)
1992        );
1993        assert_eq!(
1994            WorkspaceConfig::stable_ids().reference_target,
1995            Addressing::Id
1996        );
1997    }
1998
1999    #[test]
2000    fn round_trips_through_a_nested_mapping() {
2001        let config = WorkspaceConfig {
2002            identity: Registration::EAGER,
2003            notation: Notation::Bare,
2004            path_style: PathStyle::Relative,
2005            reference_target: Addressing::Id,
2006            reference_label: true,
2007            relation_styles: BTreeMap::from([
2008                (
2009                    "contents".to_string(),
2010                    RelationStyleConfig {
2011                        notation: Some(Notation::Wikilink),
2012                        path_style: None,
2013                        target: Some(Addressing::Alias),
2014                        label: None,
2015                    },
2016                ),
2017                (
2018                    "part_of".to_string(),
2019                    RelationStyleConfig {
2020                        notation: Some(Notation::Markdown),
2021                        path_style: Some(PathStyle::Relative),
2022                        target: Some(Addressing::Id),
2023                        label: Some(false),
2024                    },
2025                ),
2026            ]),
2027            spanning: Some("contents".to_string()),
2028            relation_defs: BTreeMap::from([
2029                (
2030                    "contents".to_string(),
2031                    RelationDef {
2032                        cardinality: Some(Cardinality::Many),
2033                        inverse: Some("part_of".to_string()),
2034                        means: Some("documents contained by this one".to_string()),
2035                    },
2036                ),
2037                (
2038                    "part_of".to_string(),
2039                    RelationDef {
2040                        cardinality: Some(Cardinality::One),
2041                        inverse: Some("contents".to_string()),
2042                        means: None,
2043                    },
2044                ),
2045            ]),
2046            fields: BTreeMap::from([
2047                (
2048                    "audience".to_string(),
2049                    FieldSpec {
2050                        ty: Some(FieldType::Str),
2051                        values: OpenClosed::Closed,
2052                        vocabulary: Some("[Audiences](/vocab/audiences.yaml)".to_string()),
2053                        reify: true,
2054                    },
2055                ),
2056                // A type with no vocabulary — the other half of a field
2057                // declaration, and the shape that has no `values` to write.
2058                (
2059                    "created".to_string(),
2060                    FieldSpec {
2061                        ty: Some(FieldType::Extended(ExtKind::LocalDate)),
2062                        values: OpenClosed::default(),
2063                        vocabulary: None,
2064                        reify: false,
2065                    },
2066                ),
2067            ]),
2068            views: vec![
2069                // A scoped, materializing view with a fallback chain — every
2070                // optional key populated, so nothing survives the round trip by
2071                // being absent at both ends.
2072                ViewSpec {
2073                    name: "daily".to_string(),
2074                    label: Some("Daily".to_string()),
2075                    icon: Some("calendar".to_string()),
2076                    group: prov_views::Grouping {
2077                        keys: vec!["date_of_document".to_string(), "created".to_string()],
2078                        by: Some(prov_views::Grain::Month),
2079                    },
2080                    under: Some("[Daily](id:abc1234)".to_string()),
2081                    // A condition too, so the round trip covers `where:`.
2082                    filter: Some(prov_views::Condition::Not(Box::new(
2083                        prov_views::Condition::Has("draft".to_string()),
2084                    ))),
2085                    nest: Some(prov_views::Grain::Year),
2086                },
2087                // …and the minimal one, which must not gain keys on the way
2088                // back.
2089                ViewSpec {
2090                    name: "who".to_string(),
2091                    label: None,
2092                    icon: None,
2093                    group: prov_views::Grouping::field("people"),
2094                    under: None,
2095                    filter: None,
2096                    nest: None,
2097                },
2098            ],
2099            exports: vec![
2100                // Every optional key populated, and the minimal form, for the
2101                // same reason as the two views above.
2102                ExportSpec {
2103                    name: "letters".to_string(),
2104                    label: Some("Letters home".to_string()),
2105                    gate: prov_exports::Gate {
2106                        field: "audience".to_string(),
2107                        value: "family".to_string(),
2108                    },
2109                    view: Some("daily".to_string()),
2110                },
2111                ExportSpec {
2112                    name: "notes".to_string(),
2113                    label: None,
2114                    gate: prov_exports::Gate {
2115                        field: "audience".to_string(),
2116                        value: "public".to_string(),
2117                    },
2118                    view: None,
2119                },
2120            ],
2121            id_storage: IdStorage::Frontmatter,
2122            default_embed_format: fig::Format::Yaml,
2123            embed_style: EmbedStyle::CodeBlock,
2124            content_format: ContentFormat::Djot,
2125            recycle_bin: false,
2126            fixity: Fixity::Full,
2127            // Non-default, so the round trip actually exercises the axis.
2128            history: History::Manual,
2129            // Likewise non-default — `structure` is the default, so `off` is
2130            // what proves the value survives the mapping rather than being
2131            // silently re-defaulted on the way back.
2132            about: About::Off,
2133            updated: "modified".to_string(),
2134            // Non-default (the default is anonymous), so the round trip proves
2135            // the name survives rather than being silently dropped.
2136            workspace_id: "notes".to_string(),
2137        };
2138        let back = WorkspaceConfig::from_meta(&Value::Mapping(config.to_mapping()));
2139        assert_eq!(back, config);
2140    }
2141
2142    #[test]
2143    fn per_relation_styles_resolve_over_the_workspace_default() {
2144        // The diaryx up≠down example: a workspace default target of `id`, with
2145        // `contents` (down) overridden to a nominal alias wikilink and `part_of`
2146        // (up) to a bare markdown id link — each partial overlaying the default.
2147        let mut cfg = WorkspaceConfig::default();
2148        cfg.apply(&config_doc_nested(
2149            &[("target", "id")],
2150            &[
2151                ("contents", &[("notation", "wikilink"), ("target", "alias")]),
2152                ("part_of", &[("target", "id")]),
2153            ],
2154        ));
2155
2156        let styles = cfg.resolved_relation_styles();
2157        let down = styles.get("contents").expect("contents style");
2158        assert_eq!(down.wrapper, prov_graph::link::Wrapper::Wikilink);
2159        assert_eq!(down.addressing, Addressing::Alias);
2160
2161        let up = styles.get("part_of").expect("part_of style");
2162        // Inherits the default notation (markdown), keeps its own id target.
2163        assert_eq!(up.wrapper, prov_graph::link::Wrapper::Markdown);
2164        assert_eq!(up.addressing, Addressing::Id);
2165    }
2166
2167    /// Build a config value with a top-level `references` block and a `relations`
2168    /// block of per-relation overrides.
2169    fn config_doc_nested(
2170        references: &[(&str, &str)],
2171        relations: &[(&str, &[(&str, &str)])],
2172    ) -> Value {
2173        let mut top = Mapping::new();
2174        let mut refs = Mapping::new();
2175        for (k, v) in references {
2176            refs.insert((*k).into(), Value::String((*v).into()));
2177        }
2178        top.insert("references".into(), Value::Mapping(refs));
2179        let mut rels = Mapping::new();
2180        for (name, axes) in relations {
2181            let mut spec = Mapping::new();
2182            for (k, v) in *axes {
2183                spec.insert((*k).into(), Value::String((*v).into()));
2184            }
2185            rels.insert((*name).into(), Value::Mapping(spec));
2186        }
2187        top.insert("relations".into(), Value::Mapping(rels));
2188        Value::Mapping(top)
2189    }
2190
2191    #[test]
2192    fn a_retired_canonical_path_style_is_reported_and_falls_back_to_root() {
2193        // The migration contract for a workspace still configured with the
2194        // retired value. Two things have to be true at once, and they pull in
2195        // opposite directions: the workspace must keep *loading* (an archive
2196        // that will not open because a setting was withdrawn is worse than the
2197        // setting), and it must not quietly keep resolving links the way the
2198        // broken style did.
2199        //
2200        // Falling back to `root` is what squares them. `canonical` emitted a
2201        // bare workspace-relative path that `resolve` reads directory-relative,
2202        // so it only ever resolved correctly from the workspace root; `root`
2203        // emits the same path with the leading slash that makes that reading
2204        // explicit, and resolves correctly from anywhere. `check` says so, and
2205        // `prov convert <root> link_format markdown_root -r` rewrites the
2206        // documents to match.
2207        let mut cfg = WorkspaceConfig::default();
2208        let mut refs = Mapping::new();
2209        refs.insert("path_style".into(), Value::String("canonical".into()));
2210        let mut top = Mapping::new();
2211        top.insert("references".into(), Value::Mapping(refs));
2212        let meta = Value::Mapping(top);
2213
2214        cfg.apply(&meta);
2215        assert_eq!(cfg.path_style, PathStyle::Root, "the resolvable spelling");
2216
2217        let issues = diagnose(&meta);
2218        assert!(
2219            issues.iter().any(|i| matches!(
2220                &i.kind,
2221                ConfigIssueKind::InvalidValue { value, expected }
2222                    if value.contains("canonical") && expected == &["root", "relative"]
2223            )),
2224            "{issues:?}"
2225        );
2226    }
2227
2228    #[test]
2229    fn reference_axes_orthogonalize_notation_and_resolution() {
2230        // bare + relative renders a plain directory-relative path; wikilink wraps.
2231        let mut cfg = WorkspaceConfig::default();
2232        let mut refs = Mapping::new();
2233        refs.insert("notation".into(), Value::String("bare".into()));
2234        refs.insert("path_style".into(), Value::String("relative".into()));
2235        let mut top = Mapping::new();
2236        top.insert("references".into(), Value::Mapping(refs));
2237        cfg.apply(&Value::Mapping(top));
2238        assert_eq!(cfg.link_format(), LinkStyle::PlainRelative);
2239        assert_eq!(cfg.notation, Notation::Bare);
2240        assert_eq!(cfg.path_style, PathStyle::Relative);
2241    }
2242
2243    #[test]
2244    fn apply_overlays_only_present_keys_so_the_config_document_wins() {
2245        let mut config = WorkspaceConfig::default();
2246        // Root block sets only content_format.
2247        config.apply(&config_doc(&[("content_format", "djot")]));
2248        assert_eq!(config.content_format, ContentFormat::Djot);
2249        assert_eq!(config.identity, Registration::LAZY, "identity untouched");
2250        // The config document then overrides identity; content_format preserved.
2251        config.apply(&config_doc(&[("identity", "none")]));
2252        assert_eq!(config.identity, Registration::OFF);
2253        assert_eq!(config.content_format, ContentFormat::Djot);
2254    }
2255
2256    #[test]
2257    fn diagnose_is_silent_on_a_clean_config_and_on_user_fields() {
2258        let doc = config_doc(&[
2259            ("title", "prov config"),
2260            ("part_of", "index.md"),
2261            ("id", "abc123"),
2262            ("spec", "1"),
2263            ("identity", "lazy"),
2264            ("fixity", "all"),
2265            ("recycle_bin", "false"),
2266            ("content_format", "djot"),
2267            ("id_storage", "both"),
2268            ("author", "someone"),
2269        ]);
2270        assert!(diagnose(&doc).is_empty(), "flagged: {:?}", diagnose(&doc));
2271    }
2272
2273    #[test]
2274    fn diagnose_flags_a_misspelled_top_level_key_with_a_suggestion() {
2275        let issues = diagnose(&config_doc(&[("recyle_bin", "false")]));
2276        assert_eq!(issues.len(), 1);
2277        assert_eq!(
2278            issues[0].kind,
2279            ConfigIssueKind::UnknownKey {
2280                suggestion: "recycle_bin".into()
2281            }
2282        );
2283    }
2284
2285    #[test]
2286    fn workspace_id_applies_when_well_formed_and_is_ignored_when_not() {
2287        let mut cfg = WorkspaceConfig::default();
2288        assert_eq!(cfg.workspace_id, "", "anonymous by default");
2289
2290        cfg.apply(&config_doc(&[("workspace_id", "notes")]));
2291        assert_eq!(cfg.workspace_id, "notes");
2292
2293        // A malformed value never half-lands: the previous name stands rather
2294        // than being replaced by something prov cannot write into a reference.
2295        for bad in ["with/slash", "with:colon", "with space", ""] {
2296            cfg.apply(&config_doc(&[("workspace_id", bad)]));
2297            assert_eq!(cfg.workspace_id, "notes", "rejected {bad:?}");
2298        }
2299    }
2300
2301    #[test]
2302    fn diagnose_flags_a_malformed_workspace_id_but_not_an_empty_one() {
2303        for bad in ["with/slash", "with:colon", "with space"] {
2304            let issues = diagnose(&config_doc(&[("workspace_id", bad)]));
2305            assert_eq!(
2306                issues.first().map(|i| &i.kind),
2307                Some(&ConfigIssueKind::MalformedWorkspaceId {
2308                    value: bad.to_string()
2309                }),
2310                "{bad:?}"
2311            );
2312        }
2313        // Empty is the explicit spelling of anonymous — the same shape as an
2314        // empty `updated` — so it is clean, and `to_mapping` may write it.
2315        assert!(
2316            diagnose(&config_doc(&[("workspace_id", "")])).is_empty(),
2317            "an empty name is anonymity, not an error"
2318        );
2319        assert!(diagnose(&config_doc(&[("workspace_id", "notes")])).is_empty());
2320    }
2321
2322    #[test]
2323    fn diagnose_flags_bad_values_and_typos_inside_nested_blocks() {
2324        // references.notaton (typo) + references.target bad value.
2325        let mut refs = Mapping::new();
2326        refs.insert("notaton".into(), Value::String("markdown".into()));
2327        refs.insert("target".into(), Value::String("pointer".into()));
2328        let mut top = Mapping::new();
2329        top.insert("references".into(), Value::Mapping(refs));
2330        let issues = diagnose(&Value::Mapping(top));
2331        assert!(
2332            issues.iter().any(|i| i.key == "references.notaton"
2333                && matches!(&i.kind, ConfigIssueKind::UnknownKey { suggestion } if suggestion == "references.notation")),
2334            "{issues:?}"
2335        );
2336        assert!(
2337            issues.iter().any(|i| i.key == "references.target"
2338                && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, .. } if value == "pointer")),
2339            "{issues:?}"
2340        );
2341    }
2342
2343    #[test]
2344    fn diagnose_flags_an_unrecognized_value_on_a_real_key() {
2345        let issues = diagnose(&config_doc(&[("fixity", "alll")]));
2346        assert_eq!(issues.len(), 1);
2347        match &issues[0].kind {
2348            ConfigIssueKind::InvalidValue { value, expected } => {
2349                assert_eq!(value, "alll");
2350                assert!(expected.contains(&"all".to_string()), "{expected:?}");
2351            }
2352            other => panic!("expected InvalidValue, got {other:?}"),
2353        }
2354    }
2355
2356    #[test]
2357    fn about_defaults_on_and_accepts_only_its_two_spellings() {
2358        // Default is `structure`, not `off` — the one axis that departs from
2359        // `history`'s posture, because self-description by default is the thesis.
2360        assert_eq!(WorkspaceConfig::default().about, About::Structure);
2361        assert!(About::Structure.generates());
2362        assert!(!About::Off.generates());
2363
2364        let mut cfg = WorkspaceConfig::default();
2365        cfg.apply(&config_doc(&[("about", "off")]));
2366        assert_eq!(cfg.about, About::Off);
2367
2368        // An unknown spelling is a finding that names both accepted values, and
2369        // leaves the default in place rather than guessing.
2370        let issues = diagnose(&config_doc(&[("about", "structrue")]));
2371        assert_eq!(issues.len(), 1);
2372        match &issues[0].kind {
2373            ConfigIssueKind::InvalidValue { value, expected } => {
2374                assert_eq!(value, "structrue");
2375                assert!(expected.contains(&"structure".to_string()), "{expected:?}");
2376                assert!(expected.contains(&"off".to_string()), "{expected:?}");
2377            }
2378            other => panic!("expected InvalidValue, got {other:?}"),
2379        }
2380        let mut unchanged = WorkspaceConfig::default();
2381        unchanged.apply(&config_doc(&[("about", "structrue")]));
2382        assert_eq!(unchanged.about, About::Structure);
2383    }
2384
2385    #[test]
2386    fn relation_defs_and_spanning_apply_and_round_trip() {
2387        // A fully self-described `part`/`whole` vocabulary from config.
2388        let mut top = Mapping::new();
2389        top.insert("spanning".into(), Value::String("part".into()));
2390        let mut rels = Mapping::new();
2391        let mut part = Mapping::new();
2392        part.insert("cardinality".into(), Value::String("many".into()));
2393        part.insert("inverse".into(), Value::String("whole".into()));
2394        part.insert("means".into(), Value::String("the pieces".into()));
2395        let mut whole = Mapping::new();
2396        whole.insert("cardinality".into(), Value::String("one".into()));
2397        whole.insert("inverse".into(), Value::String("part".into()));
2398        rels.insert("part".into(), Value::Mapping(part));
2399        rels.insert("whole".into(), Value::Mapping(whole));
2400        top.insert("relations".into(), Value::Mapping(rels));
2401
2402        let cfg = WorkspaceConfig::from_meta(&Value::Mapping(top));
2403        assert_eq!(cfg.spanning.as_deref(), Some("part"));
2404        let part_def = cfg.relation_defs.get("part").expect("part def");
2405        assert_eq!(part_def.cardinality, Some(Cardinality::Many));
2406        assert_eq!(part_def.inverse.as_deref(), Some("whole"));
2407        assert_eq!(part_def.means.as_deref(), Some("the pieces"));
2408        // A clean self-described vocabulary passes its own diagnosis.
2409        assert!(diagnose(&Value::Mapping(cfg.to_mapping())).is_empty());
2410    }
2411
2412    #[test]
2413    fn diagnose_flags_a_spanning_relation_whose_inverse_is_many() {
2414        // `spanning: part`, but its inverse `whole` is declared `many` — that
2415        // cannot be a single-parent tree.
2416        let mut top = Mapping::new();
2417        top.insert("spanning".into(), Value::String("part".into()));
2418        let mut rels = Mapping::new();
2419        let mut part = Mapping::new();
2420        part.insert("inverse".into(), Value::String("whole".into()));
2421        let mut whole = Mapping::new();
2422        whole.insert("cardinality".into(), Value::String("many".into()));
2423        rels.insert("part".into(), Value::Mapping(part));
2424        rels.insert("whole".into(), Value::Mapping(whole));
2425        top.insert("relations".into(), Value::Mapping(rels));
2426
2427        let issues = diagnose(&Value::Mapping(top));
2428        assert!(
2429            issues.iter().any(|i| i.key == "spanning"
2430                && matches!(&i.kind, ConfigIssueKind::SpanningNotSingleParent { inverse } if inverse == "whole")),
2431            "{issues:?}"
2432        );
2433    }
2434
2435    /// A field declaration used to require a vocabulary to exist at all. A type
2436    /// is the other, independent half: `created` is a date that nothing controls.
2437    #[test]
2438    fn a_field_may_declare_a_type_without_a_vocabulary() {
2439        let mut created = Mapping::new();
2440        created.insert("type".into(), Value::String("date".into()));
2441        let mut fields = Mapping::new();
2442        fields.insert("created".into(), Value::Mapping(created));
2443        let mut top = Mapping::new();
2444        top.insert("fields".into(), Value::Mapping(fields));
2445
2446        let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
2447        let spec = config.fields.get("created").expect("a recorded field");
2448        assert_eq!(spec.ty, Some(FieldType::Extended(ExtKind::LocalDate)));
2449        assert_eq!(spec.vocabulary, None);
2450    }
2451
2452    /// The inverse guard: an entry that declares neither is not a description of
2453    /// anything, so it is not recorded as one.
2454    #[test]
2455    fn a_field_declaring_neither_type_nor_vocabulary_is_not_recorded() {
2456        let mut empty = Mapping::new();
2457        empty.insert("reify".into(), Value::Bool(true));
2458        let mut fields = Mapping::new();
2459        fields.insert("mystery".into(), Value::Mapping(empty));
2460        let mut top = Mapping::new();
2461        top.insert("fields".into(), Value::Mapping(fields));
2462
2463        let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
2464        assert!(config.fields.is_empty(), "{:?}", config.fields);
2465    }
2466
2467    /// A `views:` block, as a config surface writes it.
2468    fn views_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
2469        let mut views = Mapping::new();
2470        for (name, keys) in entries {
2471            let mut entry = Mapping::new();
2472            for (k, v) in *keys {
2473                entry.insert((*k).into(), v.clone());
2474            }
2475            views.insert((*name).into(), Value::Mapping(entry));
2476        }
2477        let mut top = Mapping::new();
2478        top.insert("views".into(), Value::Mapping(views));
2479        Value::Mapping(top)
2480    }
2481
2482    fn str_value(text: &str) -> Value {
2483        Value::String(text.to_string())
2484    }
2485
2486    #[test]
2487    fn views_apply_in_declaration_order() {
2488        let config = WorkspaceConfig::from_meta(&views_block(&[
2489            ("daily", &[("group", str_value("created"))]),
2490            ("who", &[("group", str_value("people"))]),
2491        ]));
2492        assert_eq!(
2493            config
2494                .views
2495                .iter()
2496                .map(|v| v.name.as_str())
2497                .collect::<Vec<_>>(),
2498            ["daily", "who"]
2499        );
2500    }
2501
2502    /// The same merge rule `fields` has, for the same reason: a vault config
2503    /// declaring one view must not wipe the ones an app's defaults supplied.
2504    /// Redeclaring a name replaces that view whole rather than merging into it —
2505    /// `by` means nothing without `group`, so a key-wise merge would build a
2506    /// view neither surface wrote.
2507    #[test]
2508    fn a_later_surface_replaces_one_view_and_leaves_the_others() {
2509        let mut config = WorkspaceConfig::from_meta(&views_block(&[
2510            (
2511                "daily",
2512                &[
2513                    ("group", str_value("created")),
2514                    ("by", str_value("month")),
2515                    ("icon", str_value("calendar")),
2516                ],
2517            ),
2518            ("who", &[("group", str_value("people"))]),
2519        ]));
2520        config.apply(&views_block(&[(
2521            "daily",
2522            &[("group", str_value("date_of_document"))],
2523        )]));
2524
2525        assert_eq!(
2526            config
2527                .views
2528                .iter()
2529                .map(|v| v.name.as_str())
2530                .collect::<Vec<_>>(),
2531            ["daily", "who"],
2532            "position is kept, and the untouched view survives"
2533        );
2534        let daily = &config.views[0];
2535        assert_eq!(daily.group, prov_views::Grouping::field("date_of_document"));
2536        assert_eq!(daily.group.by, None, "replaced whole, not merged key-wise");
2537        assert_eq!(daily.icon, None);
2538    }
2539
2540    /// An entry that says nothing about grouping is not a view — and, unlike a
2541    /// silently dropped one, it is reported.
2542    #[test]
2543    fn a_view_without_a_grouping_is_not_recorded_and_is_diagnosed() {
2544        let meta = views_block(&[("daily", &[("label", str_value("Daily"))])]);
2545        assert!(WorkspaceConfig::from_meta(&meta).views.is_empty());
2546
2547        let issues = diagnose(&meta);
2548        assert_eq!(issues.len(), 1, "{issues:?}");
2549        assert_eq!(issues[0].key, "views.daily.group");
2550        assert!(matches!(
2551            &issues[0].kind,
2552            ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
2553        ));
2554    }
2555
2556    /// `ViewSpec::parse` reads an unparseable grain as no grain — it will not
2557    /// invent a cut the config did not ask for — so the view still works and
2558    /// the linter is the only thing that ever says the config was wrong.
2559    #[test]
2560    fn diagnose_flags_a_misspelled_grain_and_a_misspelled_view_key() {
2561        let issues = diagnose(&views_block(&[(
2562            "daily",
2563            &[
2564                ("group", str_value("created")),
2565                ("by", str_value("yearr")),
2566                ("labl", str_value("Daily")),
2567            ],
2568        )]));
2569        assert!(
2570            issues.iter().any(|i| i.key == "views.daily.by"
2571                && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, expected }
2572                    if value == "yearr" && expected.iter().any(|e| e == "year"))),
2573            "{issues:?}"
2574        );
2575        assert!(
2576            issues.iter().any(|i| i.key == "views.daily.labl"
2577                && i.kind
2578                    == ConfigIssueKind::UnknownKey {
2579                        suggestion: "views.daily.label".into()
2580                    }),
2581            "{issues:?}"
2582        );
2583    }
2584
2585    /// An `exports:` block, as a config surface writes it.
2586    fn exports_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
2587        let mut exports = Mapping::new();
2588        for (name, keys) in entries {
2589            let mut entry = Mapping::new();
2590            for (k, v) in *keys {
2591                entry.insert((*k).into(), v.clone());
2592            }
2593            exports.insert((*name).into(), Value::Mapping(entry));
2594        }
2595        let mut top = Mapping::new();
2596        top.insert("exports".into(), Value::Mapping(exports));
2597        Value::Mapping(top)
2598    }
2599
2600    fn gate_value(field: &str, value: &str) -> Value {
2601        let mut gate = Mapping::new();
2602        gate.insert("field".into(), str_value(field));
2603        gate.insert("value".into(), str_value(value));
2604        Value::Mapping(gate)
2605    }
2606
2607    #[test]
2608    fn exports_apply_and_round_trip() {
2609        let config = WorkspaceConfig::from_meta(&exports_block(&[
2610            (
2611                "letters",
2612                &[
2613                    ("gate", gate_value("audience", "family")),
2614                    ("view", str_value("daily")),
2615                ],
2616            ),
2617            ("notes", &[("gate", gate_value("audience", "public"))]),
2618        ]));
2619        assert_eq!(
2620            config
2621                .exports
2622                .iter()
2623                .map(|e| e.name.as_str())
2624                .collect::<Vec<_>>(),
2625            ["letters", "notes"]
2626        );
2627        assert_eq!(config.exports[0].gate.field, "audience");
2628        assert_eq!(config.exports[0].view.as_deref(), Some("daily"));
2629
2630        let written = config.to_mapping();
2631        let reread = WorkspaceConfig::from_meta(&Value::Mapping(written));
2632        assert_eq!(reread.exports, config.exports);
2633    }
2634
2635    /// The same whole-entry replacement `views` has, for a sharper reason: an
2636    /// export half-merged across two surfaces would bound what leaves with a
2637    /// gate neither surface wrote.
2638    #[test]
2639    fn a_later_surface_replaces_one_export_whole() {
2640        let mut config = WorkspaceConfig::from_meta(&exports_block(&[(
2641            "letters",
2642            &[
2643                ("gate", gate_value("audience", "family")),
2644                ("view", str_value("daily")),
2645            ],
2646        )]));
2647        config.apply(&exports_block(&[(
2648            "letters",
2649            &[("gate", gate_value("audience", "friends"))],
2650        )]));
2651
2652        assert_eq!(config.exports.len(), 1);
2653        assert_eq!(config.exports[0].gate.value, "friends");
2654        assert_eq!(
2655            config.exports[0].view, None,
2656            "replaced whole, not merged key-wise"
2657        );
2658    }
2659
2660    /// A dropped export publishes nothing, silently — the report is the only
2661    /// thing that ever says the declaration does not exist.
2662    #[test]
2663    fn an_export_without_a_gate_is_not_recorded_and_is_diagnosed() {
2664        let meta = exports_block(&[("letters", &[("view", str_value("daily"))])]);
2665        assert!(WorkspaceConfig::from_meta(&meta).exports.is_empty());
2666
2667        let issues = diagnose(&meta);
2668        assert_eq!(issues.len(), 1, "{issues:?}");
2669        assert_eq!(issues[0].key, "exports.letters.gate");
2670        assert!(matches!(
2671            &issues[0].kind,
2672            ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
2673        ));
2674    }
2675
2676    #[test]
2677    fn diagnose_flags_misspelled_export_keys_at_both_levels() {
2678        let mut gate = Mapping::new();
2679        gate.insert("field".into(), str_value("audience"));
2680        gate.insert("valeu".into(), str_value("family"));
2681        let issues = diagnose(&exports_block(&[(
2682            "letters",
2683            &[("gate", Value::Mapping(gate)), ("veiw", str_value("daily"))],
2684        )]));
2685        assert!(
2686            issues.iter().any(|i| i.kind
2687                == ConfigIssueKind::UnknownKey {
2688                    suggestion: "exports.letters.view".into()
2689                }),
2690            "{issues:?}"
2691        );
2692        assert!(
2693            issues.iter().any(|i| i.kind
2694                == ConfigIssueKind::UnknownKey {
2695                    suggestion: "exports.letters.gate.value".into()
2696                }),
2697            "{issues:?}"
2698        );
2699    }
2700
2701    /// The runtime refuses an export whose view nobody declares (fail closed);
2702    /// this is the author-time half, so the typo is fixed before the first
2703    /// preview rather than at the moment someone tries to publish.
2704    #[test]
2705    fn diagnose_flags_an_export_arranged_by_an_undeclared_view() {
2706        let mut top = Mapping::new();
2707        let Value::Mapping(views) = views_block(&[("daily", &[("group", str_value("created"))])])
2708        else {
2709            unreachable!()
2710        };
2711        let Value::Mapping(exports) = exports_block(&[(
2712            "letters",
2713            &[
2714                ("gate", gate_value("audience", "family")),
2715                ("view", str_value("dialy")),
2716            ],
2717        )]) else {
2718            unreachable!()
2719        };
2720        for (k, v) in views.iter().chain(exports.iter()) {
2721            top.insert(k.clone(), v.clone());
2722        }
2723
2724        let issues = diagnose(&Value::Mapping(top));
2725        assert_eq!(issues.len(), 1, "{issues:?}");
2726        assert_eq!(issues[0].key, "exports.letters.view");
2727        assert!(
2728            matches!(
2729                &issues[0].kind,
2730                ConfigIssueKind::InvalidValue { value, expected }
2731                    if value == "dialy" && expected == &vec!["daily".to_string()]
2732            ),
2733            "{issues:?}"
2734        );
2735
2736        // And the same export beside no `views:` block is silent — one
2737        // surface at a time, the bound every cross-key check here has.
2738        let issues = diagnose(&exports_block(&[(
2739            "letters",
2740            &[
2741                ("gate", gate_value("audience", "family")),
2742                ("view", str_value("dialy")),
2743            ],
2744        )]));
2745        assert!(issues.is_empty(), "{issues:?}");
2746    }
2747
2748    /// `nest` files into the single-parent spine, so a document with two values
2749    /// for the grouping field has two homes. Grouping by it is fine — only the
2750    /// filing half is reported.
2751    #[test]
2752    fn diagnose_flags_a_nest_on_a_multi_valued_field() {
2753        let block = |view: &[(&str, Value)]| {
2754            let mut fields = Mapping::new();
2755            let mut people = Mapping::new();
2756            people.insert("type".into(), str_value("seq"));
2757            fields.insert("people".into(), Value::Mapping(people));
2758
2759            let mut views = Mapping::new();
2760            let mut entry = Mapping::new();
2761            for (k, v) in view {
2762                entry.insert((*k).into(), v.clone());
2763            }
2764            views.insert("who".into(), Value::Mapping(entry));
2765
2766            let mut top = Mapping::new();
2767            top.insert("fields".into(), Value::Mapping(fields));
2768            top.insert("views".into(), Value::Mapping(views));
2769            Value::Mapping(top)
2770        };
2771
2772        let issues = diagnose(&block(&[
2773            ("group", str_value("people")),
2774            ("nest", str_value("initial")),
2775        ]));
2776        assert_eq!(issues.len(), 1, "{issues:?}");
2777        assert_eq!(issues[0].key, "views.who.nest");
2778        assert_eq!(
2779            issues[0].kind,
2780            ConfigIssueKind::NestNotSingleValued {
2781                field: "people".into()
2782            }
2783        );
2784
2785        // The same view without `nest:` is clean — one document under several
2786        // groups is what a view is *for*.
2787        assert!(
2788            diagnose(&block(&[("group", str_value("people"))])).is_empty(),
2789            "grouping by a multi-valued field is not the problem"
2790        );
2791    }
2792
2793    /// The bound worth knowing: `diagnose` lints one surface at a time, so the
2794    /// cross-key check is silent when `fields` and `views` are declared apart.
2795    #[test]
2796    fn the_nest_check_is_silent_across_two_config_surfaces() {
2797        let mut views = Mapping::new();
2798        let mut entry = Mapping::new();
2799        entry.insert("group".into(), str_value("people"));
2800        entry.insert("nest".into(), str_value("initial"));
2801        views.insert("who".into(), Value::Mapping(entry));
2802        let mut top = Mapping::new();
2803        top.insert("views".into(), Value::Mapping(views));
2804
2805        assert!(
2806            diagnose(&Value::Mapping(top)).is_empty(),
2807            "no `fields` in this surface to contradict it"
2808        );
2809    }
2810
2811    #[test]
2812    fn diagnose_flags_a_views_block_that_is_not_a_block() {
2813        let mut top = Mapping::new();
2814        top.insert("views".into(), Value::String("daily".into()));
2815        let issues = diagnose(&Value::Mapping(top));
2816        assert_eq!(issues.len(), 1);
2817        assert_eq!(issues[0].key, "views");
2818
2819        let issues = diagnose(&views_block(&[]));
2820        assert!(issues.is_empty(), "an empty block is clean: {issues:?}");
2821    }
2822
2823    #[test]
2824    fn every_field_type_spelling_round_trips() {
2825        for spelling in FIELD_TYPES {
2826            let ty = field_type_from_config_str(spelling)
2827                .unwrap_or_else(|| panic!("{spelling} is offered but does not parse"));
2828            assert_eq!(field_type_as_config_str(ty), Some(*spelling));
2829        }
2830    }
2831
2832    #[test]
2833    fn diagnose_flags_an_unknown_field_type_and_offers_the_near_miss() {
2834        let mut created = Mapping::new();
2835        created.insert("type".into(), Value::String("datetime2".into()));
2836        let mut fields = Mapping::new();
2837        fields.insert("created".into(), Value::Mapping(created));
2838        let mut top = Mapping::new();
2839        top.insert("fields".into(), Value::Mapping(fields));
2840
2841        let issues = diagnose(&Value::Mapping(top));
2842        assert!(
2843            issues.iter().any(|i| i.key == "fields.created.type"
2844                && matches!(
2845                    &i.kind,
2846                    ConfigIssueKind::InvalidValue { expected, .. }
2847                        if expected.iter().any(|e| e == "datetime")
2848                )),
2849            "{issues:?}"
2850        );
2851    }
2852
2853    #[test]
2854    fn diagnose_flags_bad_field_and_relation_def_values() {
2855        // fields.audience.values bad + a relations def with bad cardinality.
2856        let mut top = Mapping::new();
2857        let mut fields = Mapping::new();
2858        let mut audience = Mapping::new();
2859        audience.insert("values".into(), Value::String("secret".into())); // not open/closed
2860        audience.insert("vocabulary".into(), Value::String("/vocab/aud.yaml".into()));
2861        fields.insert("audience".into(), Value::Mapping(audience));
2862        top.insert("fields".into(), Value::Mapping(fields));
2863        let mut rels = Mapping::new();
2864        let mut c = Mapping::new();
2865        c.insert("cardinality".into(), Value::String("two".into())); // not one/many
2866        rels.insert("contents".into(), Value::Mapping(c));
2867        top.insert("relations".into(), Value::Mapping(rels));
2868
2869        let issues = diagnose(&Value::Mapping(top));
2870        assert!(
2871            issues.iter().any(|i| i.key == "fields.audience.values"),
2872            "{issues:?}"
2873        );
2874        assert!(
2875            issues
2876                .iter()
2877                .any(|i| i.key == "relations.contents.cardinality"),
2878            "{issues:?}"
2879        );
2880    }
2881
2882    #[test]
2883    fn spec_ahead_fires_only_for_a_newer_spec() {
2884        assert_eq!(
2885            spec_ahead(&config_doc(&[("identity", "lazy")])),
2886            None,
2887            "absent spec"
2888        );
2889        let at = {
2890            let mut m = Mapping::new();
2891            m.insert("spec".into(), Value::Int(SPEC_VERSION));
2892            Value::Mapping(m)
2893        };
2894        assert_eq!(spec_ahead(&at), None, "current spec is fine");
2895        let ahead = {
2896            let mut m = Mapping::new();
2897            m.insert("spec".into(), Value::Int(SPEC_VERSION + 1));
2898            Value::Mapping(m)
2899        };
2900        assert_eq!(spec_ahead(&ahead), Some(SPEC_VERSION + 1));
2901    }
2902
2903    #[test]
2904    fn serialized_defaults_and_presets_all_pass_diagnosis() {
2905        for config in [
2906            WorkspaceConfig::default(),
2907            WorkspaceConfig::paths_only(),
2908            WorkspaceConfig::stable_ids(),
2909        ] {
2910            let serialized = Value::Mapping(config.to_mapping());
2911            assert!(
2912                diagnose(&serialized).is_empty(),
2913                "flagged itself: {:?}",
2914                diagnose(&serialized)
2915            );
2916        }
2917    }
2918}