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