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