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