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