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
24use std::collections::BTreeMap;
25
26use fig::ExtKind;
27use fig_schema::FieldType;
28
29use crate::textdist::nearest;
30use prov_exports::{ExportIssueKind, ExportSpec};
31use prov_graph::content::ContentFormat;
32use prov_graph::document::EmbedStyle;
33pub use prov_graph::fixity::Fixity;
34use prov_graph::identity::{Registration, Trigger};
35use prov_graph::link::{Addressing, LinkStyle, Notation, PathStyle, ReferenceStyle};
36use prov_graph::meta::{Mapping, Value};
37use prov_graph::relation::{Cardinality, Relation, RelationSet};
38use prov_views::{ViewIssueKind, ViewSpec};
39
40/// Where a document's stable id is persisted. Defined in `prov-graph`, because
41/// it is the one identity setting that changes what a link *resolves to* — a
42/// reader has to know whether frontmatter is a place an id can be found.
43pub use prov_graph::identity::IdStorage;
44
45/// The config-vocabulary version stamped as `spec` and recognized on read — a
46/// marker so a foreign tool (or a future prov) knows which vocabulary it is
47/// looking at. Bumped only on an incompatible reshape.
48pub const SPEC_VERSION: i64 = 1;
49
50/// The root-frontmatter key under which workspace policy is nested. A root
51/// document's frontmatter mixes structural links, identity, and user-owned
52/// fields with the occasional policy setting; nesting policy under this one key
53/// keeps the two apart, so config is unambiguous to read *and* to lint, and an
54/// unrecognized *sibling* is never mistaken for a misspelled setting. The
55/// dedicated config document needs no such wrapper — the whole document is policy
56/// (`docs/config-vocab.md`, "The two homes").
57pub const ROOT_CONFIG_KEY: &str = "prov";
58
59/// A per-relation reference-style override, as declared in a config's
60/// `relations` block. Each axis is optional and inherits the workspace default
61/// ([`WorkspaceConfig::reference_style`]) when absent — so a block need only name
62/// the axes it changes. This is the config form of
63/// [`Relation::style`](prov_graph::relation::Relation::style), and what lets links
64/// going "down" (`contents`) differ from links going "up" (`part_of`).
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
66pub struct RelationStyleConfig {
67    /// The notation override (`markdown` / `wikilink` / `bare`).
68    pub notation: Option<Notation>,
69    /// The path-resolution override (`root` / `relative`).
70    pub path_style: Option<PathStyle>,
71    /// The addressing override (`path` / `id` / `alias`).
72    pub target: Option<Addressing>,
73    /// The `id`-wikilink label override.
74    pub label: Option<bool>,
75}
76
77/// A relation *definition* declared in a config's `relations` block — the
78/// structural half of an entry, parallel to the reference-style half
79/// ([`RelationStyleConfig`]). This is what makes a workspace's vocabulary
80/// **self-describing** (DESIGN §1, the `prov/1` spec): a foreign reader learns
81/// the graph — which fields are relations, their inverse, their cardinality —
82/// from the document itself rather than assuming prov's `contents`/`part_of`
83/// preset. Each field is optional; a `relations` entry may carry only style, only
84/// definition, or both.
85///
86/// A definition **overlays** the built-in vocabulary rather than replacing it
87/// ([`WorkspaceConfig::relation_set`]): a name the preset does not have is added,
88/// a name it has is redefined per field — unsaid halves inherited from the
89/// preset's own definition — and [`off`](Self::off) retracts one.
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct RelationDef {
92    /// How many targets the field may hold (`one` / `many`). `None` inherits the
93    /// preset relation's cardinality when this def overlays one, and otherwise
94    /// falls to `many`, the permissive choice.
95    pub cardinality: Option<Cardinality>,
96    /// The reciprocal relation's field name, bidirectionally maintained.
97    pub inverse: Option<String>,
98    /// A free-form, human-facing gloss of what the relation means. prov never
99    /// reads this back (DESIGN §2, tier 3) — it is documentation that travels with
100    /// the data so a person reading the frontmatter learns the vocabulary too.
101    pub means: Option<String>,
102    /// The entry states that this name is **not** a relation in this workspace
103    /// (`relations: { link_of: off }`) — spelled as the scalar `off`, the house
104    /// word for machinery that is not in use. The name loses whatever the
105    /// built-in vocabulary gave it, so a document key by that name is an
106    /// ordinary user field prov carries and never follows (DESIGN §2, tier 3).
107    ///
108    /// The other fields are meaningless beside it: an entry that retracts a name
109    /// has no cardinality, no inverse and nothing to gloss. Retracting one of the
110    /// five **pointer** names (`registry`/`config`/`deletions`/`history`/`about`)
111    /// takes it out of the vocabulary but not out of the machinery — prov still
112    /// reads the root's key by that name to find the thing it points at.
113    pub off: bool,
114}
115
116/// Whether a controlled `fields` vocabulary is *open* (folksonomy — unknown
117/// values are allowed, only near-misses warn) or *closed* (every value must be a
118/// known term; an unknown value is an error). See the `fields` block and
119/// [`crate::vocabulary`].
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
121pub enum OpenClosed {
122    /// Unknown values allowed; `check` warns only on a probable typo of a known
123    /// term (casing/spelling drift).
124    #[default]
125    Open,
126    /// Every value must resolve to a known term; an unknown value is a hard
127    /// `check` finding. The right posture for a safety-critical vocabulary (a
128    /// diaryx `audience`, where a typo is a disclosure bug).
129    Closed,
130}
131
132impl OpenClosed {
133    /// Parse the `values` config spelling; unknown → `None`.
134    pub fn from_config_str(value: &str) -> Option<Self> {
135        match value {
136            "open" => Some(Self::Open),
137            "closed" => Some(Self::Closed),
138            _ => None,
139        }
140    }
141
142    /// The `values` config spelling.
143    pub fn as_config_str(self) -> &'static str {
144        match self {
145            Self::Open => "open",
146            Self::Closed => "closed",
147        }
148    }
149}
150
151/// A field declaration — an entry in the `fields` block. It promotes a
152/// frontmatter field (`tags`, `audience`, `created`) that prov would otherwise
153/// merely carry (DESIGN §2, tier 3) into something prov and its frontends know
154/// the shape of. Two independent things can be declared, and a field needs at
155/// least one of them to be worth an entry:
156///
157/// - **A type** ([`ty`](Self::ty)) — what the value *is*. Pure data shape,
158///   decidable from the value alone, so it is spelled in `fig-schema`'s
159///   vocabulary rather than one prov invents.
160/// - **A vocabulary** ([`vocabulary`](Self::vocabulary)) — which values are
161///   *legal*, turning the field into a resolvable reference prov keeps
162///   consistent: every value is checked against the vocabulary document the
163///   pointer reaches.
164///
165/// They compose (a closed vocabulary of strings is both), but neither implies
166/// the other: `created` is a date with no vocabulary, and a vocabulary field
167/// needs no declared type.
168// `PartialEq` without `Eq`: a starting value is a [`Value`], and a float has no
169// total equality. What the equality is for — the config round-trip tests, a
170// frontend asking whether two configs differ — needs only the partial one.
171#[derive(Debug, Clone, PartialEq)]
172pub struct FieldSpec {
173    /// The type the field's values are expected to take, if declared. Drives
174    /// type-directed parsing and widget choice in a frontend (a `date` field
175    /// gets a date picker); prov itself carries it without interpreting it.
176    pub ty: Option<FieldType>,
177    /// Whether the value set is open (folksonomy) or closed (must be known).
178    /// Meaningful only alongside a [`vocabulary`](Self::vocabulary).
179    pub values: OpenClosed,
180    /// The pointer (a link) to the vocabulary document listing this field's legal
181    /// terms — resolved like the `registry`/`config` pointers (DESIGN §6). `None`
182    /// for a field that declares a type but no controlled vocabulary.
183    pub vocabulary: Option<String>,
184    /// Whether each term is reified as its own node (rich: backlinks, a prose
185    /// body, stable id) rather than a bare key in a flat registry. A hint to
186    /// tooling; prov validates membership either way.
187    pub reify: bool,
188    /// The value a **new** document opens with in this field, if the
189    /// declaration names one — `status: open` on a task the moment it is
190    /// made. Written by `create` and never read back: it is a starting
191    /// value, not a rule about the field, and a document that unsets or
192    /// changes it is not wrong. Carried in the workspace rather than in a
193    /// caller's flags so that a stencil can state it and `about.md` can say
194    /// it.
195    pub default: Option<Value>,
196    /// The subtree this declaration governs, as a link to its index — by
197    /// path, by `id:`, or by title (`[[Tasks]]`), resolved exactly as a view's
198    /// `under:` is. `None` governs the whole workspace. A field may carry
199    /// several declarations, each scoped, so that `status` means one closed
200    /// set of terms under `Tasks` and another under `Proposals`; where scopes
201    /// nest, the deepest wins, and an unscoped declaration is the fallback.
202    /// The index itself is not in its own scope, for the reason a view's
203    /// anchor is not one of its records.
204    pub under: Option<String>,
205}
206
207/// The config spellings of [`FieldType`], in the order a diagnostic offers them.
208///
209/// A deliberate subset of `fig-schema`'s type vocabulary: the kinds a *document
210/// field* can meaningfully declare. `fig`'s remaining extended kinds
211/// (`EnumLiteral`, `CharLiteral`, `NumberSpecial`) are artifacts of particular
212/// serializations — ZON, JSON5 — rather than things a workspace declares about
213/// its own metadata, so they get no spelling here.
214pub const FIELD_TYPES: &[&str] = &[
215    "str",
216    "bool",
217    "int",
218    "float",
219    "date",
220    "datetime",
221    "local-datetime",
222    "time",
223    "ref",
224    "map",
225    "seq",
226];
227
228/// Parse a `fields.<name>.type` spelling into a [`FieldType`]; unknown → `None`.
229///
230/// A free function rather than an inherent method because [`FieldType`] is
231/// `fig-schema`'s type, not prov's — but the shape mirrors
232/// [`OpenClosed::from_config_str`] and its siblings, since this is the same kind
233/// of config-vocabulary translation.
234///
235/// The date/time spellings map onto `fig`'s extended scalars, which round-trip
236/// as a format's *native* date where the format has one (a TOML `1979-05-27`
237/// stays a date rather than becoming a quoted string) and as plain unquoted text
238/// where it does not (YAML frontmatter, where the same value reads back as a
239/// string — harmless, since a rule is matched by path, not by value type).
240pub fn field_type_from_config_str(value: &str) -> Option<FieldType> {
241    Some(match value {
242        "str" => FieldType::Str,
243        "bool" => FieldType::Bool,
244        "int" => FieldType::Int,
245        "float" => FieldType::Float,
246        // An instant carrying its offset — the archivally honest default, and
247        // what `updated:` stamps.
248        "datetime" => FieldType::Extended(ExtKind::OffsetDateTime),
249        "local-datetime" => FieldType::Extended(ExtKind::LocalDateTime),
250        "date" => FieldType::Extended(ExtKind::LocalDate),
251        "time" => FieldType::Extended(ExtKind::LocalTime),
252        "ref" => FieldType::Ref,
253        "map" => FieldType::Map,
254        "seq" => FieldType::Seq,
255        _ => return None,
256    })
257}
258
259/// The `fields.<name>.type` spelling of a [`FieldType`], or `None` for a type
260/// with no config spelling (see [`FIELD_TYPES`]) — such a type is dropped on
261/// serialization rather than written as something that would not read back.
262pub fn field_type_as_config_str(ty: FieldType) -> Option<&'static str> {
263    Some(match ty {
264        FieldType::Str => "str",
265        FieldType::Bool => "bool",
266        FieldType::Int => "int",
267        FieldType::Float => "float",
268        FieldType::Ref => "ref",
269        FieldType::Map => "map",
270        FieldType::Seq => "seq",
271        FieldType::Extended(ExtKind::OffsetDateTime) => "datetime",
272        FieldType::Extended(ExtKind::LocalDateTime) => "local-datetime",
273        FieldType::Extended(ExtKind::LocalDate) => "date",
274        FieldType::Extended(ExtKind::LocalTime) => "time",
275        FieldType::Null | FieldType::Extended(_) => return None,
276        // `FieldType` is `#[non_exhaustive]` upstream, so a version of
277        // fig-schema newer than this one may name a type prov has no config
278        // spelling for. That is the same case as `Null`: no spelling, so it is
279        // dropped rather than written as something that would not read back.
280        _ => return None,
281    })
282}
283
284/// Whether the workspace generates **`about.md`** — a short prose page,
285/// specialized against this workspace's own configuration, that tells a reader
286/// with no prior knowledge how to read *this* directory.
287///
288/// The gap it closes is narrow and specific. A prov workspace already explains
289/// its *structure* — the links are in the documents, visibly — but not its
290/// *conventions*: what the links mean, how they are spelled, which files are in
291/// the tree and which are not. Those live in the config, which is machine-facing
292/// and assumes the reader already knows what its keys mean. So a person who
293/// opens the directory with no prior knowledge cannot today learn to read it
294/// *from* the directory; they must obtain `docs/spec.md`, which is a dependency
295/// on an institution surviving — exactly the dependency the project refuses
296/// everywhere else.
297///
298/// The page is **not** a vendored copy of the spec. It is the spec *specialized*
299/// against this configuration: every rule resolved to a concrete fact, every
300/// branch this workspace does not take deleted. Where the spec says "the block
301/// is fenced by `---`, `;;;`, or ```` ```fig ````," the generated page says
302/// "every file here opens with a `---` line." Nothing is lost operationally, and
303/// the sentence is about *this directory* rather than about prov.
304///
305/// Default **on**: it costs a few hundred bytes and one file, and a workspace
306/// that explains itself to a stranger by default is the whole thesis — making
307/// it opt-in concedes it.
308#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
309pub enum About {
310    /// No page is generated and the root declares no `about` pointer (`off`).
311    Off,
312    /// Generate the page describing the workspace's **structure** (`structure`,
313    /// the default): the root and the spine; how a file is fenced; how a
314    /// reference is written and what else is read; the relation vocabulary; what
315    /// is machinery and not in the tree; the id, checksum and deletion
316    /// conventions.
317    #[default]
318    Structure,
319}
320
321impl About {
322    /// Whether a page is generated at all.
323    pub fn generates(self) -> bool {
324        matches!(self, About::Structure)
325    }
326
327    /// Parse the `about` config spelling; unknown → `None`.
328    pub fn from_config_str(value: &str) -> Option<Self> {
329        match value {
330            "off" => Some(Self::Off),
331            "structure" => Some(Self::Structure),
332            _ => None,
333        }
334    }
335
336    /// The `about` config spelling.
337    pub fn as_config_str(self) -> &'static str {
338        match self {
339            Self::Off => "off",
340            Self::Structure => "structure",
341        }
342    }
343}
344
345/// The workspace-wide policy a config declares.
346///
347/// `PartialEq` without `Eq`, for the reason [`FieldSpec`] gives: a field's
348/// starting value may be a float.
349#[derive(Debug, Clone, PartialEq)]
350pub struct WorkspaceConfig {
351    /// When a document earns a stable ID — the identity registration triggers.
352    pub identity: Registration,
353    /// The default reference **notation** (`markdown` / `wikilink` / `bare`).
354    /// Overridden per relation by [`Relation::style`](prov_graph::relation::Relation::style).
355    pub notation: Notation,
356    /// The default **path resolution** for path targets (`root` / `relative` /
357    /// Ignored for id/alias targets.
358    pub path_style: PathStyle,
359    /// The default reference **addressing** (`path` / `id` / `alias`).
360    pub reference_target: Addressing,
361    /// Whether an id/alias reference carries a `|Title` label.
362    pub reference_label: bool,
363    /// Per-relation reference-style overrides, keyed by relation name — the
364    /// config form of [`Relation::style`](prov_graph::relation::Relation::style).
365    /// Each entry overlays the workspace default for that relation only, letting
366    /// `contents` (down) and `part_of` (up) carry different styles. Empty means
367    /// every relation inherits the default. Resolve with
368    /// [`resolved_relation_styles`](Self::resolved_relation_styles).
369    pub relation_styles: BTreeMap<String, RelationStyleConfig>,
370    /// The name of the **spanning** relation — the single-parent containment tree
371    /// that is the workspace's discovery spine (DESIGN §3). `None` leaves it to
372    /// the built vocabulary's default. Declaring it in config is what lets a
373    /// non-diaryx vocabulary name its own spine.
374    pub spanning: Option<String>,
375    /// Per-relation structural **definitions**, keyed by relation name — the
376    /// self-describing half of the `relations` block (cardinality, inverse,
377    /// human gloss). Empty means the workspace uses its built-in vocabulary
378    /// (diaryx) unchanged. Consumed by [`relation_set`](Self::relation_set).
379    pub relation_defs: BTreeMap<String, RelationDef>,
380    /// Field declarations, keyed by frontmatter field name (`tags`,
381    /// `audience`), each a list because a field may be declared once for the
382    /// whole workspace or several times, each under an index
383    /// ([`FieldSpec::under`]). Written as a bare mapping when there is one
384    /// unscoped declaration, and as a sequence otherwise. Empty means no
385    /// field is described — every such field is ordinary carried content
386    /// (DESIGN §2, tier 3). Which declaration governs a given document is a
387    /// question about the spanning tree, answered by `prov`'s `Workspace`;
388    /// [`field`](Self::field) answers the workspace-wide half.
389    pub fields: BTreeMap<String, Vec<FieldSpec>>,
390    /// The views the workspace declares, in declaration order — the second way
391    /// through the same documents the spine already holds ("the entries under
392    /// `Daily`, by month"). Empty means the workspace declares none, which is
393    /// not the same as having none to offer: a frontend is free to derive a
394    /// lens from a `fields` declaration, and a *declared* view is the workspace
395    /// overriding that.
396    ///
397    /// prov reads them and never acts on one. A view has no invariant to keep,
398    /// so nothing in `check` can be violated by a wrong one — it is carried
399    /// here so that every tool over the workspace reads the same views, rather
400    /// than each app namespacing its own block and agreeing by convention.
401    /// Executing one is `prov-views`.
402    pub views: Vec<ViewSpec>,
403    /// The exports the workspace declares, in declaration order — the named,
404    /// closed-by-default sets that may *leave* it, each bounded by a gate and
405    /// optionally arranged by one of [`views`](Self::views). Empty means
406    /// nothing is declared exportable, which is the default state of a
407    /// workspace and of every document in it.
408    ///
409    /// Carried here for the same reason `views` is — one axis every tool
410    /// reads — but unlike a view an export *has* an invariant, and it lives
411    /// with the planner in `prov-exports`: a plan's entries are a subset of
412    /// what the gate admits, whatever the named view says.
413    pub exports: Vec<ExportSpec>,
414    /// Where a document's stable ID is persisted — registry, frontmatter shadow,
415    /// or both (DESIGN §5). Independent of the `identity` trigger.
416    pub id_storage: IdStorage,
417    /// The metadata format new documents get when they inherit no parent block
418    /// — a *default* for authoring, never a workspace constraint (§7).
419    pub default_embed_format: fig::Format,
420    /// How that metadata is *embedded* — delimiters, a fenced code block, an
421    /// HTML island, or a separate sidecar. Together with `default_embed_format`
422    /// it selects the carrier a fresh root/document is authored in; recorded so
423    /// the workspace is self-describing about its embedding convention. Like
424    /// `default_embed_format`, an authoring default rather than a constraint:
425    /// existing documents keep whatever carrier they already have.
426    pub embed_style: EmbedStyle,
427    /// The body-prose grammar the workspace is authored in (Markdown/Djot/HTML)
428    /// — the format `render` and code-aware link scanning assume, and the
429    /// intended default for new documents.
430    pub content_format: ContentFormat,
431    /// Whether a `delete` **records what it destroyed** in the workspace's
432    /// deletion log. On by default.
433    ///
434    /// The delete is a hard delete either way — prov does not keep the bytes, and
435    /// recovering them is the job of whatever version-control or backup tool the
436    /// workspace is kept under. What the record adds is the half no such tool
437    /// has: the path, the title, the id and the parent entry, which is what
438    /// `restore` repairs the graph from once the bytes are back. Off is for a
439    /// workspace that wants a deletion to leave no trace at all.
440    ///
441    /// Spelled `recycle_bin` before the log replaced the bin; that spelling is
442    /// still read.
443    pub record_deletions: bool,
444    /// Whether content checksums (fixity) are recorded — on by default. What a
445    /// checksum covers is not configured: it follows the document's shape, and
446    /// every node whose content is a file of its own gets one. See [`Fixity`].
447    pub fixity: Fixity,
448    /// Whether the workspace generates **`about.md`**, the prose page that tells
449    /// a stranger how to read this directory. On by default; see [`About`].
450    pub about: About,
451    /// The frontmatter field prov's own edits (`edit`, `set`, `unset`, `stamp`)
452    /// stamp with the current time when a document's content changes — the
453    /// machine-maintained "last updated" field.
454    /// Empty (the default) disables it. The *name* is yours (`updated`,
455    /// `modified`, `lastmod`); the *value* is always machine-standard (RFC 3339
456    /// UTC), because prov reads it back to know when to rewrite it. A
457    /// human-friendly date is a *different*, user-owned field prov never
458    /// touches (see DESIGN §2, "does prov read it back?").
459    pub updated: String,
460    /// The frontmatter field `create` stamps with the current time when a
461    /// document is made — the sibling of [`updated`](Self::updated), written
462    /// once. Empty (the default) disables it. The same rule about name and
463    /// value applies: the name is yours, the value is RFC 3339 UTC, because
464    /// a value prov writes is one prov owns the format of, and a view
465    /// grouping by it (`by: month`) cuts ISO-8601 text.
466    pub created: String,
467    /// What this workspace calls **itself** — the qualifier a cross-workspace
468    /// reference (`id:<workspace>/<id>`) names it by. Empty (the default) means
469    /// the workspace is anonymous: it can still *hold* foreign references, but
470    /// no reference can be recognized as pointing back at it.
471    ///
472    /// This is the one piece of cross-workspace linking that is genuinely a fact
473    /// about the archive, so it is the one piece that lives in its config. Where
474    /// some *other* workspace can be found is a property of a device, not of
475    /// this workspace, and deliberately has no config key — see
476    /// [`Target::Foreign`](prov_graph::graph::Target::Foreign).
477    ///
478    /// Must be [well-formed](is_valid_workspace_id): a malformed value is
479    /// reported by [`diagnose`] and ignored rather than half-honored.
480    pub workspace_id: String,
481    /// The document this workspace calls its **root**, named by the workspace
482    /// node so that a directory prov cannot otherwise choose in does not have to
483    /// be guessed at (spec §1 rule 1).
484    ///
485    /// A bare file name in the node's own directory
486    /// ([`is_valid_root_name`]) — not a path. Where the root lives is the one
487    /// fact rule 1 is *for*, and letting the key reach into a subdirectory would
488    /// make "the root directory" and "the directory holding the node" two
489    /// different things for every walk downstream.
490    ///
491    /// Read only from the workspace node, because it is the only policy home
492    /// reachable before the root is known; written in a root's own `prov:` block
493    /// it names what has already been found, and is ignored. `None` (the
494    /// default) means the root is chosen by the candidate scan, which is every
495    /// workspace that has never needed otherwise.
496    pub root: Option<String>,
497    /// The directories that are **on disk beside the workspace but are not the
498    /// workspace** — another tool's store, a sync cache, a vendored checkout.
499    ///
500    /// The one axis prov cannot work out for itself. Reachability answers
501    /// "does the graph link this?", and for a folder nobody meant as content
502    /// the answer is no in exactly the same way it is for a note someone
503    /// forgot to link — so a walk that has only reachability to go on must
504    /// either descend into both or into neither. Declaring the folder is how
505    /// the workspace says which it is, and the declaration is what every walk
506    /// then honors: the title index does not name a document inside one, the
507    /// orphan and containment sweeps do not report its interior, `attach` does
508    /// not sweep into it, and [`ignore_list`] rules it whole with
509    /// [`Reason::Declared`] rather than picking through it file by file.
510    ///
511    /// Each entry is a **directory** path relative to the workspace root,
512    /// `/`-separated, with no leading slash and no `.` or `..` segment
513    /// ([`is_valid_scope_path`]); a malformed one is reported by [`diagnose`]
514    /// and dropped rather than half-honored, exactly as a malformed
515    /// [`workspace_id`](Self::workspace_id) is. Empty (the default) means the
516    /// workspace declares nothing out of scope, which is every workspace that
517    /// has never needed to.
518    ///
519    /// Nothing is *hidden* by this: [`ignore_list`] names each declared
520    /// directory, which is what the list is for, and the files are on disk
521    /// where they always were. What it buys is that prov stops reporting
522    /// another tool's interior as this workspace's problem.
523    ///
524    /// [`ignore_list`]: https://docs.rs/prov/latest/prov/struct.Workspace.html#method.ignore_list
525    /// [`Reason::Declared`]: https://docs.rs/prov/latest/prov/enum.Reason.html
526    pub out_of_scope: Vec<String>,
527}
528
529/// Whether `name` is a usable workspace self-name.
530///
531/// Re-exported at the path it has always had, but *defined* beside the grammar
532/// it is a constraint on: every clause of it is dictated by how an
533/// `id:<workspace>/<id>` target parses, which is `prov-graph`'s business, not
534/// policy this crate gets a say in.
535pub use prov_graph::link::is_valid_workspace_id;
536
537/// Whether `path` is a usable [`out_of_scope`](WorkspaceConfig::out_of_scope)
538/// entry: a directory named relative to the workspace root.
539///
540/// Every clause is about the one thing the value is *for* — being compared
541/// against a workspace-relative path during a walk. A leading `/` or a drive
542/// letter names somewhere else entirely; a `..` segment names outside the
543/// workspace, which is the one place a workspace has no business declaring
544/// anything about; a `.` or empty segment spells the same directory two ways,
545/// so a path would fail to match itself. A trailing slash is *accepted* and
546/// carries no meaning — every entry is a directory already — but it is not
547/// normalized away here, so [`WorkspaceConfig::apply`] trims it.
548/// Whether `name` can be a [`root`](WorkspaceConfig::root): a bare file name in
549/// the node's own directory.
550///
551/// Rejects anything with a separator, the relative segments, and the empty
552/// string. Deliberately says nothing about the extension — which formats can be
553/// a root document is rule 1's business and varies with the build's features,
554/// while the *shape* of the value is fixed.
555pub fn is_valid_root_name(name: &str) -> bool {
556    !name.is_empty() && !name.contains('/') && !name.contains('\\') && name != "." && name != ".."
557}
558
559pub fn is_valid_scope_path(path: &str) -> bool {
560    let path = path.strip_suffix('/').unwrap_or(path);
561    !path.is_empty()
562        && !path.starts_with('/')
563        && !path.contains('\\')
564        && path
565            .split('/')
566            .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
567}
568
569impl Default for WorkspaceConfig {
570    /// The standalone default: portable markdown-root path links, identity
571    /// available lazily (IDs minted only on a durable link-by-id or publish, §4),
572    /// and path addressing (id-linking is opt-in).
573    fn default() -> Self {
574        Self {
575            identity: Registration::LAZY,
576            notation: Notation::Markdown,
577            path_style: PathStyle::Root,
578            reference_target: Addressing::Path,
579            reference_label: false,
580            relation_styles: BTreeMap::new(),
581            spanning: None,
582            relation_defs: BTreeMap::new(),
583            fields: BTreeMap::new(),
584            views: Vec::new(),
585            exports: Vec::new(),
586            id_storage: IdStorage::Frontmatter,
587            default_embed_format: fig::Format::Yaml,
588            embed_style: EmbedStyle::Delimited,
589            content_format: ContentFormat::Markdown,
590            record_deletions: true,
591            fixity: Fixity::On,
592            about: About::Structure,
593            updated: String::new(),
594            created: String::new(),
595            workspace_id: String::new(),
596            root: None,
597            out_of_scope: Vec::new(),
598        }
599    }
600}
601
602impl WorkspaceConfig {
603    /// Diaryx-style: path links, no identity — nothing mints an ID, so the
604    /// workspace is addressed purely by path (the Adam's-Archive shape).
605    pub fn paths_only() -> Self {
606        Self {
607            identity: Registration::OFF,
608            id_storage: IdStorage::Registry,
609            ..Self::default()
610        }
611    }
612
613    /// Obsidian-style: stable IDs minted lazily (link-by-id or publish), and
614    /// prov authors structural links *by* id — so a move rewrites nothing,
615    /// the registry keeps them resolving. Portable path links for the rest.
616    pub fn stable_ids() -> Self {
617        Self {
618            identity: Registration::LAZY,
619            reference_target: Addressing::Id,
620            id_storage: IdStorage::Registry,
621            ..Self::default()
622        }
623    }
624
625    /// The fused path [`LinkStyle`] this config's notation + path resolution
626    /// select — what `prov`'s `Workspace` builder's
627    /// `link_style` expects for authoring structural path links.
628    pub fn link_format(&self) -> LinkStyle {
629        LinkStyle::from_axes(self.notation, self.path_style)
630    }
631
632    /// The effective workspace-default [`ReferenceStyle`] — the fallback for any
633    /// relation without its own override, composed from the four reference axes.
634    pub fn reference_style(&self) -> ReferenceStyle {
635        ReferenceStyle {
636            wrapper: self.notation.wrapper(),
637            addressing: self.reference_target,
638            label: self.reference_label,
639            path_style: LinkStyle::from_axes(self.notation, self.path_style),
640        }
641        .normalized()
642    }
643
644    /// The declared per-relation overrides resolved to full [`ReferenceStyle`]s,
645    /// each partial overlaid on the workspace default ([`reference_style`]) and
646    /// normalized. Feed the result to
647    /// [`RelationSet::with_styles`](prov_graph::relation::RelationSet::with_styles) to
648    /// build the workspace's relation vocabulary from a config. Empty when no
649    /// relation declares an override — every relation then inherits the default.
650    ///
651    /// [`reference_style`]: Self::reference_style
652    pub fn resolved_relation_styles(&self) -> BTreeMap<String, ReferenceStyle> {
653        let base = self.reference_style();
654        let base_notation = Notation::from_wrapper(base.wrapper, base.path_style);
655        let base_path = base.path_style.axes().1;
656        self.relation_styles
657            .iter()
658            .map(|(name, over)| {
659                let notation = over.notation.unwrap_or(base_notation);
660                let path = over.path_style.unwrap_or(base_path);
661                let style = ReferenceStyle {
662                    wrapper: notation.wrapper(),
663                    addressing: over.target.unwrap_or(base.addressing),
664                    label: over.label.unwrap_or(base.label),
665                    path_style: LinkStyle::from_axes(notation, path),
666                }
667                .normalized();
668                (name.clone(), style)
669            })
670            .collect()
671    }
672
673    /// Build this workspace's relation vocabulary — the self-describing path
674    /// (DESIGN §1, the `prov/1` spec). The diaryx preset
675    /// ([`RelationSet::diaryx`](prov_graph::relation::RelationSet::diaryx)) is always
676    /// the **base**, and [`relation_defs`](Self::relation_defs) is an **overlay**
677    /// on it: a declared name the preset lacks is added, a name it has is
678    /// redefined, and an [`off`](RelationDef::off) entry retracts one.
679    /// Declaring nothing therefore leaves the preset unchanged (graceful
680    /// degradation, so a minimal vault spells out nothing), and adding one pair
681    /// costs one pair rather than a restatement of the other four. Wholesale
682    /// replacement is still expressible — declare your vocabulary and turn off
683    /// the preset relations you do not use.
684    ///
685    /// The overlay is **per field**, matching how [`apply`](Self::apply) already
686    /// layers a def across the two config surfaces: what a redefinition leaves
687    /// unsaid, the preset's own definition answers, so glossing `contents` with
688    /// a `means:` alone does not silently strip its inverse or reset its
689    /// cardinality. Only a name the preset lacks falls back to the bare defaults
690    /// (`many`, no inverse) — there is nothing else to inherit from. A preset
691    /// relation with *no* inverse is therefore not writable under its preset
692    /// name; a vocabulary that wants one names it itself and turns the preset
693    /// relation off.
694    ///
695    /// The five structural **pointer marks** are unconditional, because they are
696    /// how a reader finds the workspace's own machinery (§6) rather than
697    /// vocabulary the workspace gets a say in: `off` on one of those names takes
698    /// it out of the relation list, but prov still reads the root's key by that
699    /// name to reach the registry, config, bin, history or about page.
700    ///
701    /// An explicit `spanning` always wins; per-relation reference styles are
702    /// overlaid last.
703    /// The declaration of `name` that governs the whole workspace — the one
704    /// without an [`under`](FieldSpec::under) — if there is one. What a
705    /// reader with no document in hand can know about a field; which
706    /// declaration governs a *particular* document is `Workspace`'s question.
707    pub fn field(&self, name: &str) -> Option<&FieldSpec> {
708        self.fields
709            .get(name)?
710            .iter()
711            .find(|spec| spec.under.is_none())
712    }
713
714    /// Every declaration of every field, flattened, with the field's name —
715    /// for a reader that wants each store or each starting value once,
716    /// whatever it is scoped to.
717    pub fn field_declarations(&self) -> impl Iterator<Item = (&str, &FieldSpec)> {
718        self.fields
719            .iter()
720            .flat_map(|(name, specs)| specs.iter().map(move |spec| (name.as_str(), spec)))
721    }
722
723    pub fn relation_set(&self) -> RelationSet {
724        let preset = RelationSet::diaryx();
725        let mut set = preset.clone();
726        for (name, def) in &self.relation_defs {
727            // Remove first either way: `off` is the removal, and a redefinition
728            // is a replacement rather than a second relation of the same name.
729            set = set.without(name);
730            if def.off {
731                continue;
732            }
733            let base = preset.relations().iter().find(|r| r.name == *name);
734            let cardinality = def
735                .cardinality
736                .or(base.map(|r| r.cardinality))
737                .unwrap_or(Cardinality::Many);
738            let mut rel = match cardinality {
739                Cardinality::One => Relation::one(name),
740                Cardinality::Many => Relation::many(name),
741            };
742            if let Some(inverse) = def
743                .inverse
744                .as_deref()
745                .or(base.and_then(|r| r.inverse.as_deref()))
746            {
747                rel = rel.inverse(inverse);
748            }
749            set = set.with(rel);
750        }
751        if let Some(spanning) = &self.spanning {
752            set = set.spanning(spanning);
753        }
754        set.with_styles(&self.resolved_relation_styles())
755    }
756
757    /// Whether a *mutation* under this config could mint a new stable ID — so a
758    /// caller that will land one must bootstrap a registry document *first*
759    /// (before the change set that would otherwise strand the id→path map with no
760    /// home). Two ways an op mints: an **eager** identity policy stamps every
761    /// created document, and any **id-registering reference style** (the workspace
762    /// default, or a single relation's override — e.g. `part_of: id` in a split)
763    /// registers a link's target when a `link` fires.
764    ///
765    /// This is the single home for a judgment the CLI previously recomputed at
766    /// every mutation command (`new`, `attach`, `mv --in`, `reparent`,
767    /// `duplicate`, `init`'s adoption pass), each an identical copy of the same
768    /// three-line `link_registers && fires_on(Link) || fires_on(Create)` — the
769    /// kind of duplicated policy that drifts silently. It lives here because every
770    /// term it needs is a fact about the config.
771    pub fn mints_on_mutation(&self) -> bool {
772        let link_registers = self.reference_style().registers()
773            || self
774                .resolved_relation_styles()
775                .values()
776                .any(|s| s.registers());
777        (link_registers && self.identity.fires_on(Trigger::Link))
778            || self.identity.fires_on(Trigger::Create)
779    }
780
781    /// Overlay the recognized keys present in `meta` onto this config; absent
782    /// keys keep their current value. `meta` is either a root's `prov:` block
783    /// or a config document's top-level mapping — the same nested shape. Apply the
784    /// root block first, then the config document, so the config document wins.
785    pub fn apply(&mut self, meta: &Value) {
786        if let Some(v) = meta
787            .get("content_format")
788            .and_then(Value::as_str)
789            .and_then(ContentFormat::from_config_str)
790        {
791            self.content_format = v;
792        }
793        if let Some(md) = meta.get("metadata") {
794            if let Some(v) = md
795                .get("format")
796                .and_then(Value::as_str)
797                .and_then(format_from_str)
798            {
799                self.default_embed_format = v;
800            }
801            if let Some(v) = md
802                .get("embed")
803                .and_then(Value::as_str)
804                .and_then(EmbedStyle::from_config_str)
805            {
806                self.embed_style = v;
807            }
808        }
809        if let Some(rf) = meta.get("references") {
810            if let Some(v) = rf
811                .get("notation")
812                .and_then(Value::as_str)
813                .and_then(Notation::from_config_str)
814            {
815                self.notation = v;
816            }
817            if let Some(v) = rf
818                .get("path_style")
819                .and_then(Value::as_str)
820                .and_then(PathStyle::from_config_str)
821            {
822                self.path_style = v;
823            }
824            if let Some(v) = rf
825                .get("target")
826                .and_then(Value::as_str)
827                .and_then(Addressing::from_config_str)
828            {
829                self.reference_target = v;
830            }
831            if let Some(v) = rf.get("label").and_then(Value::as_bool) {
832                self.reference_label = v;
833            }
834        }
835        // The spanning relation (self-description, §3): a top-level field name.
836        if let Some(v) = meta.get("spanning").and_then(Value::as_str) {
837            self.spanning = Some(v.to_string());
838        }
839        // What the workspace calls itself. A malformed name is ignored here and
840        // reported by `diagnose` — honoring half of it would mean a reference
841        // that round-trips through a name prov cannot actually write.
842        if let Some(v) = meta
843            .get("workspace_id")
844            .and_then(Value::as_str)
845            .filter(|v| is_valid_workspace_id(v))
846        {
847            self.workspace_id = v.to_string();
848        }
849        // Which document is the root. Malformed values are ignored and reported,
850        // like `workspace_id` — half-honoring a path here would put the root in
851        // a directory the rest of discovery does not believe is the root.
852        if let Some(v) = meta
853            .get("root")
854            .and_then(Value::as_str)
855            .filter(|v| is_valid_root_name(v))
856        {
857            self.root = Some(v.to_string());
858        }
859        // Per-relation entries carry two orthogonal halves in one block:
860        // *style* overrides (`notation`/`path_style`/`target`/`label`) and
861        // structural *definitions* (`cardinality`/`inverse`/`means`).
862        if let Some(relations) = meta.get("relations").and_then(Value::as_mapping) {
863            for (name, spec) in relations {
864                // `<name>: off` retracts the name from the vocabulary — the one
865                // entry shape that is a scalar rather than a settings mapping.
866                // Matched strictly (trimmed, exact), like every other off-axis:
867                // a near-miss is `diagnose`'s to report, not this to guess at.
868                if spec.as_mapping().is_none() {
869                    if spec.as_str().is_some_and(|s| s.trim() == "off") {
870                        self.relation_defs.insert(
871                            name.clone(),
872                            RelationDef {
873                                off: true,
874                                ..RelationDef::default()
875                            },
876                        );
877                    }
878                    continue;
879                }
880                let entry = self.relation_styles.entry(name.clone()).or_default();
881                if let Some(v) = spec
882                    .get("notation")
883                    .and_then(Value::as_str)
884                    .and_then(Notation::from_config_str)
885                {
886                    entry.notation = Some(v);
887                }
888                if let Some(v) = spec
889                    .get("path_style")
890                    .and_then(Value::as_str)
891                    .and_then(PathStyle::from_config_str)
892                {
893                    entry.path_style = Some(v);
894                }
895                if let Some(v) = spec
896                    .get("target")
897                    .and_then(Value::as_str)
898                    .and_then(Addressing::from_config_str)
899                {
900                    entry.target = Some(v);
901                }
902                if let Some(v) = spec.get("label").and_then(Value::as_bool) {
903                    entry.label = Some(v);
904                }
905                // The structural half — only recorded when at least one def key is
906                // present, so a style-only entry does not synthesize an empty def.
907                let cardinality = spec
908                    .get("cardinality")
909                    .and_then(Value::as_str)
910                    .and_then(cardinality_from_str);
911                let inverse = spec
912                    .get("inverse")
913                    .and_then(Value::as_str)
914                    .map(str::to_string);
915                let means = spec
916                    .get("means")
917                    .and_then(Value::as_str)
918                    .map(str::to_string);
919                if cardinality.is_some() || inverse.is_some() || means.is_some() {
920                    let def = self.relation_defs.entry(name.clone()).or_default();
921                    // A surface that defines the relation un-retracts it: the
922                    // later surface wins per key, and "here is its cardinality"
923                    // cannot coexist with "this is not a relation".
924                    def.off = false;
925                    if cardinality.is_some() {
926                        def.cardinality = cardinality;
927                    }
928                    if inverse.is_some() {
929                        def.inverse = inverse;
930                    }
931                    if means.is_some() {
932                        def.means = means;
933                    }
934                }
935            }
936        }
937        // Field declarations: `fields: { <field>: <decl> | [<decl>, …] }`,
938        // each `<decl>` a mapping of `{ type, values, vocabulary, reify,
939        // default, under }`. A bare mapping is one declaration for the whole
940        // workspace; a sequence is several, each scoped by `under`.
941        if let Some(fields) = meta.get("fields").and_then(Value::as_mapping) {
942            for (name, value) in fields {
943                let entries: Vec<&Value> = match value {
944                    Value::Sequence(items) => items.iter().collect(),
945                    other => vec![other],
946                };
947                let mut declarations = Vec::new();
948                for spec in entries {
949                    let vocabulary = spec
950                        .get("vocabulary")
951                        .and_then(Value::as_str)
952                        .map(str::to_string);
953                    let ty = spec
954                        .get("type")
955                        .and_then(Value::as_str)
956                        .and_then(field_type_from_config_str);
957                    let default = spec.get("default").cloned();
958                    // An entry that declares neither a type, nor a vocabulary,
959                    // nor a starting value says nothing about the field that
960                    // prov or a frontend could act on; recording it would only
961                    // claim the field is described when it isn't — a scope
962                    // alone governs nothing. (`diagnose` reports the malformed
963                    // spelling that most often causes this.)
964                    if ty.is_none() && vocabulary.is_none() && default.is_none() {
965                        continue;
966                    }
967                    let values = spec
968                        .get("values")
969                        .and_then(Value::as_str)
970                        .and_then(OpenClosed::from_config_str)
971                        .unwrap_or_default();
972                    let reify = spec.get("reify").and_then(Value::as_bool).unwrap_or(false);
973                    let under = spec
974                        .get("under")
975                        .and_then(Value::as_str)
976                        .map(str::trim)
977                        .filter(|s| !s.is_empty())
978                        .map(str::to_string);
979                    declarations.push(FieldSpec {
980                        ty,
981                        values,
982                        vocabulary,
983                        reify,
984                        default,
985                        under,
986                    });
987                }
988                if !declarations.is_empty() {
989                    self.fields.insert(name.clone(), declarations);
990                }
991            }
992        }
993        // View declarations: `views: { <name>: { group, by, under, nest, … } }`.
994        //
995        // Merged per entry, exactly as `fields` is and for the same reason: a
996        // vault config that declares one view must not wipe the ones the app's
997        // defaults supplied. A later surface redeclaring a name replaces that
998        // view whole — a view is small and its keys interlock (`by` means
999        // nothing without `group`), so merging *within* one would produce
1000        // hybrids no surface wrote.
1001        if let Some(views) = meta.get(prov_views::VIEWS_KEY).and_then(Value::as_mapping) {
1002            for (name, value) in views {
1003                let Some(spec) = ViewSpec::parse(name, value) else {
1004                    continue;
1005                };
1006                match self.views.iter_mut().find(|v| v.name == spec.name) {
1007                    Some(existing) => *existing = spec,
1008                    None => self.views.push(spec),
1009                }
1010            }
1011        }
1012        // Export declarations: `exports: { <name>: { gate, view, … } }`.
1013        // Merged per entry like `views` — and replacement is whole for a
1014        // sharper reason than key interlock: an export half-merged across two
1015        // surfaces would bound what leaves with a gate neither surface wrote.
1016        // An entry `parse` cannot make a gate of is dropped (fail closed — it
1017        // exports nothing) and `diagnose` is where the reason surfaces.
1018        if let Some(exports) = meta
1019            .get(prov_exports::EXPORTS_KEY)
1020            .and_then(Value::as_mapping)
1021        {
1022            for (name, value) in exports {
1023                let Some(spec) = ExportSpec::parse(name, value) else {
1024                    continue;
1025                };
1026                match self.exports.iter_mut().find(|e| e.name == spec.name) {
1027                    Some(existing) => *existing = spec,
1028                    None => self.exports.push(spec),
1029                }
1030            }
1031        }
1032        if let Some(v) = meta
1033            .get("id_storage")
1034            .and_then(Value::as_str)
1035            .and_then(IdStorage::from_config_str)
1036        {
1037            self.id_storage = v;
1038        }
1039        if let Some(v) = meta.get("updated").and_then(Value::as_str) {
1040            self.updated = v.to_string();
1041        }
1042        if let Some(v) = meta.get("created").and_then(Value::as_str) {
1043            self.created = v.to_string();
1044        }
1045        if let Some(v) = meta
1046            .get("identity")
1047            .and_then(Value::as_str)
1048            .and_then(registration_from_str)
1049        {
1050            self.identity = v;
1051        }
1052        if let Some(v) = meta
1053            .get("fixity")
1054            .and_then(Value::as_str)
1055            .and_then(Fixity::from_config_str)
1056        {
1057            self.fixity = v;
1058        }
1059        // `recycle_bin` is the spelling this axis had when a delete parked bytes
1060        // in a bin. Read first so the new name overrides it in a document that
1061        // somehow carries both, rather than depending on key order.
1062        if let Some(v) = meta.get("recycle_bin").and_then(Value::as_bool) {
1063            self.record_deletions = v;
1064        }
1065        if let Some(v) = meta.get("record_deletions").and_then(Value::as_bool) {
1066            self.record_deletions = v;
1067        }
1068        if let Some(v) = meta
1069            .get("about")
1070            .and_then(Value::as_str)
1071            .and_then(About::from_config_str)
1072        {
1073            self.about = v;
1074        }
1075        // The declared scope. Replaced whole rather than merged, unlike `views`
1076        // and `fields`: those are keyed collections where a later surface adds
1077        // an entry, and this is one statement about one workspace — a surface
1078        // that could only ever lengthen the list could never shorten it.
1079        // Normalized here (trimmed, deduplicated, sorted) so `to_mapping`
1080        // round-trips stably and two configs saying the same thing diff clean.
1081        if let Some(seq) = meta.get("out_of_scope").and_then(Value::as_sequence) {
1082            let mut dirs: Vec<String> = seq
1083                .iter()
1084                .filter_map(Value::as_str)
1085                .map(str::trim)
1086                .filter(|dir| is_valid_scope_path(dir))
1087                .map(|dir| dir.strip_suffix('/').unwrap_or(dir).to_string())
1088                .collect();
1089            dirs.sort();
1090            dirs.dedup();
1091            self.out_of_scope = dirs;
1092        }
1093    }
1094
1095    /// A fresh config with `meta`'s recognized keys applied over the defaults.
1096    pub fn from_meta(meta: &Value) -> Self {
1097        let mut config = Self::default();
1098        config.apply(meta);
1099        config
1100    }
1101
1102    /// This config as config-document metadata keys (the nested vocabulary,
1103    /// `docs/config-vocab.md`). Emitted at the top level of the config document;
1104    /// the same mapping nests under `prov:` in a root's frontmatter.
1105    pub fn to_mapping(&self) -> Mapping {
1106        let mut map = Mapping::new();
1107        map.insert("spec".into(), Value::Int(SPEC_VERSION));
1108        map.insert(
1109            "content_format".into(),
1110            Value::String(self.content_format.as_config_str().into()),
1111        );
1112
1113        let mut metadata = Mapping::new();
1114        metadata.insert(
1115            "format".into(),
1116            Value::String(format_str(self.default_embed_format).into()),
1117        );
1118        metadata.insert(
1119            "embed".into(),
1120            Value::String(self.embed_style.as_config_str().into()),
1121        );
1122        map.insert("metadata".into(), Value::Mapping(metadata));
1123
1124        let mut references = Mapping::new();
1125        references.insert(
1126            "notation".into(),
1127            Value::String(self.notation.as_config_str().into()),
1128        );
1129        references.insert(
1130            "path_style".into(),
1131            Value::String(self.path_style.as_config_str().into()),
1132        );
1133        references.insert(
1134            "target".into(),
1135            Value::String(self.reference_target.as_config_str().into()),
1136        );
1137        references.insert("label".into(), Value::Bool(self.reference_label));
1138        map.insert("references".into(), Value::Mapping(references));
1139
1140        if let Some(spanning) = &self.spanning {
1141            map.insert("spanning".into(), Value::String(spanning.clone()));
1142        }
1143
1144        // One `relations` block carries both halves of each entry — style
1145        // overrides and structural definitions — so the union of the two maps'
1146        // keys is emitted, each entry merging whichever halves it has.
1147        if !self.relation_styles.is_empty() || !self.relation_defs.is_empty() {
1148            let mut names: Vec<&String> = self
1149                .relation_styles
1150                .keys()
1151                .chain(self.relation_defs.keys())
1152                .collect();
1153            names.sort();
1154            names.dedup();
1155            let mut relations = Mapping::new();
1156            for name in names {
1157                // A retraction is a scalar, not a settings mapping: there is no
1158                // setting to write beside it, and `off` is what `apply` reads
1159                // back.
1160                if self.relation_defs.get(name).is_some_and(|d| d.off) {
1161                    relations.insert(name.clone(), Value::String("off".into()));
1162                    continue;
1163                }
1164                let mut spec = Mapping::new();
1165                if let Some(over) = self.relation_styles.get(name) {
1166                    if let Some(n) = over.notation {
1167                        spec.insert("notation".into(), Value::String(n.as_config_str().into()));
1168                    }
1169                    if let Some(p) = over.path_style {
1170                        spec.insert("path_style".into(), Value::String(p.as_config_str().into()));
1171                    }
1172                    if let Some(t) = over.target {
1173                        spec.insert("target".into(), Value::String(t.as_config_str().into()));
1174                    }
1175                    if let Some(l) = over.label {
1176                        spec.insert("label".into(), Value::Bool(l));
1177                    }
1178                }
1179                if let Some(def) = self.relation_defs.get(name) {
1180                    if let Some(c) = def.cardinality {
1181                        spec.insert(
1182                            "cardinality".into(),
1183                            Value::String(cardinality_str(c).into()),
1184                        );
1185                    }
1186                    if let Some(inv) = &def.inverse {
1187                        spec.insert("inverse".into(), Value::String(inv.clone()));
1188                    }
1189                    if let Some(m) = &def.means {
1190                        spec.insert("means".into(), Value::String(m.clone()));
1191                    }
1192                }
1193                relations.insert(name.clone(), Value::Mapping(spec));
1194            }
1195            map.insert("relations".into(), Value::Mapping(relations));
1196        }
1197
1198        if !self.fields.is_empty() {
1199            let mut fields = Mapping::new();
1200            for (name, declarations) in &self.fields {
1201                let entries: Vec<Value> = declarations
1202                    .iter()
1203                    .map(|spec| {
1204                        let mut entry = Mapping::new();
1205                        if let Some(under) = &spec.under {
1206                            entry.insert("under".into(), Value::String(under.clone()));
1207                        }
1208                        if let Some(ty) = spec.ty.and_then(field_type_as_config_str) {
1209                            entry.insert("type".into(), Value::String(ty.into()));
1210                        }
1211                        // `values` describes a vocabulary, so it is only
1212                        // meaningful — and only written — alongside one.
1213                        if let Some(vocabulary) = &spec.vocabulary {
1214                            entry.insert(
1215                                "values".into(),
1216                                Value::String(spec.values.as_config_str().into()),
1217                            );
1218                            entry.insert("vocabulary".into(), Value::String(vocabulary.clone()));
1219                        }
1220                        if spec.reify {
1221                            entry.insert("reify".into(), Value::Bool(true));
1222                        }
1223                        if let Some(default) = &spec.default {
1224                            entry.insert("default".into(), default.clone());
1225                        }
1226                        Value::Mapping(entry)
1227                    })
1228                    .collect();
1229                // One unscoped declaration is the common case and keeps the
1230                // bare spelling; anything else is the list it is.
1231                let value = match entries.as_slice() {
1232                    [one] if declarations[0].under.is_none() => one.clone(),
1233                    _ => Value::Sequence(entries),
1234                };
1235                fields.insert(name.clone(), value);
1236            }
1237            map.insert("fields".into(), Value::Mapping(fields));
1238        }
1239
1240        if !self.views.is_empty() {
1241            let mut views = Mapping::new();
1242            for spec in &self.views {
1243                views.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
1244            }
1245            map.insert(prov_views::VIEWS_KEY.into(), Value::Mapping(views));
1246        }
1247
1248        if !self.exports.is_empty() {
1249            let mut exports = Mapping::new();
1250            for spec in &self.exports {
1251                exports.insert(spec.name.clone(), Value::Mapping(spec.to_mapping()));
1252            }
1253            map.insert(prov_exports::EXPORTS_KEY.into(), Value::Mapping(exports));
1254        }
1255
1256        map.insert(
1257            "id_storage".into(),
1258            Value::String(self.id_storage.as_config_str().into()),
1259        );
1260        map.insert("updated".into(), Value::String(self.updated.clone()));
1261        map.insert("created".into(), Value::String(self.created.clone()));
1262        map.insert(
1263            "identity".into(),
1264            Value::String(registration_str(self.identity).into()),
1265        );
1266        map.insert(
1267            "fixity".into(),
1268            Value::String(self.fixity.as_config_str().into()),
1269        );
1270        map.insert(
1271            "record_deletions".into(),
1272            Value::Bool(self.record_deletions),
1273        );
1274        map.insert(
1275            "about".into(),
1276            Value::String(self.about.as_config_str().into()),
1277        );
1278        map.insert(
1279            "workspace_id".into(),
1280            Value::String(self.workspace_id.clone()),
1281        );
1282        // Written only when the workspace names one, like `out_of_scope`: the
1283        // default is "scan for it", which no key spells.
1284        if let Some(root) = &self.root {
1285            map.insert("root".into(), Value::String(root.clone()));
1286        }
1287        // Written only when the workspace declares something, like `views` and
1288        // unlike the scalar axes: an empty sequence is the default said out
1289        // loud, and every existing config document would grow the key for it.
1290        if !self.out_of_scope.is_empty() {
1291            map.insert(
1292                "out_of_scope".into(),
1293                Value::Sequence(
1294                    self.out_of_scope
1295                        .iter()
1296                        .map(|dir| Value::String(dir.clone()))
1297                        .collect(),
1298                ),
1299            );
1300        }
1301        map
1302    }
1303}
1304
1305// ── Config linting (`docs/config-vocab.md`, "Linting") ──────────────────────
1306
1307/// A key in a config surface that [`WorkspaceConfig::apply`] would silently
1308/// ignore — surfaced so a setting that never takes effect becomes visible rather
1309/// than staying invisible. `apply` keeps the current value whenever a key is
1310/// unrecognized or its value fails to parse; that robustness is what makes a
1311/// typo (`notaton`) or a bad value (`fixity: alll`) vanish without a word.
1312#[derive(Debug, Clone, PartialEq, Eq)]
1313pub struct ConfigIssue {
1314    /// The offending key, dotted from the block root (`references.notation`).
1315    pub key: String,
1316    /// What is wrong with it.
1317    pub kind: ConfigIssueKind,
1318}
1319
1320/// The two ways a config key goes unread. See [`ConfigIssue`].
1321#[derive(Debug, Clone, PartialEq, Eq)]
1322pub enum ConfigIssueKind {
1323    /// `key` is not a recognized axis but closely resembles `suggestion` — almost
1324    /// certainly a misspelling. An unrecognized key that resembles *no* axis at
1325    /// its level is deliberately **not** reported: a config surface can carry
1326    /// user-owned fields prov never reads (DESIGN §2), so flagging every
1327    /// unknown key would be noise.
1328    UnknownKey { suggestion: String },
1329    /// `key` is a recognized axis but `value` is not a spelling prov
1330    /// understands, so `apply` kept the default. `expected` lists the accepted
1331    /// spellings (advisory help; mirrors the axis's parser).
1332    InvalidValue {
1333        value: String,
1334        expected: Vec<String>,
1335    },
1336    /// The `spanning` relation's declared `inverse` is a relation whose
1337    /// cardinality is `many`, which cannot form the single-parent containment
1338    /// tree the spanning relation requires (DESIGN §3). `key` is `spanning`;
1339    /// `inverse` is the offending child→parent relation.
1340    SpanningNotSingleParent { inverse: String },
1341    /// A view declares `nest:` but groups by a field the workspace declares
1342    /// multi-valued (`fields.<field>.type: seq`).
1343    ///
1344    /// Nesting files a record into the single-parent spanning relation, so a
1345    /// document carrying two values for `field` has two homes and nothing can
1346    /// choose between them. The *grouping* is fine — one document under several
1347    /// groups is what a view is for — so only the filing half is reported.
1348    NestNotSingleValued { field: String },
1349    /// `workspace_id` holds a name that cannot be written as the qualifier of an
1350    /// `id:<workspace>/<id>` reference — it contains `/`, `:` or whitespace, or
1351    /// is not a string at all. `apply` ignored it, so the workspace stayed
1352    /// anonymous.
1353    ///
1354    /// An **empty** value is not this: it is the explicit spelling of anonymous,
1355    /// the way an empty `updated` spells that feature off.
1356    ///
1357    /// Unlike [`InvalidValue`](Self::InvalidValue) there is no list of accepted
1358    /// spellings to offer: the name is the user's to choose and only its *shape*
1359    /// is constrained.
1360    MalformedWorkspaceId { value: String },
1361    /// `root` holds something that is not a bare file name in the node's own
1362    /// directory — it contains a separator, is a relative segment, or is not a
1363    /// string at all. `apply` ignored it, so the root is chosen by the candidate
1364    /// scan as though the key were absent.
1365    ///
1366    /// Like [`MalformedWorkspaceId`](Self::MalformedWorkspaceId) and unlike
1367    /// [`InvalidValue`](Self::InvalidValue) there is no list of accepted
1368    /// spellings: the name is the user's to choose and only its shape is fixed.
1369    MalformedRoot { value: String },
1370}
1371
1372/// Top-level config keys (block names + scalar axes + the `spec` marker).
1373const TOP_KEYS: &[&str] = &[
1374    "spec",
1375    "content_format",
1376    "metadata",
1377    "references",
1378    "relations",
1379    "spanning",
1380    "fields",
1381    "views",
1382    "exports",
1383    "id_storage",
1384    "updated",
1385    "created",
1386    "workspace_id",
1387    "root",
1388    "identity",
1389    "fixity",
1390    "record_deletions",
1391    // The spelling `record_deletions` replaced. Listed so a config written
1392    // before the rename is read rather than reported as an unknown key.
1393    "recycle_bin",
1394    "about",
1395    "out_of_scope",
1396];
1397/// Keys inside the `metadata:` block.
1398const METADATA_KEYS: &[&str] = &["format", "embed"];
1399/// The reference-style keys valid in the `references:` block and in each
1400/// `relations.<name>` entry.
1401const REFERENCE_KEYS: &[&str] = &["notation", "path_style", "target", "label"];
1402/// The structural definition keys valid only in a `relations.<name>` entry
1403/// (`means` is free-form and never near-miss-matched, like `updated`).
1404const RELATION_DEF_KEYS: &[&str] = &["cardinality", "inverse", "means"];
1405/// Keys inside each `fields.<name>` entry.
1406const FIELD_KEYS: &[&str] = &["type", "values", "vocabulary", "reify", "default", "under"];
1407
1408/// If `meta` declares a `spec` newer than [`SPEC_VERSION`] — the version this
1409/// build understands — the declared version. The signal that prov may be
1410/// silently ignoring settings a newer prov wrote. `None` when `spec` is
1411/// absent, not an integer, or within range. Shared by `check` (a
1412/// `Finding::ConfigSpecAhead`) and the CLI's proactive config warning, so the
1413/// version comparison lives in one place.
1414pub fn spec_ahead(meta: &Value) -> Option<i64> {
1415    match meta.get("spec") {
1416        Some(Value::Int(v)) if *v > SPEC_VERSION => Some(*v),
1417        _ => None,
1418    }
1419}
1420
1421/// Diagnose a config surface (a root's `prov:` block or a config document's
1422/// top-level mapping): one [`ConfigIssue`] per key `apply` would silently ignore.
1423/// Recognized keys are checked for a value prov can parse; unrecognized keys
1424/// are reported only when they closely resemble a real axis at their level (a
1425/// likely typo). Returns empty for a clean config.
1426pub fn diagnose(meta: &Value) -> Vec<ConfigIssue> {
1427    let mut issues = Vec::new();
1428    let Some(map) = meta.as_mapping() else {
1429        return issues;
1430    };
1431    for (key, value) in map {
1432        match key.as_str() {
1433            "spec" => {} // version marker — not a policy axis
1434            "content_format" => {
1435                enum_axis(
1436                    &mut issues,
1437                    key,
1438                    value,
1439                    |s| ContentFormat::from_config_str(s).is_some(),
1440                    &["markdown", "djot", "html"],
1441                );
1442            }
1443            "id_storage" => {
1444                enum_axis(
1445                    &mut issues,
1446                    key,
1447                    value,
1448                    |s| IdStorage::from_config_str(s).is_some(),
1449                    &["registry", "frontmatter", "both"],
1450                );
1451            }
1452            "identity" => {
1453                enum_axis(
1454                    &mut issues,
1455                    key,
1456                    value,
1457                    |s| registration_from_str(s).is_some(),
1458                    &["none", "lazy", "eager"],
1459                );
1460            }
1461            "fixity" => {
1462                enum_axis(
1463                    &mut issues,
1464                    key,
1465                    value,
1466                    |s| Fixity::from_config_str(s).is_some(),
1467                    &["off", "on"],
1468                );
1469            }
1470            "record_deletions" | "recycle_bin" => bool_axis(&mut issues, key, value),
1471            "about" => {
1472                enum_axis(
1473                    &mut issues,
1474                    key,
1475                    value,
1476                    |s| About::from_config_str(s).is_some(),
1477                    &["off", "structure"],
1478                );
1479            }
1480            "updated" | "created" => {} // free-form field names
1481            // A sequence of workspace-relative directory paths. Each entry is
1482            // judged on its own, so one malformed line is one issue naming
1483            // that line rather than a verdict on the whole list.
1484            "out_of_scope" => match value.as_sequence() {
1485                Some(seq) => {
1486                    for entry in seq {
1487                        let ok = entry
1488                            .as_str()
1489                            .is_some_and(|dir| is_valid_scope_path(dir.trim()));
1490                        if !ok {
1491                            issues.push(ConfigIssue {
1492                                key: key.clone(),
1493                                kind: ConfigIssueKind::InvalidValue {
1494                                    value: value_summary(entry),
1495                                    expected: vec![
1496                                        "a directory path relative to the workspace root".into(),
1497                                    ],
1498                                },
1499                            });
1500                        }
1501                    }
1502                }
1503                None => issues.push(ConfigIssue {
1504                    key: key.clone(),
1505                    kind: ConfigIssueKind::InvalidValue {
1506                        value: value_summary(value),
1507                        expected: vec!["a list of directory paths".into()],
1508                    },
1509                }),
1510            },
1511            // A name the user chose, constrained only in shape — it has to
1512            // survive being written as the qualifier of an `id:<ws>/<id>`
1513            // target. A non-string is malformed for the same reason.
1514            //
1515            // The empty string is *not*: it is the explicit spelling of the
1516            // default (anonymous), exactly as an empty `updated` spells the
1517            // stamping feature off. `to_mapping` writes it that way, so
1518            // flagging it would make prov's own serialized default fail its own
1519            // diagnosis.
1520            "workspace_id" => {
1521                let ok = match value.as_str() {
1522                    Some(s) => s.is_empty() || is_valid_workspace_id(s),
1523                    None => false,
1524                };
1525                if !ok {
1526                    issues.push(ConfigIssue {
1527                        key: key.clone(),
1528                        kind: ConfigIssueKind::MalformedWorkspaceId {
1529                            value: value_summary(value),
1530                        },
1531                    });
1532                }
1533            }
1534            // The same posture as `workspace_id`: a shape, not a vocabulary.
1535            // An empty string is *not* the spelling of a default here — there is
1536            // no "anonymous root" — so it is malformed like any other.
1537            "root" => {
1538                if !value.as_str().is_some_and(is_valid_root_name) {
1539                    issues.push(ConfigIssue {
1540                        key: key.clone(),
1541                        kind: ConfigIssueKind::MalformedRoot {
1542                            value: value_summary(value),
1543                        },
1544                    });
1545                }
1546            }
1547            "spanning" => {
1548                // A relation name — must be a string; its coherence with the
1549                // relations block is a cross-relation check below.
1550                if value.as_str().is_none() {
1551                    issues.push(ConfigIssue {
1552                        key: key.clone(),
1553                        kind: ConfigIssueKind::InvalidValue {
1554                            value: value_summary(value),
1555                            expected: vec!["a relation name".into()],
1556                        },
1557                    });
1558                }
1559            }
1560            "metadata" => diagnose_metadata(&mut issues, value),
1561            "references" => diagnose_reference_block(&mut issues, "references", value),
1562            "relations" => diagnose_relations(&mut issues, value),
1563            "fields" => diagnose_fields(&mut issues, value),
1564            "views" => diagnose_views(&mut issues, value, map),
1565            "exports" => diagnose_exports(&mut issues, value, map),
1566            other => {
1567                if let Some(suggestion) = nearest(other, TOP_KEYS) {
1568                    issues.push(unknown(key.clone(), suggestion));
1569                }
1570            }
1571        }
1572    }
1573    diagnose_spanning_invariant(&mut issues, map);
1574    issues
1575}
1576
1577/// The single-parent invariant (DESIGN §3): if `spanning` names a declared
1578/// relation whose declared `inverse` is itself declared with `cardinality: many`,
1579/// that inverse cannot be the child→parent side of a tree — reported so an
1580/// incoherent vocabulary is caught at author time rather than surfacing as a
1581/// runtime `DuplicateContainment` finding. Absence (an undeclared inverse, or a
1582/// spanning relation built into the vocabulary rather than declared) is left
1583/// alone — only a *declared contradiction* is flagged, never under-specification.
1584fn diagnose_spanning_invariant(issues: &mut Vec<ConfigIssue>, map: &Mapping) {
1585    let Some(spanning) = map.get("spanning").and_then(Value::as_str) else {
1586        return;
1587    };
1588    let Some(relations) = map.get("relations").and_then(Value::as_mapping) else {
1589        return;
1590    };
1591    // The spine names a relation this surface retracts — a workspace with no
1592    // spine at all, and the failure mode `off` introduces: turning `contents`
1593    // off without renaming the spanning relation to whatever replaced it. Only
1594    // this surface is consulted, exactly as the invariant below is; a *declared*
1595    // contradiction is what is being reported.
1596    if relations
1597        .get(spanning)
1598        .and_then(Value::as_str)
1599        .is_some_and(|s| s.trim() == "off")
1600    {
1601        issues.push(ConfigIssue {
1602            key: "spanning".into(),
1603            kind: ConfigIssueKind::InvalidValue {
1604                value: spanning.to_string(),
1605                expected: vec![
1606                    "a relation this workspace has — `relations` turns this one off".into(),
1607                ],
1608            },
1609        });
1610        return;
1611    }
1612    let Some(inverse) = relations
1613        .get(spanning)
1614        .and_then(Value::as_mapping)
1615        .and_then(|r| r.get("inverse"))
1616        .and_then(Value::as_str)
1617    else {
1618        return;
1619    };
1620    let inverse_cardinality = relations
1621        .get(inverse)
1622        .and_then(Value::as_mapping)
1623        .and_then(|r| r.get("cardinality"))
1624        .and_then(Value::as_str);
1625    if inverse_cardinality == Some("many") {
1626        issues.push(ConfigIssue {
1627            key: "spanning".into(),
1628            kind: ConfigIssueKind::SpanningNotSingleParent {
1629                inverse: inverse.to_string(),
1630            },
1631        });
1632    }
1633}
1634
1635/// Diagnose the `metadata:` block.
1636fn diagnose_metadata(issues: &mut Vec<ConfigIssue>, value: &Value) {
1637    let Some(map) = value.as_mapping() else {
1638        return block_shape_issue(issues, "metadata", value);
1639    };
1640    for (key, v) in map {
1641        let dotted = format!("metadata.{key}");
1642        match key.as_str() {
1643            "format" => enum_axis(
1644                issues,
1645                &dotted,
1646                v,
1647                |s| format_from_str(s).is_some(),
1648                &embed_format_spellings(),
1649            ),
1650            "embed" => enum_axis(
1651                issues,
1652                &dotted,
1653                v,
1654                |s| EmbedStyle::from_config_str(s).is_some(),
1655                &[
1656                    "delimited",
1657                    "code_block",
1658                    "html_script",
1659                    "html_code",
1660                    "separate",
1661                ],
1662            ),
1663            other => {
1664                if let Some(sug) = nearest(other, METADATA_KEYS) {
1665                    issues.push(unknown(dotted, format!("metadata.{sug}")));
1666                }
1667            }
1668        }
1669    }
1670}
1671
1672/// Diagnose a `references:`-shaped block (the workspace default or a
1673/// `relations.<name>` entry), `prefix` dotting the reported keys.
1674fn diagnose_reference_block(issues: &mut Vec<ConfigIssue>, prefix: &str, value: &Value) {
1675    let Some(map) = value.as_mapping() else {
1676        return block_shape_issue(issues, prefix, value);
1677    };
1678    for (key, v) in map {
1679        let dotted = format!("{prefix}.{key}");
1680        match key.as_str() {
1681            "notation" => enum_axis(
1682                issues,
1683                &dotted,
1684                v,
1685                |s| Notation::from_config_str(s).is_some(),
1686                &["markdown", "wikilink", "bare"],
1687            ),
1688            "path_style" => enum_axis(
1689                issues,
1690                &dotted,
1691                v,
1692                |s| PathStyle::from_config_str(s).is_some(),
1693                &["root", "relative"],
1694            ),
1695            "target" => enum_axis(
1696                issues,
1697                &dotted,
1698                v,
1699                |s| Addressing::from_config_str(s).is_some(),
1700                &["path", "id", "alias"],
1701            ),
1702            "label" => bool_axis(issues, &dotted, v),
1703            other => {
1704                if let Some(sug) = nearest(other, REFERENCE_KEYS) {
1705                    issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1706                }
1707            }
1708        }
1709    }
1710}
1711
1712/// Diagnose the `relations:` block — a mapping of relation name to an entry that
1713/// may carry both reference-style keys and structural definition keys.
1714fn diagnose_relations(issues: &mut Vec<ConfigIssue>, value: &Value) {
1715    let Some(map) = value.as_mapping() else {
1716        return block_shape_issue(issues, "relations", value);
1717    };
1718    for (name, spec) in map {
1719        diagnose_relation_entry(issues, name, spec);
1720    }
1721}
1722
1723/// Diagnose one `relations.<name>` entry: the reference-style axes
1724/// ([`REFERENCE_KEYS`]) plus the structural definition keys
1725/// ([`RELATION_DEF_KEYS`]). `means` is free-form and accepted without check;
1726/// `cardinality` is enum-checked; `inverse` must be a string. An unknown key is
1727/// reported only when it near-misses a valid key at this level.
1728///
1729/// An entry has one other legal shape: the scalar `off`, retracting the name
1730/// from the vocabulary. Any *other* scalar is reported here rather than by
1731/// [`block_shape_issue`], because the accepted shapes are no longer just
1732/// "a mapping" and a reader told only that would not find `off`.
1733fn diagnose_relation_entry(issues: &mut Vec<ConfigIssue>, name: &str, value: &Value) {
1734    let prefix = format!("relations.{name}");
1735    let Some(map) = value.as_mapping() else {
1736        if value.as_str().is_some_and(|s| s.trim() == "off") {
1737            return;
1738        }
1739        return issues.push(ConfigIssue {
1740            key: prefix,
1741            kind: ConfigIssueKind::InvalidValue {
1742                value: value_summary(value),
1743                expected: vec!["a mapping of relation settings".into(), "off".into()],
1744            },
1745        });
1746    };
1747    for (key, v) in map {
1748        let dotted = format!("{prefix}.{key}");
1749        match key.as_str() {
1750            "notation" => enum_axis(
1751                issues,
1752                &dotted,
1753                v,
1754                |s| Notation::from_config_str(s).is_some(),
1755                &["markdown", "wikilink", "bare"],
1756            ),
1757            "path_style" => enum_axis(
1758                issues,
1759                &dotted,
1760                v,
1761                |s| PathStyle::from_config_str(s).is_some(),
1762                &["root", "relative"],
1763            ),
1764            "target" => enum_axis(
1765                issues,
1766                &dotted,
1767                v,
1768                |s| Addressing::from_config_str(s).is_some(),
1769                &["path", "id", "alias"],
1770            ),
1771            "label" => bool_axis(issues, &dotted, v),
1772            "cardinality" => enum_axis(
1773                issues,
1774                &dotted,
1775                v,
1776                |s| cardinality_from_str(s).is_some(),
1777                &["one", "many"],
1778            ),
1779            "inverse" => {
1780                if v.as_str().is_none() {
1781                    issues.push(ConfigIssue {
1782                        key: dotted,
1783                        kind: ConfigIssueKind::InvalidValue {
1784                            value: value_summary(v),
1785                            expected: vec!["a relation name".into()],
1786                        },
1787                    });
1788                }
1789            }
1790            "means" => {} // free-form human gloss — carried, not read (§2)
1791            other => {
1792                let mut valid: Vec<&str> = REFERENCE_KEYS.to_vec();
1793                valid.extend_from_slice(RELATION_DEF_KEYS);
1794                if let Some(sug) = nearest(other, &valid) {
1795                    issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1796                }
1797            }
1798        }
1799    }
1800}
1801
1802/// Diagnose the `fields:` block — a mapping of frontmatter field name to a field
1803/// declaration (`type` / `values` / `vocabulary` / `reify` / `default` /
1804/// `under`), or to a sequence of them, each scoped by `under`.
1805fn diagnose_fields(issues: &mut Vec<ConfigIssue>, value: &Value) {
1806    let Some(map) = value.as_mapping() else {
1807        return block_shape_issue(issues, "fields", value);
1808    };
1809    for (name, spec) in map {
1810        let prefix = format!("fields.{name}");
1811        match spec {
1812            Value::Sequence(items) => {
1813                for (i, item) in items.iter().enumerate() {
1814                    diagnose_field_declaration(issues, &format!("{prefix}.{i}"), item);
1815                }
1816            }
1817            other => diagnose_field_declaration(issues, &prefix, other),
1818        }
1819    }
1820}
1821
1822/// One field declaration, at `prefix` (`fields.status`, or `fields.status.1`
1823/// inside a scoped list).
1824fn diagnose_field_declaration(issues: &mut Vec<ConfigIssue>, prefix: &str, spec: &Value) {
1825    {
1826        let Some(entry) = spec.as_mapping() else {
1827            return block_shape_issue(issues, prefix, spec);
1828        };
1829        for (key, v) in entry {
1830            let dotted = format!("{prefix}.{key}");
1831            match key.as_str() {
1832                "type" => enum_axis(
1833                    issues,
1834                    &dotted,
1835                    v,
1836                    |s| field_type_from_config_str(s).is_some(),
1837                    FIELD_TYPES,
1838                ),
1839                "values" => enum_axis(
1840                    issues,
1841                    &dotted,
1842                    v,
1843                    |s| OpenClosed::from_config_str(s).is_some(),
1844                    &["open", "closed"],
1845                ),
1846                "vocabulary" => {
1847                    if v.as_str().is_none() {
1848                        issues.push(ConfigIssue {
1849                            key: dotted,
1850                            kind: ConfigIssueKind::InvalidValue {
1851                                value: value_summary(v),
1852                                expected: vec!["a link to a vocabulary document".into()],
1853                            },
1854                        });
1855                    }
1856                }
1857                "reify" => bool_axis(issues, &dotted, v),
1858                // Any value: the starting value of a field is whatever the
1859                // field holds, and a `seq` field's is a list. Whether it is a
1860                // term of a closed vocabulary is `check`'s question, asked of
1861                // the document that ends up carrying it.
1862                "default" => {}
1863                // A link, resolved against the tree at read time; whether it
1864                // names an index is not a question one config surface can
1865                // answer.
1866                "under" => {
1867                    if v.as_str().is_none() {
1868                        issues.push(ConfigIssue {
1869                            key: dotted,
1870                            kind: ConfigIssueKind::InvalidValue {
1871                                value: value_summary(v),
1872                                expected: vec![
1873                                    "a link to the index this declaration governs".into(),
1874                                ],
1875                            },
1876                        });
1877                    }
1878                }
1879                other => {
1880                    if let Some(sug) = nearest(other, FIELD_KEYS) {
1881                        issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1882                    }
1883                }
1884            }
1885        }
1886    }
1887}
1888
1889/// Diagnose the `views:` block — a mapping of view name to a view declaration.
1890///
1891/// The judgment is `prov-views`' (one definition of what a view is, shared with
1892/// the crate that executes one); this is the translation into config-issue
1893/// vocabulary, plus the near-miss suggestion, which needs the edit distance
1894/// every other config near-miss already uses.
1895fn diagnose_views(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
1896    let Some(map) = value.as_mapping() else {
1897        return block_shape_issue(issues, "views", value);
1898    };
1899    for (name, spec) in map {
1900        let prefix = format!("views.{name}");
1901        diagnose_nest_is_fileable(issues, &prefix, spec, surface);
1902        for issue in prov_views::diagnose_view(name, spec) {
1903            let dotted = match issue.key.as_str() {
1904                "" => prefix.clone(),
1905                key => format!("{prefix}.{key}"),
1906            };
1907            let expected = || issue.kind.expected().iter().map(|s| (*s).into()).collect();
1908            match &issue.kind {
1909                ViewIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
1910                ViewIssueKind::NoGrouping => issues.push(ConfigIssue {
1911                    key: dotted,
1912                    kind: ConfigIssueKind::InvalidValue {
1913                        value: spec
1914                            .get("group")
1915                            .map_or_else(|| "(absent)".to_string(), value_summary),
1916                        expected: vec![
1917                            "a field name, or a list of field names to try in order".into(),
1918                        ],
1919                    },
1920                }),
1921                ViewIssueKind::BadGrain => issues.push(ConfigIssue {
1922                    key: dotted.clone(),
1923                    kind: ConfigIssueKind::InvalidValue {
1924                        value: spec
1925                            .get(&issue.key)
1926                            .map_or_else(|| "(absent)".to_string(), value_summary),
1927                        expected: expected(),
1928                    },
1929                }),
1930                ViewIssueKind::NoCondition => issues.push(ConfigIssue {
1931                    key: dotted,
1932                    kind: ConfigIssueKind::InvalidValue {
1933                        value: spec
1934                            .get("where")
1935                            .map_or_else(|| "(absent)".to_string(), value_summary),
1936                        expected: expected(),
1937                    },
1938                }),
1939                // Unlike a stray *top-level* key — which may be a user-owned
1940                // field prov never reads (DESIGN §2) — a stray key inside a
1941                // `views.<name>` entry is inside a block prov defines
1942                // completely, so a near-miss is the only thing it can be.
1943                ViewIssueKind::UnknownKey => {
1944                    if let Some(sug) = nearest(&issue.key, prov_views::VIEW_KEYS) {
1945                        issues.push(unknown(dotted, format!("{prefix}.{sug}")));
1946                    }
1947                }
1948            }
1949        }
1950    }
1951}
1952
1953/// Flag a `nest:` on a view that groups by a field the workspace declares
1954/// **multi-valued** (`fields.<name>.type: seq`).
1955///
1956/// `nest` files a record into the spanning relation, which is single-parent, so
1957/// a document with two values for the grouping field has two homes and no way
1958/// to choose between them. Grouping by such a field is perfectly good — that is
1959/// the whole point of a view — so this flags only the *filing* half.
1960///
1961/// Reported rather than left to bite later because `nest:` is a description a
1962/// frontend acts on, so the failure surfaces at the moment someone creates a
1963/// document, which is the worst time to discover it. `ViewSpec::nest_route`
1964/// returns `None` for the same case at runtime, so the two agree.
1965///
1966/// Only fires when `fields` and `views` are declared in the **same config
1967/// surface**: `diagnose` lints one surface at a time and cannot see the merged
1968/// config, which is the same bound every other cross-key check here has.
1969fn diagnose_nest_is_fileable(
1970    issues: &mut Vec<ConfigIssue>,
1971    prefix: &str,
1972    spec: &Value,
1973    surface: &Mapping,
1974) {
1975    if spec.get("nest").is_none() {
1976        return;
1977    }
1978    let Some(fields) = surface.get("fields").and_then(Value::as_mapping) else {
1979        return;
1980    };
1981    let Some(view) = prov_views::ViewSpec::parse("", spec) else {
1982        return;
1983    };
1984    // Any key in the chain being multi-valued is enough: the chain picks
1985    // whichever is filled in, so a document could reach the `seq` one. And
1986    // any *declaration* of the key being multi-valued is enough, for the same
1987    // reason — a scoped one governs some of the documents the view files.
1988    let declares_seq = |decl: &Value| {
1989        decl.get("type")
1990            .and_then(Value::as_str)
1991            .and_then(field_type_from_config_str)
1992            == Some(FieldType::Seq)
1993    };
1994    let multi: Vec<&String> = view
1995        .group
1996        .keys
1997        .iter()
1998        .filter(|key| match fields.get(*key) {
1999            Some(Value::Sequence(decls)) => decls.iter().any(declares_seq),
2000            Some(decl) => declares_seq(decl),
2001            None => false,
2002        })
2003        .collect();
2004    if let Some(field) = multi.first() {
2005        issues.push(ConfigIssue {
2006            key: format!("{prefix}.nest"),
2007            kind: ConfigIssueKind::NestNotSingleValued {
2008                field: (*field).clone(),
2009            },
2010        });
2011    }
2012}
2013
2014/// Diagnose the `exports:` block — a mapping of export name to an export
2015/// declaration.
2016///
2017/// The judgment is `prov-exports`' (one definition of what an export is,
2018/// shared with the crate that plans one); this is the translation into
2019/// config-issue vocabulary, plus the near-miss suggestion. The stakes of the
2020/// translation are asymmetric here: a dropped export publishes *nothing*, so
2021/// every fatal issue below is a declaration someone wrote that silently does
2022/// not exist until this report says so.
2023fn diagnose_exports(issues: &mut Vec<ConfigIssue>, value: &Value, surface: &Mapping) {
2024    let Some(map) = value.as_mapping() else {
2025        return block_shape_issue(issues, "exports", value);
2026    };
2027    for (name, spec) in map {
2028        let prefix = format!("exports.{name}");
2029        diagnose_export_view_is_declared(issues, &prefix, spec, surface);
2030        for issue in prov_exports::diagnose_export(name, spec) {
2031            match &issue.kind {
2032                ExportIssueKind::NotAMapping => block_shape_issue(issues, &prefix, spec),
2033                ExportIssueKind::NoGate => issues.push(ConfigIssue {
2034                    key: format!("{prefix}.gate"),
2035                    kind: ConfigIssueKind::InvalidValue {
2036                        value: spec
2037                            .get("gate")
2038                            .map_or_else(|| "(absent)".to_string(), value_summary),
2039                        expected: vec![
2040                            "a mapping with `field` and `value` — the field a document \
2041                             declares its membership in, and the value that admits it"
2042                                .into(),
2043                        ],
2044                    },
2045                }),
2046                ExportIssueKind::HoldNotAField => issues.push(ConfigIssue {
2047                    key: format!("{prefix}.hold"),
2048                    kind: ConfigIssueKind::InvalidValue {
2049                        value: spec
2050                            .get("hold")
2051                            .map_or_else(|| "(absent)".to_string(), value_summary),
2052                        expected: vec![
2053                            "the name of a field — a document the gate admits is held \
2054                             back while it declares `true` under it (`hold: draft`)"
2055                                .into(),
2056                        ],
2057                    },
2058                }),
2059                // A stray key inside an `exports.<name>` entry (or its gate)
2060                // is inside a block prov defines completely, so a near-miss is
2061                // the only thing it can be — same reasoning as `views`.
2062                ExportIssueKind::UnknownKey => {
2063                    if let Some(sug) = nearest(&issue.key, prov_exports::EXPORT_KEYS) {
2064                        issues.push(unknown(
2065                            format!("{prefix}.{}", issue.key),
2066                            format!("{prefix}.{sug}"),
2067                        ));
2068                    }
2069                }
2070                ExportIssueKind::GateUnknownKey => {
2071                    if let Some(sug) = nearest(&issue.key, prov_exports::GATE_KEYS) {
2072                        issues.push(unknown(
2073                            format!("{prefix}.gate.{}", issue.key),
2074                            format!("{prefix}.gate.{sug}"),
2075                        ));
2076                    }
2077                }
2078            }
2079        }
2080    }
2081}
2082
2083/// Flag an export arranged by a view its own surface does not declare.
2084///
2085/// The runtime refuses such an export outright (`prov-exports` fails closed
2086/// rather than falling back to the gate's whole set), so this is the
2087/// author-time half: reported here, the typo is fixed before the first
2088/// preview; unreported, it surfaces as a refusal at the moment someone tries
2089/// to publish, which is the worst time.
2090///
2091/// Only fires when `views` and `exports` are declared in the **same config
2092/// surface** — `diagnose` lints one surface at a time, the same bound every
2093/// other cross-key check here has.
2094fn diagnose_export_view_is_declared(
2095    issues: &mut Vec<ConfigIssue>,
2096    prefix: &str,
2097    spec: &Value,
2098    surface: &Mapping,
2099) {
2100    let Some(named) = spec.get("view").and_then(Value::as_str).map(str::trim) else {
2101        return;
2102    };
2103    let Some(views) = surface
2104        .get(prov_views::VIEWS_KEY)
2105        .and_then(Value::as_mapping)
2106    else {
2107        return;
2108    };
2109    if named.is_empty() || views.contains_key(named) {
2110        return;
2111    }
2112    let declared: Vec<String> = views.keys().cloned().collect();
2113    issues.push(ConfigIssue {
2114        key: format!("{prefix}.view"),
2115        kind: ConfigIssueKind::InvalidValue {
2116            value: named.to_string(),
2117            expected: declared,
2118        },
2119    });
2120}
2121
2122/// Flag a block key whose value is not a mapping (e.g. `references: markdown`).
2123fn block_shape_issue(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
2124    issues.push(ConfigIssue {
2125        key: key.to_string(),
2126        kind: ConfigIssueKind::InvalidValue {
2127            value: value_summary(value),
2128            expected: vec!["a block of keys".into()],
2129        },
2130    });
2131}
2132
2133/// Check an enum-valued axis, pushing an `InvalidValue` (with the accepted
2134/// spellings) when the written value does not parse.
2135fn enum_axis(
2136    issues: &mut Vec<ConfigIssue>,
2137    key: &str,
2138    value: &Value,
2139    parses: impl Fn(&str) -> bool,
2140    expected: &[&str],
2141) {
2142    if !value.as_str().is_some_and(parses) {
2143        issues.push(ConfigIssue {
2144            key: key.to_string(),
2145            kind: ConfigIssueKind::InvalidValue {
2146                value: value_summary(value),
2147                expected: expected.iter().map(|s| s.to_string()).collect(),
2148            },
2149        });
2150    }
2151}
2152
2153/// Check a bool-valued axis.
2154fn bool_axis(issues: &mut Vec<ConfigIssue>, key: &str, value: &Value) {
2155    if value.as_bool().is_none() {
2156        issues.push(ConfigIssue {
2157            key: key.to_string(),
2158            kind: ConfigIssueKind::InvalidValue {
2159                value: value_summary(value),
2160                expected: vec!["true".into(), "false".into()],
2161            },
2162        });
2163    }
2164}
2165
2166fn unknown(key: String, suggestion: String) -> ConfigIssue {
2167    ConfigIssue {
2168        key,
2169        kind: ConfigIssueKind::UnknownKey { suggestion },
2170    }
2171}
2172
2173/// The `metadata.format` spellings compiled into this build (yaml is always
2174/// available; the rest are feature-gated, matching [`format_from_str`]).
2175fn embed_format_spellings() -> Vec<&'static str> {
2176    // `mut` is used only when a format feature below is compiled in.
2177    #[allow(unused_mut)]
2178    let mut v = vec!["yaml"];
2179    #[cfg(feature = "json")]
2180    v.push("json");
2181    #[cfg(feature = "toml")]
2182    v.push("toml");
2183    #[cfg(feature = "fig-lang")]
2184    v.push("fig");
2185    v
2186}
2187
2188/// A short, human-readable rendering of a config value for a diagnostic message.
2189fn value_summary(value: &Value) -> String {
2190    match value {
2191        Value::String(s) => s.clone(),
2192        Value::Bool(b) => b.to_string(),
2193        Value::Int(i) => i.to_string(),
2194        Value::Float(f) => f.to_string(),
2195        _ => "(non-scalar)".to_string(),
2196    }
2197}
2198
2199/// Parse a `metadata.format` config value (`yaml`/`json`/`toml`/`fig`) into a
2200/// metadata [`fig::Format`], honoring the compiled-in formats — the public form of
2201/// [`format_from_str`], for callers that name a frontmatter language from outside
2202/// the config parser (the CLI's `convert … metadata.format …`).
2203pub fn metadata_format_from_str(value: &str) -> Option<fig::Format> {
2204    format_from_str(value)
2205}
2206
2207/// The `metadata.format` config spelling for a metadata [`fig::Format`] — the
2208/// public form of [`format_str`], and the inverse of [`metadata_format_from_str`].
2209pub fn metadata_format_str(format: fig::Format) -> &'static str {
2210    format_str(format)
2211}
2212
2213/// Parse the `metadata.format` config value into a metadata format (only the
2214/// compiled-in formats are recognized; others → `None`, keeping the default).
2215fn format_from_str(value: &str) -> Option<fig::Format> {
2216    match value {
2217        "yaml" | "yml" => Some(fig::Format::Yaml),
2218        #[cfg(feature = "json")]
2219        "json" => Some(fig::Format::Json),
2220        #[cfg(feature = "toml")]
2221        "toml" => Some(fig::Format::Toml),
2222        #[cfg(feature = "fig-lang")]
2223        "fig" => Some(fig::Format::Fig),
2224        _ => None,
2225    }
2226}
2227
2228/// The `metadata.format` config spelling for a metadata format.
2229fn format_str(format: fig::Format) -> &'static str {
2230    match format {
2231        #[cfg(feature = "json")]
2232        fig::Format::Json => "json",
2233        #[cfg(feature = "toml")]
2234        fig::Format::Toml => "toml",
2235        #[cfg(feature = "fig-lang")]
2236        fig::Format::Fig => "fig",
2237        _ => "yaml",
2238    }
2239}
2240
2241/// Parse a relation `cardinality` config value (`one`/`many`); unknown → `None`.
2242fn cardinality_from_str(value: &str) -> Option<Cardinality> {
2243    match value {
2244        "one" => Some(Cardinality::One),
2245        "many" => Some(Cardinality::Many),
2246        _ => None,
2247    }
2248}
2249
2250/// The `cardinality` config spelling for a [`Cardinality`].
2251fn cardinality_str(cardinality: Cardinality) -> &'static str {
2252    match cardinality {
2253        Cardinality::One => "one",
2254        Cardinality::Many => "many",
2255    }
2256}
2257
2258/// Parse the `identity` config value into a registration trigger set. `none` is
2259/// the canonical spelling for "identity off" (see `docs/config-vocab.md`), but
2260/// `off` is accepted as a synonym so the two never diverge: it is the word the
2261/// CLI's `--identity` flag and every other "off" axis (`fixity: off`) use, and a
2262/// user who reaches for it must not be told it is invalid.
2263fn registration_from_str(value: &str) -> Option<Registration> {
2264    match value {
2265        "none" | "off" => Some(Registration::OFF),
2266        "lazy" => Some(Registration::LAZY),
2267        "eager" => Some(Registration::EAGER),
2268        _ => None,
2269    }
2270}
2271
2272/// The `identity` config spelling for a registration trigger set. A custom
2273/// combination (not one of the three presets) is reported as its nearest name.
2274fn registration_str(registration: Registration) -> &'static str {
2275    match registration {
2276        Registration::OFF => "none",
2277        Registration::EAGER => "eager",
2278        _ => "lazy",
2279    }
2280}
2281
2282#[cfg(test)]
2283mod tests {
2284    use super::*;
2285    use prov_graph::identity::Trigger;
2286
2287    /// A config surface as a `Value::Mapping` from `(key, value)` pairs, values
2288    /// inferred as bools where they parse.
2289    fn config_doc(pairs: &[(&str, &str)]) -> Value {
2290        let mut map = Mapping::new();
2291        for (k, v) in pairs {
2292            let value = match *v {
2293                "true" => Value::Bool(true),
2294                "false" => Value::Bool(false),
2295                other => Value::String(other.into()),
2296            };
2297            map.insert((*k).into(), value);
2298        }
2299        Value::Mapping(map)
2300    }
2301
2302    /// A config surface declaring `out_of_scope` and nothing else.
2303    fn scope_doc(dirs: &[&str]) -> Value {
2304        let mut map = Mapping::new();
2305        map.insert(
2306            "out_of_scope".into(),
2307            Value::Sequence(dirs.iter().map(|d| Value::String((*d).into())).collect()),
2308        );
2309        Value::Mapping(map)
2310    }
2311
2312    /// A structural definition, the shape a `relations.<name>` mapping entry
2313    /// parses to.
2314    fn rel(cardinality: Cardinality, inverse: &str) -> RelationDef {
2315        RelationDef {
2316            cardinality: Some(cardinality),
2317            inverse: Some(inverse.to_string()),
2318            ..RelationDef::default()
2319        }
2320    }
2321
2322    /// The relation named, if the built vocabulary has it.
2323    fn built<'a>(set: &'a RelationSet, name: &str) -> Option<&'a prov_graph::relation::Relation> {
2324        set.relations().iter().find(|r| r.name == name)
2325    }
2326
2327    // Uses YAML frontmatter fixtures, so it runs under the `yaml` feature.
2328    #[test]
2329    #[cfg(feature = "yaml")]
2330    fn a_vocabulary_declaring_only_a_new_pair_keeps_the_preset_it_did_not_mention() {
2331        use prov_graph::document::Document;
2332
2333        // No relation defs → the diaryx preset unchanged (graceful degradation).
2334        let default_set = WorkspaceConfig::default().relation_set();
2335        assert_eq!(default_set.spanning_relation(), Some("contents"));
2336        assert_eq!(default_set.registry_relation(), Some("registry"));
2337
2338        // The scenario extension is *for*: one new pair, nothing else said. The
2339        // four content relations and the spine must survive it — under the old
2340        // replace semantics this collapsed the vocabulary to `front_page`/
2341        // `fronts` and left the workspace with no tree.
2342        let config = WorkspaceConfig {
2343            relation_defs: BTreeMap::from([
2344                ("front_page".to_string(), rel(Cardinality::One, "fronts")),
2345                ("fronts".to_string(), rel(Cardinality::Many, "front_page")),
2346            ]),
2347            ..WorkspaceConfig::default()
2348        };
2349        let set = config.relation_set();
2350        for preset in ["contents", "part_of", "links", "link_of"] {
2351            assert!(built(&set, preset).is_some(), "{preset} was dropped");
2352        }
2353        assert!(built(&set, "front_page").is_some());
2354        assert_eq!(set.spanning_relation(), Some("contents"));
2355
2356        let d = Document::parse(
2357            "index.md",
2358            "---\ncontents:\n- one.md\n- two.md\n---\nbody\n",
2359        )
2360        .expect("document");
2361        assert_eq!(
2362            set.children(&fig::Value::from(&d.meta)),
2363            vec!["one.md".to_string(), "two.md".to_string()]
2364        );
2365    }
2366
2367    #[test]
2368    fn a_redefined_relation_replaces_the_preset_one_rather_than_joining_it() {
2369        // `links` is in the preset as many/`link_of`; redeclaring it one/`cites`
2370        // must leave exactly one `links`, not two relations racing to read the
2371        // same key.
2372        let config = WorkspaceConfig {
2373            relation_defs: BTreeMap::from([("links".to_string(), rel(Cardinality::One, "cites"))]),
2374            ..WorkspaceConfig::default()
2375        };
2376        let set = config.relation_set();
2377        assert_eq!(
2378            set.relations().iter().filter(|r| r.name == "links").count(),
2379            1
2380        );
2381        let links = built(&set, "links").expect("links");
2382        assert_eq!(links.cardinality, Cardinality::One);
2383        assert_eq!(links.inverse.as_deref(), Some("cites"));
2384    }
2385
2386    #[test]
2387    fn glossing_a_preset_relation_keeps_its_shape() {
2388        // The overlay is per field: an author writing a `means:` for `contents`
2389        // is documenting the vocabulary, not redefining it, and must not
2390        // silently strip the spine's inverse or reset its cardinality.
2391        let config = WorkspaceConfig {
2392            relation_defs: BTreeMap::from([(
2393                "contents".to_string(),
2394                RelationDef {
2395                    means: Some("chapters of this book".into()),
2396                    ..RelationDef::default()
2397                },
2398            )]),
2399            ..WorkspaceConfig::default()
2400        };
2401        let set = config.relation_set();
2402        let contents = built(&set, "contents").expect("contents");
2403        assert_eq!(contents.cardinality, Cardinality::Many);
2404        assert_eq!(contents.inverse.as_deref(), Some("part_of"));
2405        assert_eq!(set.spanning_relation(), Some("contents"));
2406    }
2407
2408    #[test]
2409    fn an_off_entry_takes_the_name_out_of_the_vocabulary() {
2410        // Nothing else moves: `off` is a retraction of one name, so the rest of
2411        // the preset — the spine included — is exactly where it was.
2412        let config = WorkspaceConfig {
2413            relation_defs: BTreeMap::from([(
2414                "link_of".to_string(),
2415                RelationDef {
2416                    off: true,
2417                    ..RelationDef::default()
2418                },
2419            )]),
2420            ..WorkspaceConfig::default()
2421        };
2422        let set = config.relation_set();
2423        assert!(built(&set, "link_of").is_none());
2424        assert!(built(&set, "links").is_some());
2425        assert_eq!(set.spanning_relation(), Some("contents"));
2426    }
2427
2428    #[test]
2429    fn a_pointer_turned_off_stops_being_a_relation_but_still_points() {
2430        // The five pointers are how a reader finds the workspace's machinery
2431        // (§6), not vocabulary the workspace gets to revoke: `off` takes
2432        // `registry` out of the relation list, and prov still reads the root's
2433        // `registry:` key to find the registry.
2434        let config = WorkspaceConfig {
2435            relation_defs: BTreeMap::from([(
2436                "registry".to_string(),
2437                RelationDef {
2438                    off: true,
2439                    ..RelationDef::default()
2440                },
2441            )]),
2442            ..WorkspaceConfig::default()
2443        };
2444        let set = config.relation_set();
2445        assert!(built(&set, "registry").is_none());
2446        assert_eq!(set.registry_relation(), Some("registry"));
2447        assert_eq!(set.config_relation(), Some("config"));
2448        assert_eq!(set.about_relation(), Some("about"));
2449    }
2450
2451    #[test]
2452    fn wholesale_replacement_is_declaring_a_vocabulary_and_turning_the_preset_off() {
2453        // The old all-or-nothing shape, still expressible — but now spelled out,
2454        // so nobody arrives at it by declaring one relation and losing four.
2455        let mut defs = BTreeMap::from([
2456            ("part".to_string(), rel(Cardinality::Many, "whole")),
2457            ("whole".to_string(), rel(Cardinality::One, "part")),
2458        ]);
2459        for preset in ["contents", "part_of", "links", "link_of"] {
2460            defs.insert(
2461                preset.to_string(),
2462                RelationDef {
2463                    off: true,
2464                    ..RelationDef::default()
2465                },
2466            );
2467        }
2468        let config = WorkspaceConfig {
2469            spanning: Some("part".into()),
2470            relation_defs: defs,
2471            ..WorkspaceConfig::default()
2472        };
2473        let set = config.relation_set();
2474        assert_eq!(set.spanning_relation(), Some("part"));
2475        let names: Vec<&str> = set.relations().iter().map(|r| r.name.as_str()).collect();
2476        assert_eq!(
2477            names,
2478            vec![
2479                "registry",
2480                "config",
2481                "deletions",
2482                "recycle_bin",
2483                "history",
2484                "about",
2485                "part",
2486                "whole"
2487            ],
2488            "only the pointers and the declared pair remain"
2489        );
2490    }
2491
2492    #[test]
2493    fn presets_encode_the_two_styles() {
2494        // Diaryx: no identity, path addressing. Obsidian: identity + id addressing.
2495        assert_eq!(WorkspaceConfig::paths_only().identity, Registration::OFF);
2496        assert_eq!(
2497            WorkspaceConfig::paths_only().reference_target,
2498            Addressing::Path
2499        );
2500        assert!(
2501            WorkspaceConfig::stable_ids()
2502                .identity
2503                .fires_on(Trigger::Link)
2504        );
2505        assert_eq!(
2506            WorkspaceConfig::stable_ids().reference_target,
2507            Addressing::Id
2508        );
2509    }
2510
2511    #[test]
2512    fn round_trips_through_a_nested_mapping() {
2513        let config = WorkspaceConfig {
2514            identity: Registration::EAGER,
2515            notation: Notation::Bare,
2516            path_style: PathStyle::Relative,
2517            reference_target: Addressing::Id,
2518            reference_label: true,
2519            relation_styles: BTreeMap::from([
2520                (
2521                    "contents".to_string(),
2522                    RelationStyleConfig {
2523                        notation: Some(Notation::Wikilink),
2524                        path_style: None,
2525                        target: Some(Addressing::Alias),
2526                        label: None,
2527                    },
2528                ),
2529                (
2530                    "part_of".to_string(),
2531                    RelationStyleConfig {
2532                        notation: Some(Notation::Markdown),
2533                        path_style: Some(PathStyle::Relative),
2534                        target: Some(Addressing::Id),
2535                        label: Some(false),
2536                    },
2537                ),
2538            ]),
2539            spanning: Some("contents".to_string()),
2540            relation_defs: BTreeMap::from([
2541                (
2542                    "contents".to_string(),
2543                    RelationDef {
2544                        cardinality: Some(Cardinality::Many),
2545                        inverse: Some("part_of".to_string()),
2546                        means: Some("documents contained by this one".to_string()),
2547                        off: false,
2548                    },
2549                ),
2550                (
2551                    "part_of".to_string(),
2552                    RelationDef {
2553                        cardinality: Some(Cardinality::One),
2554                        inverse: Some("contents".to_string()),
2555                        means: None,
2556                        off: false,
2557                    },
2558                ),
2559                // A retraction: written as the scalar `off` rather than a
2560                // settings mapping, so it is the one entry shape whose round
2561                // trip goes through a different branch at both ends.
2562                (
2563                    "link_of".to_string(),
2564                    RelationDef {
2565                        off: true,
2566                        ..RelationDef::default()
2567                    },
2568                ),
2569            ]),
2570            fields: BTreeMap::from([
2571                (
2572                    "audience".to_string(),
2573                    vec![FieldSpec {
2574                        ty: Some(FieldType::Str),
2575                        values: OpenClosed::Closed,
2576                        vocabulary: Some("[Audiences](/vocab/audiences.yaml)".to_string()),
2577                        reify: true,
2578                        // A starting value, carried as the value it is rather
2579                        // than as text, so a `default: 3` round-trips as an int.
2580                        default: Some(Value::String("friends".to_string())),
2581                        under: None,
2582                    }],
2583                ),
2584                // A type with no vocabulary — the other half of a field
2585                // declaration, and the shape that has no `values` to write.
2586                (
2587                    "created".to_string(),
2588                    vec![FieldSpec {
2589                        ty: Some(FieldType::Extended(ExtKind::LocalDate)),
2590                        values: OpenClosed::default(),
2591                        vocabulary: None,
2592                        reify: false,
2593                        default: None,
2594                        under: None,
2595                    }],
2596                ),
2597            ]),
2598            views: vec![
2599                // A scoped, materializing view with a fallback chain — every
2600                // optional key populated, so nothing survives the round trip by
2601                // being absent at both ends.
2602                ViewSpec {
2603                    name: "daily".to_string(),
2604                    label: Some("Daily".to_string()),
2605                    icon: Some("calendar".to_string()),
2606                    group: prov_views::Grouping {
2607                        keys: vec!["date_of_document".to_string(), "created".to_string()],
2608                        by: Some(prov_views::Grain::Month),
2609                    },
2610                    under: Some("[Daily](id:abc1234)".to_string()),
2611                    // A condition too, so the round trip covers `where:`.
2612                    filter: Some(prov_views::Condition::Not(Box::new(
2613                        prov_views::Condition::Has("draft".to_string()),
2614                    ))),
2615                    nest: Some(prov_views::Grain::Year),
2616                },
2617                // …and the minimal one, which must not gain keys on the way
2618                // back.
2619                ViewSpec {
2620                    name: "who".to_string(),
2621                    label: None,
2622                    icon: None,
2623                    group: prov_views::Grouping::field("people"),
2624                    under: None,
2625                    filter: None,
2626                    nest: None,
2627                },
2628            ],
2629            exports: vec![
2630                // Every optional key populated, and the minimal form, for the
2631                // same reason as the two views above.
2632                ExportSpec {
2633                    name: "letters".to_string(),
2634                    label: Some("Letters home".to_string()),
2635                    gate: prov_exports::Gate {
2636                        field: "audience".to_string(),
2637                        value: "family".to_string(),
2638                    },
2639                    hold: Some("draft".to_string()),
2640                    view: Some("daily".to_string()),
2641                },
2642                ExportSpec {
2643                    name: "notes".to_string(),
2644                    label: None,
2645                    gate: prov_exports::Gate {
2646                        field: "audience".to_string(),
2647                        value: "public".to_string(),
2648                    },
2649                    hold: None,
2650                    view: None,
2651                },
2652            ],
2653            id_storage: IdStorage::Frontmatter,
2654            default_embed_format: fig::Format::Yaml,
2655            embed_style: EmbedStyle::CodeBlock,
2656            content_format: ContentFormat::Djot,
2657            record_deletions: false,
2658            fixity: Fixity::Off,
2659            // Non-default, so the round trip actually exercises the axis.
2660            // Likewise non-default — `structure` is the default, so `off` is
2661            // what proves the value survives the mapping rather than being
2662            // silently re-defaulted on the way back.
2663            about: About::Off,
2664            updated: "modified".to_string(),
2665            created: "made".to_string(),
2666            // Non-default (the default is anonymous), so the round trip proves
2667            // the name survives rather than being silently dropped.
2668            workspace_id: "notes".to_string(),
2669            // Sorted here rather than as authored: `apply` normalizes, so a
2670            // list written in any other order would fail this round trip for
2671            // the right reason.
2672            out_of_scope: vec![".obsidian".to_string(), "history".to_string()],
2673            root: Some("home.md".into()),
2674        };
2675        let back = WorkspaceConfig::from_meta(&Value::Mapping(config.to_mapping()));
2676        assert_eq!(back, config);
2677    }
2678
2679    #[test]
2680    fn per_relation_styles_resolve_over_the_workspace_default() {
2681        // The diaryx up≠down example: a workspace default target of `id`, with
2682        // `contents` (down) overridden to a nominal alias wikilink and `part_of`
2683        // (up) to a bare markdown id link — each partial overlaying the default.
2684        let mut cfg = WorkspaceConfig::default();
2685        cfg.apply(&config_doc_nested(
2686            &[("target", "id")],
2687            &[
2688                ("contents", &[("notation", "wikilink"), ("target", "alias")]),
2689                ("part_of", &[("target", "id")]),
2690            ],
2691        ));
2692
2693        let styles = cfg.resolved_relation_styles();
2694        let down = styles.get("contents").expect("contents style");
2695        assert_eq!(down.wrapper, prov_graph::link::Wrapper::Wikilink);
2696        assert_eq!(down.addressing, Addressing::Alias);
2697
2698        let up = styles.get("part_of").expect("part_of style");
2699        // Inherits the default notation (markdown), keeps its own id target.
2700        assert_eq!(up.wrapper, prov_graph::link::Wrapper::Markdown);
2701        assert_eq!(up.addressing, Addressing::Id);
2702    }
2703
2704    /// Build a config value with a top-level `references` block and a `relations`
2705    /// block of per-relation overrides.
2706    fn config_doc_nested(
2707        references: &[(&str, &str)],
2708        relations: &[(&str, &[(&str, &str)])],
2709    ) -> Value {
2710        let mut top = Mapping::new();
2711        let mut refs = Mapping::new();
2712        for (k, v) in references {
2713            refs.insert((*k).into(), Value::String((*v).into()));
2714        }
2715        top.insert("references".into(), Value::Mapping(refs));
2716        let mut rels = Mapping::new();
2717        for (name, axes) in relations {
2718            let mut spec = Mapping::new();
2719            for (k, v) in *axes {
2720                spec.insert((*k).into(), Value::String((*v).into()));
2721            }
2722            rels.insert((*name).into(), Value::Mapping(spec));
2723        }
2724        top.insert("relations".into(), Value::Mapping(rels));
2725        Value::Mapping(top)
2726    }
2727
2728    #[test]
2729    fn a_retired_canonical_path_style_is_reported_and_falls_back_to_root() {
2730        // The migration contract for a workspace still configured with the
2731        // retired value. Two things have to be true at once, and they pull in
2732        // opposite directions: the workspace must keep *loading* (an archive
2733        // that will not open because a setting was withdrawn is worse than the
2734        // setting), and it must not quietly keep resolving links the way the
2735        // broken style did.
2736        //
2737        // Falling back to `root` is what squares them. `canonical` emitted a
2738        // bare workspace-relative path that `resolve` reads directory-relative,
2739        // so it only ever resolved correctly from the workspace root; `root`
2740        // emits the same path with the leading slash that makes that reading
2741        // explicit, and resolves correctly from anywhere. `check` says so, and
2742        // `prov convert <root> link_format markdown_root -r` rewrites the
2743        // documents to match.
2744        let mut cfg = WorkspaceConfig::default();
2745        let mut refs = Mapping::new();
2746        refs.insert("path_style".into(), Value::String("canonical".into()));
2747        let mut top = Mapping::new();
2748        top.insert("references".into(), Value::Mapping(refs));
2749        let meta = Value::Mapping(top);
2750
2751        cfg.apply(&meta);
2752        assert_eq!(cfg.path_style, PathStyle::Root, "the resolvable spelling");
2753
2754        let issues = diagnose(&meta);
2755        assert!(
2756            issues.iter().any(|i| matches!(
2757                &i.kind,
2758                ConfigIssueKind::InvalidValue { value, expected }
2759                    if value.contains("canonical") && expected == &["root", "relative"]
2760            )),
2761            "{issues:?}"
2762        );
2763    }
2764
2765    #[test]
2766    fn reference_axes_orthogonalize_notation_and_resolution() {
2767        // bare + relative renders a plain directory-relative path; wikilink wraps.
2768        let mut cfg = WorkspaceConfig::default();
2769        let mut refs = Mapping::new();
2770        refs.insert("notation".into(), Value::String("bare".into()));
2771        refs.insert("path_style".into(), Value::String("relative".into()));
2772        let mut top = Mapping::new();
2773        top.insert("references".into(), Value::Mapping(refs));
2774        cfg.apply(&Value::Mapping(top));
2775        assert_eq!(cfg.link_format(), LinkStyle::PlainRelative);
2776        assert_eq!(cfg.notation, Notation::Bare);
2777        assert_eq!(cfg.path_style, PathStyle::Relative);
2778    }
2779
2780    #[test]
2781    fn apply_overlays_only_present_keys_so_the_config_document_wins() {
2782        let mut config = WorkspaceConfig::default();
2783        // Root block sets only content_format.
2784        config.apply(&config_doc(&[("content_format", "djot")]));
2785        assert_eq!(config.content_format, ContentFormat::Djot);
2786        assert_eq!(config.identity, Registration::LAZY, "identity untouched");
2787        // The config document then overrides identity; content_format preserved.
2788        config.apply(&config_doc(&[("identity", "none")]));
2789        assert_eq!(config.identity, Registration::OFF);
2790        assert_eq!(config.content_format, ContentFormat::Djot);
2791    }
2792
2793    /// The axis was spelled `recycle_bin` when a delete parked bytes in a bin.
2794    /// A config written then still means what it said — "record what a delete
2795    /// destroyed" — so it is read, not reported as an unknown key, and the
2796    /// current spelling wins if a document somehow carries both.
2797    #[test]
2798    fn the_old_spelling_of_the_deletion_axis_is_still_read() {
2799        let mut cfg = WorkspaceConfig::default();
2800        assert!(cfg.record_deletions, "on by default");
2801        cfg.apply(&config_doc(&[("recycle_bin", "false")]));
2802        assert!(!cfg.record_deletions, "the old spelling still turns it off");
2803        assert!(
2804            diagnose(&config_doc(&[("recycle_bin", "false")])).is_empty(),
2805            "and is not reported as an unknown key"
2806        );
2807
2808        // Both, in either order, resolve to the current one.
2809        let mut cfg = WorkspaceConfig::default();
2810        cfg.apply(&config_doc(&[
2811            ("record_deletions", "false"),
2812            ("recycle_bin", "true"),
2813        ]));
2814        assert!(!cfg.record_deletions, "the current spelling decides");
2815    }
2816
2817    #[test]
2818    fn diagnose_is_silent_on_a_clean_config_and_on_user_fields() {
2819        let doc = config_doc(&[
2820            ("title", "prov config"),
2821            ("part_of", "index.md"),
2822            ("id", "abc123"),
2823            ("spec", "1"),
2824            ("identity", "lazy"),
2825            ("fixity", "on"),
2826            ("record_deletions", "false"),
2827            ("content_format", "djot"),
2828            ("id_storage", "both"),
2829            ("author", "someone"),
2830        ]);
2831        assert!(diagnose(&doc).is_empty(), "flagged: {:?}", diagnose(&doc));
2832    }
2833
2834    #[test]
2835    fn diagnose_flags_a_misspelled_top_level_key_with_a_suggestion() {
2836        let issues = diagnose(&config_doc(&[("recyle_bin", "false")]));
2837        assert_eq!(issues.len(), 1);
2838        assert_eq!(
2839            issues[0].kind,
2840            ConfigIssueKind::UnknownKey {
2841                suggestion: "recycle_bin".into()
2842            }
2843        );
2844    }
2845
2846    /// A sequence of directory paths, normalized on the way in: trimmed,
2847    /// deduplicated, sorted, and with the trailing slash a person naturally
2848    /// types for a directory dropped. Normalizing here is what lets
2849    /// `to_mapping` round-trip stably.
2850    #[test]
2851    fn out_of_scope_is_normalized_when_applied() {
2852        let mut cfg = WorkspaceConfig::default();
2853        assert!(cfg.out_of_scope.is_empty(), "nothing declared by default");
2854        cfg.apply(&scope_doc(&["history/", " .obsidian ", "history"]));
2855        assert_eq!(cfg.out_of_scope, [".obsidian", "history"]);
2856    }
2857
2858    /// A malformed entry is dropped rather than half-honored — the same
2859    /// posture `workspace_id` has, and for the same reason: a path that names
2860    /// somewhere outside the workspace cannot bound a walk over it.
2861    #[test]
2862    fn out_of_scope_drops_entries_that_could_not_bound_a_walk() {
2863        let mut cfg = WorkspaceConfig::default();
2864        cfg.apply(&scope_doc(&[
2865            "/etc",
2866            "../sibling",
2867            "notes/./a",
2868            "",
2869            "history",
2870        ]));
2871        assert_eq!(cfg.out_of_scope, ["history"]);
2872    }
2873
2874    /// …and each dropped entry is reported, so a declaration that never takes
2875    /// effect is visible rather than silent. One issue per bad line, naming
2876    /// that line.
2877    #[test]
2878    fn diagnose_reports_each_unusable_out_of_scope_entry() {
2879        let issues = diagnose(&scope_doc(&["/etc", "history", "../sibling"]));
2880        assert_eq!(issues.len(), 2);
2881        assert!(issues.iter().all(|issue| issue.key == "out_of_scope"));
2882        assert!(
2883            issues
2884                .iter()
2885                .all(|issue| matches!(issue.kind, ConfigIssueKind::InvalidValue { .. }))
2886        );
2887    }
2888
2889    /// A scalar where a list belongs is one issue about the axis, not a silent
2890    /// no-op — the shape is wrong, so there are no entries to judge.
2891    #[test]
2892    fn diagnose_reports_an_out_of_scope_that_is_not_a_list() {
2893        let issues = diagnose(&config_doc(&[("out_of_scope", "history")]));
2894        assert_eq!(issues.len(), 1);
2895        assert_eq!(issues[0].key, "out_of_scope");
2896    }
2897
2898    #[test]
2899    fn a_scope_path_has_to_be_a_relative_directory() {
2900        assert!(is_valid_scope_path("history"));
2901        assert!(is_valid_scope_path("history/"));
2902        assert!(is_valid_scope_path("a/b/c"));
2903        assert!(is_valid_scope_path(".obsidian"));
2904        assert!(!is_valid_scope_path(""));
2905        assert!(!is_valid_scope_path("/"));
2906        assert!(!is_valid_scope_path("/absolute"));
2907        assert!(!is_valid_scope_path("../outside"));
2908        assert!(!is_valid_scope_path("a/../b"));
2909        assert!(!is_valid_scope_path("a/./b"));
2910        assert!(!is_valid_scope_path("a//b"));
2911        assert!(!is_valid_scope_path("a\\b"));
2912    }
2913
2914    #[test]
2915    fn workspace_id_applies_when_well_formed_and_is_ignored_when_not() {
2916        let mut cfg = WorkspaceConfig::default();
2917        assert_eq!(cfg.workspace_id, "", "anonymous by default");
2918
2919        cfg.apply(&config_doc(&[("workspace_id", "notes")]));
2920        assert_eq!(cfg.workspace_id, "notes");
2921
2922        // A malformed value never half-lands: the previous name stands rather
2923        // than being replaced by something prov cannot write into a reference.
2924        for bad in ["with/slash", "with:colon", "with space", ""] {
2925            cfg.apply(&config_doc(&[("workspace_id", bad)]));
2926            assert_eq!(cfg.workspace_id, "notes", "rejected {bad:?}");
2927        }
2928    }
2929
2930    #[test]
2931    fn diagnose_flags_a_malformed_workspace_id_but_not_an_empty_one() {
2932        for bad in ["with/slash", "with:colon", "with space"] {
2933            let issues = diagnose(&config_doc(&[("workspace_id", bad)]));
2934            assert_eq!(
2935                issues.first().map(|i| &i.kind),
2936                Some(&ConfigIssueKind::MalformedWorkspaceId {
2937                    value: bad.to_string()
2938                }),
2939                "{bad:?}"
2940            );
2941        }
2942        // Empty is the explicit spelling of anonymous — the same shape as an
2943        // empty `updated` — so it is clean, and `to_mapping` may write it.
2944        assert!(
2945            diagnose(&config_doc(&[("workspace_id", "")])).is_empty(),
2946            "an empty name is anonymity, not an error"
2947        );
2948        assert!(diagnose(&config_doc(&[("workspace_id", "notes")])).is_empty());
2949    }
2950
2951    #[test]
2952    fn diagnose_flags_bad_values_and_typos_inside_nested_blocks() {
2953        // references.notaton (typo) + references.target bad value.
2954        let mut refs = Mapping::new();
2955        refs.insert("notaton".into(), Value::String("markdown".into()));
2956        refs.insert("target".into(), Value::String("pointer".into()));
2957        let mut top = Mapping::new();
2958        top.insert("references".into(), Value::Mapping(refs));
2959        let issues = diagnose(&Value::Mapping(top));
2960        assert!(
2961            issues.iter().any(|i| i.key == "references.notaton"
2962                && matches!(&i.kind, ConfigIssueKind::UnknownKey { suggestion } if suggestion == "references.notation")),
2963            "{issues:?}"
2964        );
2965        assert!(
2966            issues.iter().any(|i| i.key == "references.target"
2967                && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, .. } if value == "pointer")),
2968            "{issues:?}"
2969        );
2970    }
2971
2972    #[test]
2973    fn diagnose_flags_an_unrecognized_value_on_a_real_key() {
2974        let issues = diagnose(&config_doc(&[("fixity", "alll")]));
2975        assert_eq!(issues.len(), 1);
2976        match &issues[0].kind {
2977            ConfigIssueKind::InvalidValue { value, expected } => {
2978                assert_eq!(value, "alll");
2979                assert!(expected.contains(&"on".to_string()), "{expected:?}");
2980            }
2981            other => panic!("expected InvalidValue, got {other:?}"),
2982        }
2983    }
2984
2985    #[test]
2986    fn the_retired_fixity_tier_is_reported_and_the_retired_default_is_read() {
2987        // `all` asked for body checksums. Nothing writes those now, so a
2988        // workspace that asked is told rather than quietly given something
2989        // narrower — it lands as an invalid value listing what remains.
2990        let issues = diagnose(&config_doc(&[("fixity", "all")]));
2991        assert_eq!(issues.len(), 1, "{issues:?}");
2992        assert!(
2993            matches!(&issues[0].kind, ConfigIssueKind::InvalidValue { value, expected }
2994                if value == "all" && expected.contains(&"on".to_string())),
2995            "{issues:?}"
2996        );
2997        // `attachments` named a subset of what `on` covers, so it stays silent
2998        // and keeps working — every `prov.yaml` written before this says it.
2999        assert!(diagnose(&config_doc(&[("fixity", "attachments")])).is_empty());
3000        let mut config = WorkspaceConfig::default();
3001        config.apply(&config_doc(&[("fixity", "attachments")]));
3002        assert_eq!(config.fixity, Fixity::On);
3003    }
3004
3005    #[test]
3006    fn about_defaults_on_and_accepts_only_its_two_spellings() {
3007        // Default is `structure`, not `off` — self-description by default is
3008        // the thesis, so the axis a person never sets still generates a page.
3009        assert_eq!(WorkspaceConfig::default().about, About::Structure);
3010        assert!(About::Structure.generates());
3011        assert!(!About::Off.generates());
3012
3013        let mut cfg = WorkspaceConfig::default();
3014        cfg.apply(&config_doc(&[("about", "off")]));
3015        assert_eq!(cfg.about, About::Off);
3016
3017        // An unknown spelling is a finding that names both accepted values, and
3018        // leaves the default in place rather than guessing.
3019        let issues = diagnose(&config_doc(&[("about", "structrue")]));
3020        assert_eq!(issues.len(), 1);
3021        match &issues[0].kind {
3022            ConfigIssueKind::InvalidValue { value, expected } => {
3023                assert_eq!(value, "structrue");
3024                assert!(expected.contains(&"structure".to_string()), "{expected:?}");
3025                assert!(expected.contains(&"off".to_string()), "{expected:?}");
3026            }
3027            other => panic!("expected InvalidValue, got {other:?}"),
3028        }
3029        let mut unchanged = WorkspaceConfig::default();
3030        unchanged.apply(&config_doc(&[("about", "structrue")]));
3031        assert_eq!(unchanged.about, About::Structure);
3032    }
3033
3034    #[test]
3035    fn relation_defs_and_spanning_apply_and_round_trip() {
3036        // A fully self-described `part`/`whole` vocabulary from config.
3037        let mut top = Mapping::new();
3038        top.insert("spanning".into(), Value::String("part".into()));
3039        let mut rels = Mapping::new();
3040        let mut part = Mapping::new();
3041        part.insert("cardinality".into(), Value::String("many".into()));
3042        part.insert("inverse".into(), Value::String("whole".into()));
3043        part.insert("means".into(), Value::String("the pieces".into()));
3044        let mut whole = Mapping::new();
3045        whole.insert("cardinality".into(), Value::String("one".into()));
3046        whole.insert("inverse".into(), Value::String("part".into()));
3047        rels.insert("part".into(), Value::Mapping(part));
3048        rels.insert("whole".into(), Value::Mapping(whole));
3049        top.insert("relations".into(), Value::Mapping(rels));
3050
3051        let cfg = WorkspaceConfig::from_meta(&Value::Mapping(top));
3052        assert_eq!(cfg.spanning.as_deref(), Some("part"));
3053        let part_def = cfg.relation_defs.get("part").expect("part def");
3054        assert_eq!(part_def.cardinality, Some(Cardinality::Many));
3055        assert_eq!(part_def.inverse.as_deref(), Some("whole"));
3056        assert_eq!(part_def.means.as_deref(), Some("the pieces"));
3057        // A clean self-described vocabulary passes its own diagnosis.
3058        assert!(diagnose(&Value::Mapping(cfg.to_mapping())).is_empty());
3059    }
3060
3061    #[test]
3062    fn an_off_relation_entry_parses_and_passes_its_own_diagnosis() {
3063        let mut rels = Mapping::new();
3064        rels.insert("link_of".into(), Value::String("off".into()));
3065        // Trimmed, not fuzzy: leading space is a formatting accident, `Off` is
3066        // a different word.
3067        rels.insert("links".into(), Value::String("  off ".into()));
3068        let mut top = Mapping::new();
3069        top.insert("relations".into(), Value::Mapping(rels));
3070
3071        let cfg = WorkspaceConfig::from_meta(&Value::Mapping(top.clone()));
3072        for name in ["link_of", "links"] {
3073            let def = cfg.relation_defs.get(name).expect(name);
3074            assert!(def.off, "{name}");
3075            assert_eq!(def.cardinality, None);
3076            assert_eq!(def.inverse, None);
3077        }
3078        let set = cfg.relation_set();
3079        assert!(!set.relations().iter().any(|r| r.name == "links"));
3080        assert!(diagnose(&Value::Mapping(top)).is_empty());
3081        // …and no style entry was synthesized for a scalar, so `to_mapping`
3082        // writes the retraction and nothing beside it.
3083        assert!(cfg.relation_styles.is_empty());
3084    }
3085
3086    #[test]
3087    fn a_relations_entry_that_is_neither_a_mapping_nor_off_is_a_finding() {
3088        let mut rels = Mapping::new();
3089        rels.insert("links".into(), Value::Bool(false));
3090        let mut top = Mapping::new();
3091        top.insert("relations".into(), Value::Mapping(rels));
3092
3093        let issues = diagnose(&Value::Mapping(top));
3094        assert_eq!(issues.len(), 1, "{issues:?}");
3095        assert_eq!(issues[0].key, "relations.links");
3096        match &issues[0].kind {
3097            ConfigIssueKind::InvalidValue { value, expected } => {
3098                assert_eq!(value, "false");
3099                // Both accepted shapes are named — a reader told only "a
3100                // mapping" would never find `off`.
3101                assert!(expected.iter().any(|e| e == "off"), "{expected:?}");
3102                assert!(
3103                    expected.iter().any(|e| e.contains("mapping")),
3104                    "{expected:?}"
3105                );
3106            }
3107            other => panic!("expected InvalidValue, got {other:?}"),
3108        }
3109    }
3110
3111    #[test]
3112    fn diagnose_flags_a_spine_the_relations_block_turns_off() {
3113        // Turning `contents` off without renaming the spine leaves a workspace
3114        // whose declared spanning relation is not a relation at all.
3115        let mut rels = Mapping::new();
3116        rels.insert("contents".into(), Value::String("off".into()));
3117        let mut top = Mapping::new();
3118        top.insert("spanning".into(), Value::String("contents".into()));
3119        top.insert("relations".into(), Value::Mapping(rels));
3120
3121        let issues = diagnose(&Value::Mapping(top));
3122        assert!(
3123            issues.iter().any(|i| i.key == "spanning"
3124                && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, .. } if value == "contents")),
3125            "{issues:?}"
3126        );
3127    }
3128
3129    #[test]
3130    fn diagnose_flags_a_spanning_relation_whose_inverse_is_many() {
3131        // `spanning: part`, but its inverse `whole` is declared `many` — that
3132        // cannot be a single-parent tree.
3133        let mut top = Mapping::new();
3134        top.insert("spanning".into(), Value::String("part".into()));
3135        let mut rels = Mapping::new();
3136        let mut part = Mapping::new();
3137        part.insert("inverse".into(), Value::String("whole".into()));
3138        let mut whole = Mapping::new();
3139        whole.insert("cardinality".into(), Value::String("many".into()));
3140        rels.insert("part".into(), Value::Mapping(part));
3141        rels.insert("whole".into(), Value::Mapping(whole));
3142        top.insert("relations".into(), Value::Mapping(rels));
3143
3144        let issues = diagnose(&Value::Mapping(top));
3145        assert!(
3146            issues.iter().any(|i| i.key == "spanning"
3147                && matches!(&i.kind, ConfigIssueKind::SpanningNotSingleParent { inverse } if inverse == "whole")),
3148            "{issues:?}"
3149        );
3150    }
3151
3152    /// A field declaration used to require a vocabulary to exist at all. A type
3153    /// is the other, independent half: `created` is a date that nothing controls.
3154    #[test]
3155    fn a_field_may_declare_a_type_without_a_vocabulary() {
3156        let mut created = Mapping::new();
3157        created.insert("type".into(), Value::String("date".into()));
3158        let mut fields = Mapping::new();
3159        fields.insert("created".into(), Value::Mapping(created));
3160        let mut top = Mapping::new();
3161        top.insert("fields".into(), Value::Mapping(fields));
3162
3163        let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
3164        let spec = config
3165            .fields
3166            .get("created")
3167            .and_then(|d| d.first())
3168            .expect("a recorded field");
3169        assert_eq!(spec.ty, Some(FieldType::Extended(ExtKind::LocalDate)));
3170        assert_eq!(spec.vocabulary, None);
3171    }
3172
3173    /// The third independent half: a starting value alone describes something
3174    /// `create` acts on, so it is a declaration on its own — and it is carried
3175    /// as the value written, not as its spelling.
3176    #[test]
3177    fn a_field_may_declare_only_a_starting_value() {
3178        let mut status = Mapping::new();
3179        status.insert("default".into(), Value::String("open".into()));
3180        let mut count = Mapping::new();
3181        count.insert("default".into(), Value::Int(0));
3182        let mut fields = Mapping::new();
3183        fields.insert("status".into(), Value::Mapping(status));
3184        fields.insert("count".into(), Value::Mapping(count));
3185        let mut top = Mapping::new();
3186        top.insert("fields".into(), Value::Mapping(fields));
3187
3188        let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
3189        let status = config
3190            .fields
3191            .get("status")
3192            .and_then(|d| d.first())
3193            .expect("a recorded field");
3194        assert_eq!(status.default, Some(Value::String("open".into())));
3195        assert_eq!(status.ty, None);
3196        assert_eq!(status.vocabulary, None);
3197        assert_eq!(
3198            config
3199                .fields
3200                .get("count")
3201                .and_then(|d| d[0].default.clone()),
3202            Some(Value::Int(0))
3203        );
3204        // And `default` is a known key, so a near-miss is reported as one.
3205        let mut typo = Mapping::new();
3206        typo.insert("defualt".into(), Value::String("open".into()));
3207        let mut fields = Mapping::new();
3208        fields.insert("status".into(), Value::Mapping(typo));
3209        let mut top = Mapping::new();
3210        top.insert("fields".into(), Value::Mapping(fields));
3211        let issues = diagnose(&Value::Mapping(top));
3212        assert!(
3213            issues.iter().any(|i| i.key == "fields.status.defualt"),
3214            "{issues:?}"
3215        );
3216    }
3217
3218    /// The inverse guard: an entry that declares neither is not a description of
3219    /// anything, so it is not recorded as one.
3220    #[test]
3221    fn a_field_declaring_neither_type_nor_vocabulary_is_not_recorded() {
3222        let mut empty = Mapping::new();
3223        empty.insert("reify".into(), Value::Bool(true));
3224        let mut fields = Mapping::new();
3225        fields.insert("mystery".into(), Value::Mapping(empty));
3226        let mut top = Mapping::new();
3227        top.insert("fields".into(), Value::Mapping(fields));
3228
3229        let config = WorkspaceConfig::from_meta(&Value::Mapping(top));
3230        assert!(config.fields.is_empty(), "{:?}", config.fields);
3231    }
3232
3233    /// A `views:` block, as a config surface writes it.
3234    fn views_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
3235        let mut views = Mapping::new();
3236        for (name, keys) in entries {
3237            let mut entry = Mapping::new();
3238            for (k, v) in *keys {
3239                entry.insert((*k).into(), v.clone());
3240            }
3241            views.insert((*name).into(), Value::Mapping(entry));
3242        }
3243        let mut top = Mapping::new();
3244        top.insert("views".into(), Value::Mapping(views));
3245        Value::Mapping(top)
3246    }
3247
3248    fn str_value(text: &str) -> Value {
3249        Value::String(text.to_string())
3250    }
3251
3252    #[test]
3253    fn views_apply_in_declaration_order() {
3254        let config = WorkspaceConfig::from_meta(&views_block(&[
3255            ("daily", &[("group", str_value("created"))]),
3256            ("who", &[("group", str_value("people"))]),
3257        ]));
3258        assert_eq!(
3259            config
3260                .views
3261                .iter()
3262                .map(|v| v.name.as_str())
3263                .collect::<Vec<_>>(),
3264            ["daily", "who"]
3265        );
3266    }
3267
3268    /// The same merge rule `fields` has, for the same reason: a vault config
3269    /// declaring one view must not wipe the ones an app's defaults supplied.
3270    /// Redeclaring a name replaces that view whole rather than merging into it —
3271    /// `by` means nothing without `group`, so a key-wise merge would build a
3272    /// view neither surface wrote.
3273    #[test]
3274    fn a_later_surface_replaces_one_view_and_leaves_the_others() {
3275        let mut config = WorkspaceConfig::from_meta(&views_block(&[
3276            (
3277                "daily",
3278                &[
3279                    ("group", str_value("created")),
3280                    ("by", str_value("month")),
3281                    ("icon", str_value("calendar")),
3282                ],
3283            ),
3284            ("who", &[("group", str_value("people"))]),
3285        ]));
3286        config.apply(&views_block(&[(
3287            "daily",
3288            &[("group", str_value("date_of_document"))],
3289        )]));
3290
3291        assert_eq!(
3292            config
3293                .views
3294                .iter()
3295                .map(|v| v.name.as_str())
3296                .collect::<Vec<_>>(),
3297            ["daily", "who"],
3298            "position is kept, and the untouched view survives"
3299        );
3300        let daily = &config.views[0];
3301        assert_eq!(daily.group, prov_views::Grouping::field("date_of_document"));
3302        assert_eq!(daily.group.by, None, "replaced whole, not merged key-wise");
3303        assert_eq!(daily.icon, None);
3304    }
3305
3306    /// An entry that says nothing about grouping is not a view — and, unlike a
3307    /// silently dropped one, it is reported.
3308    #[test]
3309    fn a_view_without_a_grouping_is_not_recorded_and_is_diagnosed() {
3310        let meta = views_block(&[("daily", &[("label", str_value("Daily"))])]);
3311        assert!(WorkspaceConfig::from_meta(&meta).views.is_empty());
3312
3313        let issues = diagnose(&meta);
3314        assert_eq!(issues.len(), 1, "{issues:?}");
3315        assert_eq!(issues[0].key, "views.daily.group");
3316        assert!(matches!(
3317            &issues[0].kind,
3318            ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
3319        ));
3320    }
3321
3322    /// `ViewSpec::parse` reads an unparseable grain as no grain — it will not
3323    /// invent a cut the config did not ask for — so the view still works and
3324    /// the linter is the only thing that ever says the config was wrong.
3325    #[test]
3326    fn diagnose_flags_a_misspelled_grain_and_a_misspelled_view_key() {
3327        let issues = diagnose(&views_block(&[(
3328            "daily",
3329            &[
3330                ("group", str_value("created")),
3331                ("by", str_value("yearr")),
3332                ("labl", str_value("Daily")),
3333            ],
3334        )]));
3335        assert!(
3336            issues.iter().any(|i| i.key == "views.daily.by"
3337                && matches!(&i.kind, ConfigIssueKind::InvalidValue { value, expected }
3338                    if value == "yearr" && expected.iter().any(|e| e == "year"))),
3339            "{issues:?}"
3340        );
3341        assert!(
3342            issues.iter().any(|i| i.key == "views.daily.labl"
3343                && i.kind
3344                    == ConfigIssueKind::UnknownKey {
3345                        suggestion: "views.daily.label".into()
3346                    }),
3347            "{issues:?}"
3348        );
3349    }
3350
3351    /// An `exports:` block, as a config surface writes it.
3352    fn exports_block(entries: &[(&str, &[(&str, Value)])]) -> Value {
3353        let mut exports = Mapping::new();
3354        for (name, keys) in entries {
3355            let mut entry = Mapping::new();
3356            for (k, v) in *keys {
3357                entry.insert((*k).into(), v.clone());
3358            }
3359            exports.insert((*name).into(), Value::Mapping(entry));
3360        }
3361        let mut top = Mapping::new();
3362        top.insert("exports".into(), Value::Mapping(exports));
3363        Value::Mapping(top)
3364    }
3365
3366    fn gate_value(field: &str, value: &str) -> Value {
3367        let mut gate = Mapping::new();
3368        gate.insert("field".into(), str_value(field));
3369        gate.insert("value".into(), str_value(value));
3370        Value::Mapping(gate)
3371    }
3372
3373    #[test]
3374    fn exports_apply_and_round_trip() {
3375        let config = WorkspaceConfig::from_meta(&exports_block(&[
3376            (
3377                "letters",
3378                &[
3379                    ("gate", gate_value("audience", "family")),
3380                    ("view", str_value("daily")),
3381                ],
3382            ),
3383            ("notes", &[("gate", gate_value("audience", "public"))]),
3384        ]));
3385        assert_eq!(
3386            config
3387                .exports
3388                .iter()
3389                .map(|e| e.name.as_str())
3390                .collect::<Vec<_>>(),
3391            ["letters", "notes"]
3392        );
3393        assert_eq!(config.exports[0].gate.field, "audience");
3394        assert_eq!(config.exports[0].view.as_deref(), Some("daily"));
3395
3396        let written = config.to_mapping();
3397        let reread = WorkspaceConfig::from_meta(&Value::Mapping(written));
3398        assert_eq!(reread.exports, config.exports);
3399    }
3400
3401    /// The same whole-entry replacement `views` has, for a sharper reason: an
3402    /// export half-merged across two surfaces would bound what leaves with a
3403    /// gate neither surface wrote.
3404    #[test]
3405    fn a_later_surface_replaces_one_export_whole() {
3406        let mut config = WorkspaceConfig::from_meta(&exports_block(&[(
3407            "letters",
3408            &[
3409                ("gate", gate_value("audience", "family")),
3410                ("view", str_value("daily")),
3411            ],
3412        )]));
3413        config.apply(&exports_block(&[(
3414            "letters",
3415            &[("gate", gate_value("audience", "friends"))],
3416        )]));
3417
3418        assert_eq!(config.exports.len(), 1);
3419        assert_eq!(config.exports[0].gate.value, "friends");
3420        assert_eq!(
3421            config.exports[0].view, None,
3422            "replaced whole, not merged key-wise"
3423        );
3424    }
3425
3426    /// A dropped export publishes nothing, silently — the report is the only
3427    /// thing that ever says the declaration does not exist.
3428    #[test]
3429    fn an_export_without_a_gate_is_not_recorded_and_is_diagnosed() {
3430        let meta = exports_block(&[("letters", &[("view", str_value("daily"))])]);
3431        assert!(WorkspaceConfig::from_meta(&meta).exports.is_empty());
3432
3433        let issues = diagnose(&meta);
3434        assert_eq!(issues.len(), 1, "{issues:?}");
3435        assert_eq!(issues[0].key, "exports.letters.gate");
3436        assert!(matches!(
3437            &issues[0].kind,
3438            ConfigIssueKind::InvalidValue { value, .. } if value == "(absent)"
3439        ));
3440    }
3441
3442    #[test]
3443    fn diagnose_flags_misspelled_export_keys_at_both_levels() {
3444        let mut gate = Mapping::new();
3445        gate.insert("field".into(), str_value("audience"));
3446        gate.insert("valeu".into(), str_value("family"));
3447        let issues = diagnose(&exports_block(&[(
3448            "letters",
3449            &[("gate", Value::Mapping(gate)), ("veiw", str_value("daily"))],
3450        )]));
3451        assert!(
3452            issues.iter().any(|i| i.kind
3453                == ConfigIssueKind::UnknownKey {
3454                    suggestion: "exports.letters.view".into()
3455                }),
3456            "{issues:?}"
3457        );
3458        assert!(
3459            issues.iter().any(|i| i.kind
3460                == ConfigIssueKind::UnknownKey {
3461                    suggestion: "exports.letters.gate.value".into()
3462                }),
3463            "{issues:?}"
3464        );
3465    }
3466
3467    /// The runtime refuses an export whose view nobody declares (fail closed);
3468    /// this is the author-time half, so the typo is fixed before the first
3469    /// preview rather than at the moment someone tries to publish.
3470    #[test]
3471    fn diagnose_flags_an_export_arranged_by_an_undeclared_view() {
3472        let mut top = Mapping::new();
3473        let Value::Mapping(views) = views_block(&[("daily", &[("group", str_value("created"))])])
3474        else {
3475            unreachable!()
3476        };
3477        let Value::Mapping(exports) = exports_block(&[(
3478            "letters",
3479            &[
3480                ("gate", gate_value("audience", "family")),
3481                ("view", str_value("dialy")),
3482            ],
3483        )]) else {
3484            unreachable!()
3485        };
3486        for (k, v) in views.iter().chain(exports.iter()) {
3487            top.insert(k.clone(), v.clone());
3488        }
3489
3490        let issues = diagnose(&Value::Mapping(top));
3491        assert_eq!(issues.len(), 1, "{issues:?}");
3492        assert_eq!(issues[0].key, "exports.letters.view");
3493        assert!(
3494            matches!(
3495                &issues[0].kind,
3496                ConfigIssueKind::InvalidValue { value, expected }
3497                    if value == "dialy" && expected == &vec!["daily".to_string()]
3498            ),
3499            "{issues:?}"
3500        );
3501
3502        // And the same export beside no `views:` block is silent — one
3503        // surface at a time, the bound every cross-key check here has.
3504        let issues = diagnose(&exports_block(&[(
3505            "letters",
3506            &[
3507                ("gate", gate_value("audience", "family")),
3508                ("view", str_value("dialy")),
3509            ],
3510        )]));
3511        assert!(issues.is_empty(), "{issues:?}");
3512    }
3513
3514    /// `nest` files into the single-parent spine, so a document with two values
3515    /// for the grouping field has two homes. Grouping by it is fine — only the
3516    /// filing half is reported.
3517    #[test]
3518    fn diagnose_flags_a_nest_on_a_multi_valued_field() {
3519        let block = |view: &[(&str, Value)]| {
3520            let mut fields = Mapping::new();
3521            let mut people = Mapping::new();
3522            people.insert("type".into(), str_value("seq"));
3523            fields.insert("people".into(), Value::Mapping(people));
3524
3525            let mut views = Mapping::new();
3526            let mut entry = Mapping::new();
3527            for (k, v) in view {
3528                entry.insert((*k).into(), v.clone());
3529            }
3530            views.insert("who".into(), Value::Mapping(entry));
3531
3532            let mut top = Mapping::new();
3533            top.insert("fields".into(), Value::Mapping(fields));
3534            top.insert("views".into(), Value::Mapping(views));
3535            Value::Mapping(top)
3536        };
3537
3538        let issues = diagnose(&block(&[
3539            ("group", str_value("people")),
3540            ("nest", str_value("initial")),
3541        ]));
3542        assert_eq!(issues.len(), 1, "{issues:?}");
3543        assert_eq!(issues[0].key, "views.who.nest");
3544        assert_eq!(
3545            issues[0].kind,
3546            ConfigIssueKind::NestNotSingleValued {
3547                field: "people".into()
3548            }
3549        );
3550
3551        // The same view without `nest:` is clean — one document under several
3552        // groups is what a view is *for*.
3553        assert!(
3554            diagnose(&block(&[("group", str_value("people"))])).is_empty(),
3555            "grouping by a multi-valued field is not the problem"
3556        );
3557    }
3558
3559    /// The bound worth knowing: `diagnose` lints one surface at a time, so the
3560    /// cross-key check is silent when `fields` and `views` are declared apart.
3561    #[test]
3562    fn the_nest_check_is_silent_across_two_config_surfaces() {
3563        let mut views = Mapping::new();
3564        let mut entry = Mapping::new();
3565        entry.insert("group".into(), str_value("people"));
3566        entry.insert("nest".into(), str_value("initial"));
3567        views.insert("who".into(), Value::Mapping(entry));
3568        let mut top = Mapping::new();
3569        top.insert("views".into(), Value::Mapping(views));
3570
3571        assert!(
3572            diagnose(&Value::Mapping(top)).is_empty(),
3573            "no `fields` in this surface to contradict it"
3574        );
3575    }
3576
3577    #[test]
3578    fn diagnose_flags_a_views_block_that_is_not_a_block() {
3579        let mut top = Mapping::new();
3580        top.insert("views".into(), Value::String("daily".into()));
3581        let issues = diagnose(&Value::Mapping(top));
3582        assert_eq!(issues.len(), 1);
3583        assert_eq!(issues[0].key, "views");
3584
3585        let issues = diagnose(&views_block(&[]));
3586        assert!(issues.is_empty(), "an empty block is clean: {issues:?}");
3587    }
3588
3589    #[test]
3590    fn every_field_type_spelling_round_trips() {
3591        for spelling in FIELD_TYPES {
3592            let ty = field_type_from_config_str(spelling)
3593                .unwrap_or_else(|| panic!("{spelling} is offered but does not parse"));
3594            assert_eq!(field_type_as_config_str(ty), Some(*spelling));
3595        }
3596    }
3597
3598    #[test]
3599    fn diagnose_flags_an_unknown_field_type_and_offers_the_near_miss() {
3600        let mut created = Mapping::new();
3601        created.insert("type".into(), Value::String("datetime2".into()));
3602        let mut fields = Mapping::new();
3603        fields.insert("created".into(), Value::Mapping(created));
3604        let mut top = Mapping::new();
3605        top.insert("fields".into(), Value::Mapping(fields));
3606
3607        let issues = diagnose(&Value::Mapping(top));
3608        assert!(
3609            issues.iter().any(|i| i.key == "fields.created.type"
3610                && matches!(
3611                    &i.kind,
3612                    ConfigIssueKind::InvalidValue { expected, .. }
3613                        if expected.iter().any(|e| e == "datetime")
3614                )),
3615            "{issues:?}"
3616        );
3617    }
3618
3619    #[test]
3620    fn diagnose_flags_bad_field_and_relation_def_values() {
3621        // fields.audience.values bad + a relations def with bad cardinality.
3622        let mut top = Mapping::new();
3623        let mut fields = Mapping::new();
3624        let mut audience = Mapping::new();
3625        audience.insert("values".into(), Value::String("secret".into())); // not open/closed
3626        audience.insert("vocabulary".into(), Value::String("/vocab/aud.yaml".into()));
3627        fields.insert("audience".into(), Value::Mapping(audience));
3628        top.insert("fields".into(), Value::Mapping(fields));
3629        let mut rels = Mapping::new();
3630        let mut c = Mapping::new();
3631        c.insert("cardinality".into(), Value::String("two".into())); // not one/many
3632        rels.insert("contents".into(), Value::Mapping(c));
3633        top.insert("relations".into(), Value::Mapping(rels));
3634
3635        let issues = diagnose(&Value::Mapping(top));
3636        assert!(
3637            issues.iter().any(|i| i.key == "fields.audience.values"),
3638            "{issues:?}"
3639        );
3640        assert!(
3641            issues
3642                .iter()
3643                .any(|i| i.key == "relations.contents.cardinality"),
3644            "{issues:?}"
3645        );
3646    }
3647
3648    #[test]
3649    fn spec_ahead_fires_only_for_a_newer_spec() {
3650        assert_eq!(
3651            spec_ahead(&config_doc(&[("identity", "lazy")])),
3652            None,
3653            "absent spec"
3654        );
3655        let at = {
3656            let mut m = Mapping::new();
3657            m.insert("spec".into(), Value::Int(SPEC_VERSION));
3658            Value::Mapping(m)
3659        };
3660        assert_eq!(spec_ahead(&at), None, "current spec is fine");
3661        let ahead = {
3662            let mut m = Mapping::new();
3663            m.insert("spec".into(), Value::Int(SPEC_VERSION + 1));
3664            Value::Mapping(m)
3665        };
3666        assert_eq!(spec_ahead(&ahead), Some(SPEC_VERSION + 1));
3667    }
3668
3669    #[test]
3670    fn serialized_defaults_and_presets_all_pass_diagnosis() {
3671        for config in [
3672            WorkspaceConfig::default(),
3673            WorkspaceConfig::paths_only(),
3674            WorkspaceConfig::stable_ids(),
3675        ] {
3676            let serialized = Value::Mapping(config.to_mapping());
3677            assert!(
3678                diagnose(&serialized).is_empty(),
3679                "flagged itself: {:?}",
3680                diagnose(&serialized)
3681            );
3682        }
3683    }
3684}