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