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