prov_views/spec.rs
1//! The view format: what a workspace declares under `views.<name>`.
2//!
3//! # Why a view is not a field declaration
4//!
5//! A declared field (`fields.<name>`) already makes a lens: the workspace says
6//! it files things by `people`, so a frontend groups by `people`. That covers a
7//! lens whose groups *are* one field's values, over the whole corpus.
8//!
9//! It cannot express the four things a real archive needs. **Scope**: a lens
10//! over every file in the workspace buries the entries among the notes, drafts
11//! and READMEs that happen to carry the same field. **Grain**: "by year" is a
12//! rule about how a value becomes a group, and a field declaration has nowhere
13//! to put it. **Fallback**: the value worth grouping on is often the first of
14//! several fields that is filled in. **Conditions**: not everything in scope
15//! belongs in every lens (see [`crate::filter`]).
16//!
17//! So a view is its own declaration:
18//!
19//! ```yaml
20//! views:
21//! daily:
22//! label: Daily
23//! icon: calendar
24//! group: [date_of_document, created, updated]
25//! by: month
26//! under: '[Daily](/Daily/daily_index.md)'
27//! where:
28//! not: { has: draft }
29//! nest: month
30//! ```
31//!
32//! # There is no `date` grouping
33//!
34//! An earlier form of this format spelled the above `group: date`, a token that
35//! meant "the date chain" — and the chain itself (`date_of_document` →
36//! `created` → `updated`) was hardcoded in whichever program was reading. Three
37//! field names no workspace had agreed to, blessed by the tool.
38//!
39//! Here [`Grouping`] is one shape: an ordered list of field keys, first
40//! non-empty wins, optionally [cut](Grain) at a grain. A date view is that
41//! shape with date fields in it, and nothing in this crate knows the word
42//! "date" — the chain above is a *declaration a workspace writes*, which is
43//! what makes it reviewable, diffable, and different for a workspace that files
44//! by `taken_on` or `received`.
45//!
46//! A [`Grain`] is not a calendar either — it is any coarsening (see
47//! [`Grain::cuts`]), and the date grains are one family beside
48//! [`Initial`](Grain::Initial)'s A–Z index. It applies to a *value*, never to a
49//! declared type, so it works on the `2026-07-24` that YAML hands back as a
50//! string without this crate resolving the workspace's `fields.<name>.type`
51//! declarations. The date grains read that value as EDTF (see [`crate::date`]),
52//! so an archive's `1943-05`, `1913~`, `192X` and `1918/1922` all file. A
53//! value the grain cannot cut does not group at all, rather than grouping
54//! wrongly.
55//!
56//! # Classification is not aggregation
57//!
58//! The remaining shape is [MoReq2010]'s, not an invention. ISO 15489 calls
59//! *classification* the identification of a record by the context that produced
60//! it; MoReq2010 §1.4.5 separates that from *aggregation*, "the activity of
61//! assembling related records together", which "may be based on any
62//! organisational requirement or criteria, not business context alone". It
63//! permits conjoining the two into one hierarchy and warns what happens when
64//! you do: schemes hybridize, and naturally occurring aggregations get split
65//! apart to fit the classification.
66//!
67//! That maps onto this struct exactly:
68//!
69//! - [`Grouping`] is classification — how records become groups.
70//! - [`ViewSpec::under`] is aggregation — the index the records actually hang
71//! under, resolved through the spanning relation rather than by matching a
72//! path or a title, so it survives a rename, a move and a retitle.
73//! - [`ViewSpec::nest`] is the *deliberate* seam between them. It is not
74//! derived from [`Grouping::by`], because a lens must never become a reason
75//! to move a file: changing how a view groups is a reading decision, and it
76//! would be a poor bargain if a picker that reads like a display setting
77//! silently changed where tomorrow's entry lands.
78//!
79//! # Filing by reference
80//!
81//! A grain computes the shelf from the value: `2026-07-24` becomes the index
82//! titled `2026` and the one titled `2026-07` inside it, and the crate has to
83//! know what a year is to do it. The other way to say where a record files is
84//! for the record to *link to the shelf* and for the shelf's own place in the
85//! spine to be the chain:
86//!
87//! ```yaml
88//! fields:
89//! written.on:
90//! type: ref
91//! views:
92//! journal:
93//! group: written.on
94//! under: '[Calendar](/Calendar/index.md)'
95//! nest: ref
96//! ```
97//!
98//! `nest: ref` ([`Nest::Ref`]) files the record under the document its
99//! grouping value links to, and nothing else. The day node already sits under
100//! its month, which sits under its year, because that is the calendar index's
101//! own `contents` chain — so the chain condition a grain has to prove is
102//! satisfied by construction, and this crate does not know that the target is
103//! a day. The same declaration files a note under a person, a place or a
104//! project. What it costs is that the shelf must exist: a link to a document
105//! that is not there is the ordinary broken-link finding, not a shelf prov
106//! makes. The value grains stay for a workspace that would rather not keep a
107//! node per day.
108//!
109//! The field should be declared `type: ref`, so that a move of the shelf
110//! rewrites every record that files under it — that is what makes the link a
111//! link rather than a string that used to be a path (`prov-config` reports a
112//! `nest: ref` over a field that is not).
113//!
114//! # Inheritance and override
115//!
116//! `under:` is inherited: a view covers the whole subtree below its anchor, not
117//! just the anchor's direct children. This is MoReq2010 §201.2.3 — a class
118//! applied at a root aggregation "is inherited as the default classification
119//! for all descendants". §201.2.4 then allows a class applied directly to a
120//! child to break that chain, which is what keeps aggregations from having to
121//! be homogeneous. That override is a document-level concern and is not part of
122//! this struct; the scope walk in [`select`](fn@crate::select) is the inheritance half.
123//!
124//! [MoReq2010]: https://moreq.info/files/moreq2010_vol1_v1_1_en.pdf
125
126use prov_graph::field::{FieldPath, values_at};
127use prov_graph::meta::{Mapping, Value};
128
129use crate::filter::Condition;
130
131/// The config block views are declared in — a top-level axis, so every prov
132/// tool reads the same views rather than each app namespacing its own.
133pub const VIEWS_KEY: &str = "views";
134
135/// The keys valid inside one `views.<name>` entry.
136pub const VIEW_KEYS: &[&str] = &["label", "icon", "group", "by", "under", "nest", "where"];
137
138/// A **coarsening**: how finely a value is cut into groups.
139///
140/// Not a date vocabulary. A grain is any many-to-one function from a value to
141/// group keys, and the calendar grains are one family of them — `year` is
142/// "the year this date names", and [`Initial`](Self::Initial) is "the first
143/// *n* characters" with no such condition. What makes something a grain is
144/// the two properties below, not what it is about.
145///
146/// # Two properties, and what each one licenses
147///
148/// - [`cuts`](Self::cuts) — value → keys. This is all [`by`](Grouping::by)
149/// needs, because grouping is a *reading* operation with no invariant to
150/// keep. Usually one key; an interval (`1918/1922`) is under every year it
151/// spans, which is what makes it *keys*.
152/// - [`chain`](Self::chain) — the coarser grains this one refines, coarsest
153/// first. This is what [`nest`](ViewSpec::nest) needs, and it is a strictly
154/// stronger requirement: nesting builds a hierarchy of index documents, so
155/// each level's key must be determined by the finer level's
156/// (`2026-07-24` → `2026-07` → `2026`, `Ada` → `Ad` → `A`). A coarsening
157/// with no such chain can group but cannot nest.
158///
159/// The second constraint is prov's, not taste. `nest` files a record into the
160/// **spanning relation**, which is single-parent, so a nest chain must also be
161/// *single-valued* per document — see [`ViewSpec::nest_route`], which returns
162/// `None` rather than guessing which of a multi-valued field's values a
163/// document should be filed under.
164///
165/// # Adding a grain
166///
167/// The rule is the one [`crate::filter`] uses for predicates: a **concrete lens
168/// that cannot otherwise be said**, not a shape that seems likely to be wanted.
169/// `initial` earns its place as the A–Z index every list of names and places
170/// eventually wants. A numeric `bucket` (ratings by tens) is the obvious next
171/// one and is deliberately *not* here: nobody has asked for it, and it would
172/// arrive with a problem the calendar grains do not have — its keys sort
173/// lexically as `0, 10, 100, 20`, so it needs group ordering to become
174/// grain-aware, which is really the deferred `sort:` axis wearing a disguise.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum Grain {
177 /// `2026` — the default, and what a lifetime of entries wants.
178 #[default]
179 Year,
180 /// `2026-07`.
181 Month,
182 /// `2026-07-25`.
183 Day,
184 /// The first *n* characters, upper-cased — the A–Z index.
185 ///
186 /// Upper-casing is a deliberate normalization rather than a faithful cut:
187 /// an alphabetical index that files `ada` apart from `Ada` is not an index.
188 /// It is the same kind of choice a date cut makes when it reports `2026`
189 /// for a value that says `2026-07-24`; a group key describes a bucket, not
190 /// a value that appears in the data.
191 Initial(usize),
192}
193
194/// The grain spellings that are a bare word — what a near-miss diagnostic
195/// offers. [`Grain::Initial`] also takes a parameterized form
196/// (`{ initial: 2 }`) that is not a spelling to suggest.
197pub const GRAINS: &[&str] = &["year", "month", "day", "initial"];
198
199/// The `nest:` spellings that are a bare word: every grain's, and `ref`.
200pub const NESTS: &[&str] = &["year", "month", "day", "initial", "ref"];
201
202impl Grain {
203 /// The config spelling, when this grain has a bare-word one.
204 ///
205 /// `None` for a parameterized grain that is not at its default — write
206 /// [`to_value`](Self::to_value) instead, which always round-trips.
207 pub fn as_config_str(self) -> Option<&'static str> {
208 Some(match self {
209 Grain::Year => "year",
210 Grain::Month => "month",
211 Grain::Day => "day",
212 Grain::Initial(1) => "initial",
213 Grain::Initial(_) => return None,
214 })
215 }
216
217 /// Parse a bare-word config spelling. Unknown text is **not** silently
218 /// defaulted — a `by: yearr` that quietly grouped by year would look
219 /// applied and be wrong, which is the failure a config linter exists to
220 /// prevent.
221 pub fn from_config_str(text: &str) -> Option<Self> {
222 match text.trim() {
223 "year" => Some(Grain::Year),
224 "month" => Some(Grain::Month),
225 "day" => Some(Grain::Day),
226 // The bare word is the useful case; `{ initial: n }` says the rest.
227 "initial" => Some(Grain::Initial(1)),
228 _ => None,
229 }
230 }
231
232 /// Read a `by:`/`nest:` value: a bare word, or a one-key mapping naming a
233 /// parameterized grain (`{ initial: 2 }`).
234 ///
235 /// A parameter of zero is rejected rather than clamped: `{ initial: 0 }`
236 /// would put every document in one group called "", which is a view that
237 /// has stopped being one.
238 pub fn parse(value: &Value) -> Option<Self> {
239 match value {
240 Value::String(text) => Grain::from_config_str(text),
241 Value::Mapping(map) => match map.iter().next() {
242 Some((key, arg)) if map.len() == 1 && key == "initial" => {
243 let n = match arg {
244 Value::Int(n) => *n,
245 Value::String(s) => s.trim().parse().ok()?,
246 _ => return None,
247 };
248 (n > 0).then_some(Grain::Initial(n as usize))
249 }
250 _ => None,
251 },
252 _ => None,
253 }
254 }
255
256 /// The value this grain writes back as — a bare word where it has one, a
257 /// one-key mapping otherwise.
258 pub fn to_value(self) -> Value {
259 match self.as_config_str() {
260 Some(word) => Value::String(word.into()),
261 None => {
262 let Grain::Initial(n) = self else {
263 unreachable!("every non-parameterized grain has a bare spelling")
264 };
265 let mut map = Mapping::new();
266 map.insert("initial".into(), Value::Int(n as i64));
267 Value::Mapping(map)
268 }
269 }
270 }
271
272 /// How this grain reads in a listing (`month`, `initial 2`).
273 pub fn display(self) -> String {
274 match self {
275 Grain::Initial(n) if n > 1 => format!("initial {n}"),
276 other => other.as_config_str().unwrap_or("initial").to_string(),
277 }
278 }
279
280 /// The grains to nest through to reach `self`, coarsest first.
281 ///
282 /// Filing at month grain means a year index and then a month index inside
283 /// it: a month index that is not inside its year is not where anyone looks
284 /// for it. The alphabetical case is the same shape — filing at `initial 2`
285 /// means an `A` index holding an `Ad` index.
286 ///
287 /// Each step must be *determined* by the one after it, which is what makes
288 /// the hierarchy well defined. That is why this is a property of the grain
289 /// rather than something a caller can assemble: an arbitrary sequence of
290 /// coarsenings is not a nest.
291 pub fn chain(self) -> Vec<Grain> {
292 match self {
293 Grain::Year => vec![Grain::Year],
294 Grain::Month => vec![Grain::Year, Grain::Month],
295 Grain::Day => vec![Grain::Year, Grain::Month, Grain::Day],
296 Grain::Initial(n) => (1..=n).map(Grain::Initial).collect(),
297 }
298 }
299
300 /// Every group key `value` falls under at this grain — empty when the
301 /// value does not reach it.
302 ///
303 /// The calendar grains *validate* rather than taking a blind prefix, which
304 /// is what keeps `by:` usable on a view whose field is only usually a date:
305 /// `banana` cut to a year would otherwise group under `bana`, a group key
306 /// that looks like data. A value this rejects falls to the ungrouped
307 /// bucket, where it is visible as something that did not sort.
308 ///
309 /// What they validate *as* is EDTF, so `1913~` is the group `1913`, `192X`
310 /// is a group of its own, `1918/1922` is five groups, and `XXXX` is none
311 /// — the rules are in [`crate::date`]. An RFC 3339 instant
312 /// (`2026-07-24T07:32:00Z` — what a machine-maintained `updated` field
313 /// carries) cuts exactly like the plain date it starts with.
314 ///
315 /// The group keys are spelled so that an ISO date's lexical order is its
316 /// calendar order, so the group order falls out of the string with no
317 /// calendar arithmetic and no time zone to get wrong. (Years before 0000
318 /// sort backwards among themselves; nothing files there yet.)
319 pub fn cuts(self, value: &str) -> Vec<String> {
320 let text = value.trim();
321 if let Grain::Initial(n) = self {
322 // By *character*, not byte: a name may begin with any of them, and
323 // slicing `Ålesund` at byte 1 is a panic. A value shorter than the
324 // cut is taken whole rather than rejected — `Bo` under a two-letter
325 // index belongs at `BO`, and there is no coarser truth to wait for.
326 let cut: String = text.chars().take(n).flat_map(char::to_uppercase).collect();
327 return if cut.is_empty() {
328 Vec::new()
329 } else {
330 vec![cut]
331 };
332 }
333 crate::date::keys(text, self)
334 }
335
336 /// The one group key `value` falls under at this grain, or `None` when it
337 /// falls under none — or under several.
338 ///
339 /// The single-valued half of [`cuts`](Self::cuts), for the caller that
340 /// needs one answer: filing. An interval has several homes at a grain it
341 /// spans, and [`ViewSpec::nest_route`] must not pick one, for the reason
342 /// it does not pick between two people.
343 pub fn cut(self, value: &str) -> Option<String> {
344 let mut keys = self.cuts(value);
345 (keys.len() == 1).then(|| keys.remove(0))
346 }
347}
348
349/// How a view **files** a new record — the `nest:` value.
350///
351/// Two shapes, and the difference is who knows where the shelf is. A
352/// [`Grain`] computes it from the value, so the crate must know what a year
353/// or an initial is. [`Ref`](Self::Ref) reads it off the record: the value is
354/// a link, and the record files under the document it links to, whose place
355/// in the spine is the whole chain. See the module docs, *Filing by
356/// reference*.
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358pub enum Nest {
359 /// File under an index at this grain, the coarser indexes above it —
360 /// `["2026", "2026-07"]` for a month.
361 Grain(Grain),
362 /// File under the document the grouping value links to.
363 Ref,
364}
365
366impl Nest {
367 /// Read a `nest:` value: a grain's spelling, or the word `ref`.
368 pub fn parse(value: &Value) -> Option<Self> {
369 if let Some(text) = value.as_str()
370 && text.trim() == "ref"
371 {
372 return Some(Nest::Ref);
373 }
374 Grain::parse(value).map(Nest::Grain)
375 }
376
377 /// The value this writes back as.
378 pub fn to_value(self) -> Value {
379 match self {
380 Nest::Grain(grain) => grain.to_value(),
381 Nest::Ref => Value::String("ref".into()),
382 }
383 }
384
385 /// How this reads in a listing (`month`, `initial 2`, `ref`).
386 pub fn display(self) -> String {
387 match self {
388 Nest::Grain(grain) => grain.display(),
389 Nest::Ref => "ref".to_string(),
390 }
391 }
392
393 /// The grain, when this nest is one.
394 pub fn grain(self) -> Option<Grain> {
395 match self {
396 Nest::Grain(grain) => Some(grain),
397 Nest::Ref => None,
398 }
399 }
400}
401
402/// Where a record files under a view — what [`ViewSpec::nest_route`] answers.
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub enum NestRoute {
405 /// The index *titles* to file under, coarsest first, below the view's
406 /// [`under`](ViewSpec::under) — `["2026", "2026-07"]` — which is exactly
407 /// what prov's route addressing takes, so a frontend hands them to
408 /// `plan_route` and never assembles a path. An index that does not exist
409 /// yet is the frontend's to create.
410 Titles(Vec<String>),
411 /// The link the record's own grouping field carries, as written. The
412 /// record files under whatever it resolves to — by path, by `id:`, or by
413 /// title, the way a view's anchor resolves — and the frontend resolves it
414 /// from where the record will live, since a relative link is relative to
415 /// its document. Nothing is created: a link to no document is a broken
416 /// link, not a shelf.
417 Link(String),
418}
419
420/// What a view sorts records by — MoReq2010's *classification*.
421///
422/// One shape, not a set of blessed kinds: an ordered chain of field keys, and
423/// an optional grain to cut the chosen value at. See the module docs for why
424/// there is no `date` variant.
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct Grouping {
427 /// The field paths to read, in order — the first that carries a value
428 /// wins, and supplies *all* of that view's group keys for the document.
429 /// Each is a path as a `fields` declaration writes one (`people`,
430 /// `written.on`, `confirmed[].by`), so a view groups by a key inside a
431 /// mapping or inside every item of a list, not only by a top-level key.
432 /// Guaranteed non-empty by [`ViewSpec::parse`].
433 pub keys: Vec<String>,
434 /// The grain the chosen value is cut at, or `None` to group on the value
435 /// itself.
436 pub by: Option<Grain>,
437}
438
439impl Grouping {
440 /// A view grouped on one field's raw values.
441 pub fn field(key: impl Into<String>) -> Self {
442 Grouping {
443 keys: vec![key.into()],
444 by: None,
445 }
446 }
447
448 /// The group keys `meta` falls under — empty when no field in the chain
449 /// carries a usable value, which is the ungrouped bucket.
450 ///
451 /// A sequence-valued field yields one key per element, so a letter about
452 /// two people appears under both. That is the whole point of a view: the
453 /// same document reached several ways, with retrieval decoupled from the
454 /// single containment spine.
455 ///
456 /// The chain stops at the first key that is *present and non-empty*, and
457 /// its values are used even if the grain rejects all of them. Falling
458 /// through to `created` because `date_of_document` held something
459 /// unparseable would silently file the document under a date it does not
460 /// claim; leaving it ungrouped shows the bad value instead.
461 pub fn keys_of(&self, meta: &Value) -> Vec<String> {
462 for key in &self.keys {
463 // A path, not a key: `written.on` reaches into a mapping and
464 // `confirmed[].by` into every item of a list, each value it lands
465 // on contributing its scalars. A plain key reaches its one value,
466 // as before.
467 let raw: Vec<String> = values_at(meta, &FieldPath::parse(key))
468 .into_iter()
469 .flat_map(|(_, value)| scalar_texts(value))
470 .collect();
471 if raw.is_empty() {
472 continue;
473 }
474 return match self.by {
475 Some(grain) => raw.iter().flat_map(|t| grain.cuts(t)).collect(),
476 None => raw,
477 };
478 }
479 Vec::new()
480 }
481
482 /// The `group:` value this writes back as: a bare string for a single key,
483 /// a list for a chain, so a one-field view reads as the small thing it is.
484 fn to_value(&self) -> Value {
485 match self.keys.as_slice() {
486 [only] => Value::String(only.clone()),
487 many => Value::Sequence(many.iter().cloned().map(Value::String).collect()),
488 }
489 }
490}
491
492/// The trimmed, non-empty text of a scalar, or of every scalar in a sequence.
493///
494/// A view groups on what a value *says*, so the numeric and boolean cases are
495/// rendered rather than skipped — a `rating: 5` groups under `5`. A mapping has
496/// no single text and is not groupable; a nested sequence is not flattened,
497/// because a list of lists is a shape no frontmatter field means to declare.
498pub(crate) fn scalar_texts(value: &Value) -> Vec<String> {
499 match value {
500 Value::Sequence(items) => items.iter().filter_map(scalar_text).collect(),
501 other => scalar_text(other).into_iter().collect(),
502 }
503}
504
505/// One scalar's trimmed text, or `None` for a null, an empty string, or a
506/// composite.
507fn scalar_text(value: &Value) -> Option<String> {
508 let text = match value {
509 Value::String(s) => s.trim().to_string(),
510 Value::Int(i) => i.to_string(),
511 Value::Float(f) => f.to_string(),
512 Value::Bool(b) => b.to_string(),
513 Value::Null | Value::Sequence(_) | Value::Mapping(_) => return None,
514 };
515 (!text.is_empty()).then_some(text)
516}
517
518/// One view a workspace declares for itself.
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct ViewSpec {
521 /// The key under `views` — also the token that names this view to a
522 /// frontend, and the id it is addressed by.
523 pub name: String,
524 /// What a person calls it. Absent falls back to the name, humanized.
525 pub label: Option<String>,
526 /// A glyph hint for a frontend's lens picker. Uninterpreted here: what a
527 /// `calendar` looks like is the frontend's business.
528 pub icon: Option<String>,
529 /// Classification — how records become groups.
530 pub group: Grouping,
531 /// Aggregation — the index this view's records hang under, as a link
532 /// (`'[Daily](id:abc1234)'`). `None` scopes the view to the whole
533 /// workspace.
534 pub under: Option<String>,
535 /// The `where:` conditions a document in scope must also meet. `None`
536 /// takes everything scope reaches.
537 ///
538 /// Named `filter` because `where` is a Rust keyword; the config spelling is
539 /// `where`, which is what a reader of the format sees.
540 ///
541 /// Separate from [`under`](Self::under) because the two fail differently:
542 /// an anchor that names nothing is a broken view, while a condition that
543 /// matches nothing is an ordinary empty answer.
544 pub filter: Option<Condition>,
545 /// Materialization: when set, filing a new record through this view nests
546 /// it — under an index at a grain below [`under`](Self::under), creating
547 /// the index if the calendar has turned, or under the document the
548 /// record's grouping value links to ([`Nest::Ref`]). `None` files flat.
549 ///
550 /// Independent of [`Grouping::by`] on purpose — see the module docs.
551 pub nest: Option<Nest>,
552}
553
554impl ViewSpec {
555 /// Read one `views.<name>` entry.
556 ///
557 /// Returns `None` when the entry is not a mapping or names no groupable
558 /// field — an entry that does not say what it groups by is not a view, and
559 /// recording it as one would put a lens in the picker that groups nothing.
560 /// [`crate::diagnose_view`] is the half that says *why*, so a malformed
561 /// entry is reported rather than merely dropped.
562 pub fn parse(name: &str, value: &Value) -> Option<Self> {
563 let map = value.as_mapping()?;
564 let keys = group_keys(map.get("group"))?;
565 Some(ViewSpec {
566 name: name.to_string(),
567 label: non_empty(map.get("label")),
568 icon: non_empty(map.get("icon")),
569 group: Grouping {
570 keys,
571 by: map.get("by").and_then(Grain::parse),
572 },
573 under: non_empty(map.get("under")),
574 filter: map.get("where").and_then(Condition::parse),
575 nest: map.get("nest").and_then(Nest::parse),
576 })
577 }
578
579 /// The mapping this view writes back as. Absent options are omitted rather
580 /// than written empty, so a view declared from an app reads as the small
581 /// thing it is.
582 pub fn to_mapping(&self) -> Mapping {
583 let mut map = Mapping::new();
584 if let Some(label) = &self.label {
585 map.insert("label".into(), Value::String(label.clone()));
586 }
587 if let Some(icon) = &self.icon {
588 map.insert("icon".into(), Value::String(icon.clone()));
589 }
590 map.insert("group".into(), self.group.to_value());
591 if let Some(by) = self.group.by {
592 map.insert("by".into(), by.to_value());
593 }
594 if let Some(under) = &self.under {
595 map.insert("under".into(), Value::String(under.clone()));
596 }
597 if let Some(filter) = &self.filter {
598 map.insert("where".into(), filter.to_value());
599 }
600 if let Some(nest) = self.nest {
601 map.insert("nest".into(), nest.to_value());
602 }
603 map
604 }
605
606 /// The link a record nests under when this view files by reference —
607 /// `None` when it does not, when the grouping chain carries no value, or
608 /// when it carries several. The single-valued half of
609 /// [`nest_route`](Self::nest_route), for a caller that only files by
610 /// reference; the route is the general answer.
611 pub fn nest_link(&self, meta: &Value) -> Option<String> {
612 match self.nest_route(meta)? {
613 NestRoute::Link(link) => Some(link),
614 NestRoute::Titles(_) => None,
615 }
616 }
617
618 /// Where a new record nests under this view — or `None` when the view
619 /// does not nest, or `meta` cannot be filed.
620 ///
621 /// For a date view at month grain this is the titles `["2026",
622 /// "2026-07"]`; for an alphabetical one at `initial 2`, `["A", "AD"]`.
623 /// Those are *titles*, which is exactly what prov's route addressing takes
624 /// (`prov new --under "Daily/2026/2026-07" -p`), so a frontend that
625 /// materializes a view hands this straight to `plan_route` and never
626 /// assembles a path itself. For a view that files by reference it is the
627 /// [link](NestRoute::Link) the record carries, and the frontend files under
628 /// what that resolves to.
629 ///
630 /// `None` in three cases, all of which mean *this record has no single home
631 /// under this view* rather than *nowhere*:
632 ///
633 /// - the view declares no [`nest`](Self::nest);
634 /// - no field in the grouping chain carries a usable value, so there is
635 /// nothing to file by;
636 /// - the value is **multi-valued**. This is the constraint prov's spanning
637 /// relation imposes: a document with two people cannot hang under two
638 /// parents, and picking one would be inventing an answer the workspace
639 /// did not give. Such a view groups perfectly well — it just cannot be
640 /// materialized, which is why `nest` on a multi-valued field is a config
641 /// finding rather than a runtime surprise. A record linking to two
642 /// shelves is the same case in the reference shape.
643 pub fn nest_route(&self, meta: &Value) -> Option<NestRoute> {
644 let nest = self.nest?;
645 // Read the chain *uncut*: `by:` is how this view reads, and reading must
646 // not decide where a file lands (the whole point of keeping the two
647 // keys apart). The value is then cut at each nesting grain instead.
648 let raw = Grouping {
649 keys: self.group.keys.clone(),
650 by: None,
651 };
652 let values = raw.keys_of(meta);
653 let [value] = values.as_slice() else {
654 return None;
655 };
656 let grain = match nest {
657 // The value *is* the route: the record says where it files, and
658 // the shelf's own place in the spine is the rest of the chain.
659 Nest::Ref => return Some(NestRoute::Link(value.clone())),
660 Nest::Grain(grain) => grain,
661 };
662 let route: Vec<String> = grain
663 .chain()
664 .into_iter()
665 .filter_map(|step| step.cut(value))
666 .collect();
667 // A partial chain would file a July entry under `2026` and call it
668 // done, which is a different place from the one the view describes.
669 (route.len() == grain.chain().len()).then_some(NestRoute::Titles(route))
670 }
671
672 /// What a person calls this view: its label, else its name humanized
673 /// (`daily_entries` → `Daily entries`).
674 pub fn display_label(&self) -> String {
675 match &self.label {
676 Some(label) => label.clone(),
677 None => humanize(&self.name),
678 }
679 }
680}
681
682/// The field-key chain a `group:` value names — a bare string, or a list.
683///
684/// `None` when the value is absent, is neither of those shapes, or names no
685/// non-empty key. Empty entries are dropped rather than carried, so
686/// `group: [people, '']` is the one-key chain it plainly means.
687fn group_keys(value: Option<&Value>) -> Option<Vec<String>> {
688 let keys: Vec<String> = match value? {
689 Value::String(s) => s
690 .trim()
691 .is_empty()
692 .then(Vec::new)
693 .unwrap_or_else(|| vec![s.trim().to_string()]),
694 Value::Sequence(items) => items.iter().filter_map(|v| non_empty(Some(v))).collect(),
695 _ => return None,
696 };
697 (!keys.is_empty()).then_some(keys)
698}
699
700/// A trimmed non-empty string from a config value, or `None`.
701fn non_empty(value: Option<&Value>) -> Option<String> {
702 let text = value?.as_str()?.trim();
703 (!text.is_empty()).then(|| text.to_string())
704}
705
706/// `daily_entries` → `Daily entries`: a key is written for a file, a label for
707/// a person.
708pub fn humanize(key: &str) -> String {
709 let mut words = key.split(['_', '-']).filter(|w| !w.is_empty());
710 let Some(first) = words.next() else {
711 return key.to_string();
712 };
713 let mut out = first.to_string();
714 if let Some(c) = out.get_mut(0..1) {
715 c.make_ascii_uppercase();
716 }
717 for word in words {
718 out.push(' ');
719 out.push_str(&word.to_lowercase());
720 }
721 out
722}
723
724/// Read every `views.<name>` entry out of a config surface's `views:` block,
725/// in declaration order.
726pub fn views_from(config: &Mapping) -> Vec<ViewSpec> {
727 let Some(views) = config.get(VIEWS_KEY).and_then(Value::as_mapping) else {
728 return Vec::new();
729 };
730 views
731 .iter()
732 .filter_map(|(name, value)| ViewSpec::parse(name, value))
733 .collect()
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 fn mapping(pairs: &[(&str, Value)]) -> Value {
741 let mut map = Mapping::new();
742 for (k, v) in pairs {
743 map.insert((*k).into(), v.clone());
744 }
745 Value::Mapping(map)
746 }
747
748 fn text(pairs: &[(&str, &str)]) -> Value {
749 let owned: Vec<(&str, Value)> = pairs
750 .iter()
751 .map(|(k, v)| (*k, Value::String((*v).to_string())))
752 .collect();
753 mapping(&owned)
754 }
755
756 fn text_value(s: &str) -> Value {
757 Value::String(s.to_string())
758 }
759
760 fn seq(items: &[&str]) -> Value {
761 Value::Sequence(items.iter().map(|s| Value::String((*s).into())).collect())
762 }
763
764 /// The un-blessing, stated as a test: `date` is not a token. A view that
765 /// says `group: date` groups on a *field called `date`* like any other, so
766 /// nothing in this crate has to know the word.
767 #[test]
768 fn date_is_a_field_name_not_a_grouping_kind() {
769 let spec = ViewSpec::parse("daily", &text(&[("group", "date")])).expect("a view");
770 assert_eq!(spec.group, Grouping::field("date"));
771
772 let mut doc = Mapping::new();
773 doc.insert("date".into(), Value::String("2026-07-24".into()));
774 assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["2026-07-24"]);
775 }
776
777 #[test]
778 fn a_chain_takes_the_first_field_that_carries_a_value() {
779 let spec = ViewSpec::parse(
780 "daily",
781 &mapping(&[
782 ("group", seq(&["date_of_document", "created", "updated"])),
783 ("by", Value::String("month".into())),
784 ]),
785 )
786 .expect("a view");
787
788 let mut doc = Mapping::new();
789 doc.insert("created".into(), Value::String("2026-07-24".into()));
790 doc.insert("updated".into(), Value::String("2020-01-01".into()));
791 assert_eq!(
792 spec.group.keys_of(&Value::Mapping(doc)),
793 ["2026-07"],
794 "created wins over updated; the grain cuts it"
795 );
796 }
797
798 /// A present-but-unparseable value does not fall through to the next field
799 /// in the chain. Filing the document under `created` because
800 /// `date_of_document` held junk would assert a date the document never
801 /// claimed.
802 #[test]
803 fn a_bad_value_does_not_fall_through_to_the_next_key() {
804 let spec = ViewSpec::parse(
805 "daily",
806 &mapping(&[
807 ("group", seq(&["date_of_document", "created"])),
808 ("by", Value::String("year".into())),
809 ]),
810 )
811 .expect("a view");
812
813 let mut doc = Mapping::new();
814 doc.insert("date_of_document".into(), Value::String("banana".into()));
815 doc.insert("created".into(), Value::String("2026-07-24".into()));
816 assert!(spec.group.keys_of(&Value::Mapping(doc)).is_empty());
817 }
818
819 /// One document, several groups — the property that makes a view different
820 /// from the spine.
821 #[test]
822 fn a_sequence_field_puts_one_document_in_several_groups() {
823 let spec = ViewSpec::parse("who", &text(&[("group", "people")])).expect("a view");
824 let mut doc = Mapping::new();
825 doc.insert("people".into(), seq(&["Ada", "Grace"]));
826 assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["Ada", "Grace"]);
827 }
828
829 #[test]
830 fn a_document_with_nothing_in_the_chain_is_ungrouped() {
831 let spec = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
832 assert!(
833 spec.group
834 .keys_of(&Value::Mapping(Mapping::new()))
835 .is_empty()
836 );
837 let mut blank = Mapping::new();
838 blank.insert("created".into(), Value::String(" ".into()));
839 assert!(spec.group.keys_of(&Value::Mapping(blank)).is_empty());
840 }
841
842 #[test]
843 fn a_grain_cuts_an_iso_date_and_an_rfc3339_instant_alike() {
844 assert_eq!(Grain::Year.cut("2026-07-24"), Some("2026".into()));
845 assert_eq!(Grain::Month.cut("2026-07-24"), Some("2026-07".into()));
846 assert_eq!(Grain::Day.cut("2026-07-24"), Some("2026-07-24".into()));
847 assert_eq!(
848 Grain::Month.cut("2026-07-24T07:32:00Z"),
849 Some("2026-07".into())
850 );
851 assert_eq!(Grain::Year.cut(" 2026-07-24 "), Some("2026".into()));
852 }
853
854 /// The generalization, stated as a test: a grain is any coarsening, and the
855 /// A–Z index is one — same `by:` key, same `cut`, no calendar involved.
856 #[test]
857 fn an_initial_grain_cuts_the_alphabet_the_way_a_date_grain_cuts_a_year() {
858 assert_eq!(Grain::Initial(1).cut("Ada Lovelace"), Some("A".into()));
859 assert_eq!(Grain::Initial(2).cut("Ada Lovelace"), Some("AD".into()));
860 // Upper-cased on purpose: an index that files `ada` apart from `Ada` is
861 // not an index.
862 assert_eq!(Grain::Initial(1).cut("ada"), Some("A".into()));
863 // Shorter than the cut is taken whole — there is no coarser truth to
864 // wait for, unlike a half-written date.
865 assert_eq!(Grain::Initial(3).cut("Bo"), Some("BO".into()));
866 assert_eq!(Grain::Initial(1).cut(" "), None);
867 }
868
869 /// Cutting by character rather than byte: slicing a multi-byte name at
870 /// byte 1 would panic, and `Å` is one letter.
871 #[test]
872 fn an_initial_grain_cuts_characters_not_bytes() {
873 assert_eq!(Grain::Initial(1).cut("Ålesund"), Some("Å".into()));
874 assert_eq!(Grain::Initial(2).cut("Øland"), Some("ØL".into()));
875 assert_eq!(Grain::Initial(1).cut("東京"), Some("東".into()));
876 }
877
878 /// `chain` is what `nest` needs, and it generalizes with the grain: each
879 /// step must be determined by the one after it.
880 #[test]
881 fn every_grain_chains_coarsest_first() {
882 assert_eq!(Grain::Day.chain(), [Grain::Year, Grain::Month, Grain::Day]);
883 assert_eq!(Grain::Year.chain(), [Grain::Year]);
884 assert_eq!(
885 Grain::Initial(3).chain(),
886 [Grain::Initial(1), Grain::Initial(2), Grain::Initial(3)]
887 );
888 }
889
890 #[test]
891 fn a_parameterized_grain_parses_and_round_trips() {
892 let mut map = Mapping::new();
893 map.insert("initial".into(), Value::Int(2));
894 let parsed = Grain::parse(&Value::Mapping(map)).expect("a grain");
895 assert_eq!(parsed, Grain::Initial(2));
896 assert_eq!(Grain::parse(&parsed.to_value()), Some(parsed));
897
898 // The bare word is the one-character case, and writes back bare.
899 assert_eq!(
900 Grain::parse(&text_value("initial")),
901 Some(Grain::Initial(1))
902 );
903 assert_eq!(Grain::Initial(1).to_value(), text_value("initial"));
904 assert_eq!(Grain::parse(&text_value("month")), Some(Grain::Month));
905 }
906
907 /// A zero-width cut puts every document in one group called "", which is a
908 /// view that has stopped being one. Rejected rather than clamped, so the
909 /// linter reports it instead of it silently working.
910 #[test]
911 fn a_grain_with_a_useless_parameter_does_not_parse() {
912 let mut zero = Mapping::new();
913 zero.insert("initial".into(), Value::Int(0));
914 assert_eq!(Grain::parse(&Value::Mapping(zero)), None);
915
916 let mut unknown = Mapping::new();
917 unknown.insert("bucket".into(), Value::Int(10));
918 assert_eq!(Grain::parse(&Value::Mapping(unknown)), None);
919
920 let mut two = Mapping::new();
921 two.insert("initial".into(), Value::Int(1));
922 two.insert("month".into(), Value::Int(1));
923 assert_eq!(Grain::parse(&Value::Mapping(two)), None);
924 }
925
926 /// The reason the cut validates instead of slicing: `banana` must not
927 /// become the group `bana`, and `20264` must not become the year `2026`.
928 #[test]
929 fn a_grain_rejects_what_is_not_a_date_at_that_grain() {
930 assert_eq!(Grain::Year.cut("banana"), None);
931 assert_eq!(Grain::Year.cut("20264"), None);
932 assert_eq!(Grain::Day.cut("2026-07"), None);
933 assert_eq!(Grain::Month.cut("2026/07"), None);
934 assert_eq!(Grain::Month.cut(""), None);
935 }
936
937 /// The archive's dates, read at a grain — the rules are `date`'s; this is
938 /// the view seeing them. `1913~` stands beside `1913`, and an interval is
939 /// under every year it spans.
940 #[test]
941 fn a_grain_reads_edtf() {
942 assert_eq!(Grain::Year.cut("1913~"), Some("1913".into()));
943 assert_eq!(Grain::Year.cut("192X"), Some("192X".into()));
944 assert_eq!(Grain::Month.cut("1943-05"), Some("1943-05".into()));
945 assert_eq!(Grain::Day.cut("1943-05"), None);
946 assert_eq!(Grain::Year.cut("XXXX"), None);
947 assert_eq!(Grain::Year.cuts("1918/1920"), ["1918", "1919", "1920"]);
948 assert_eq!(
949 Grain::Year.cut("1918/1920"),
950 None,
951 "several homes is not one home"
952 );
953 let sel_by_year = Grouping {
954 keys: vec!["date_of_document".into()],
955 by: Some(Grain::Year),
956 };
957 let mut doc = Mapping::new();
958 doc.insert("date_of_document".into(), Value::String("../1920".into()));
959 assert_eq!(sel_by_year.keys_of(&Value::Mapping(doc)), ["1920"]);
960 }
961
962 /// The load-bearing separation: `by:` is classification, `nest:` is
963 /// aggregation, and reading one does not set the other. A view that grouped
964 /// by month would otherwise start filing next month's entry somewhere new.
965 #[test]
966 fn grain_does_not_imply_nesting() {
967 let spec = ViewSpec::parse("daily", &text(&[("group", "created"), ("by", "month")]))
968 .expect("a view");
969 assert_eq!(spec.group.by, Some(Grain::Month));
970 assert_eq!(spec.nest, None);
971
972 let materialized = ViewSpec::parse(
973 "daily",
974 &text(&[("group", "created"), ("by", "month"), ("nest", "year")]),
975 )
976 .expect("a view");
977 assert_eq!(
978 materialized.nest,
979 Some(Nest::Grain(Grain::Year)),
980 "a view may group finer than it files"
981 );
982 }
983
984 #[test]
985 fn an_entry_without_a_grouping_is_not_a_view() {
986 assert!(ViewSpec::parse("x", &text(&[("label", "Nameless")])).is_none());
987 assert!(ViewSpec::parse("x", &text(&[("group", " ")])).is_none());
988 assert!(ViewSpec::parse("x", &mapping(&[("group", seq(&[]))])).is_none());
989 assert!(ViewSpec::parse("x", &Value::String("created".into())).is_none());
990 }
991
992 /// A view that nests hands a frontend the index *titles* to file under —
993 /// which is exactly what prov's route addressing takes, so nothing
994 /// assembles a path.
995 #[test]
996 fn nest_route_gives_the_index_titles_to_file_under() {
997 let spec = ViewSpec::parse(
998 "daily",
999 &text(&[("group", "created"), ("by", "day"), ("nest", "month")]),
1000 )
1001 .expect("a view");
1002
1003 let mut doc = Mapping::new();
1004 doc.insert("created".into(), Value::String("2026-07-24".into()));
1005 assert_eq!(
1006 spec.nest_route(&Value::Mapping(doc)),
1007 Some(NestRoute::Titles(vec![
1008 "2026".to_string(),
1009 "2026-07".to_string()
1010 ])),
1011 "a month nest is a year index holding a month index"
1012 );
1013 }
1014
1015 /// The alphabetical case is the same machinery — the generalization, seen
1016 /// from the filing side rather than the reading side.
1017 #[test]
1018 fn nest_route_generalizes_past_dates() {
1019 let mut entry = Mapping::new();
1020 entry.insert("group".into(), Value::String("surname".into()));
1021 entry.insert("nest".into(), {
1022 let mut g = Mapping::new();
1023 g.insert("initial".into(), Value::Int(2));
1024 Value::Mapping(g)
1025 });
1026 let spec = ViewSpec::parse("people", &Value::Mapping(entry)).expect("a view");
1027
1028 let mut doc = Mapping::new();
1029 doc.insert("surname".into(), Value::String("Lovelace".into()));
1030 assert_eq!(
1031 spec.nest_route(&Value::Mapping(doc)),
1032 Some(NestRoute::Titles(vec!["L".to_string(), "LO".to_string()]))
1033 );
1034 }
1035
1036 /// The constraint prov's spine imposes: a document with two people cannot
1037 /// hang under two parents, so it has no single home and this says so rather
1038 /// than picking one.
1039 #[test]
1040 fn a_multi_valued_document_has_no_nest_route() {
1041 let spec = ViewSpec::parse("who", &text(&[("group", "people"), ("nest", "initial")]))
1042 .expect("a view");
1043
1044 let mut one = Mapping::new();
1045 one.insert("people".into(), Value::String("Ada".into()));
1046 assert_eq!(
1047 spec.nest_route(&Value::Mapping(one)),
1048 Some(NestRoute::Titles(vec!["A".to_string()])),
1049 "one value files fine"
1050 );
1051
1052 let mut two = Mapping::new();
1053 two.insert("people".into(), seq(&["Ada", "Grace"]));
1054 assert_eq!(
1055 spec.nest_route(&Value::Mapping(two)),
1056 None,
1057 "two values are two homes, and prov's spine allows one"
1058 );
1059 }
1060
1061 /// An interval is the same shape one value at a time: `1918/1922` is
1062 /// under five years and so has no single home, while `1913~` has one.
1063 #[test]
1064 fn an_interval_has_no_nest_route_where_it_spans() {
1065 let spec = ViewSpec::parse(
1066 "daily",
1067 &text(&[("group", "date_of_document"), ("nest", "year")]),
1068 )
1069 .expect("a view");
1070
1071 let mut about = Mapping::new();
1072 about.insert("date_of_document".into(), Value::String("1913~".into()));
1073 assert_eq!(
1074 spec.nest_route(&Value::Mapping(about)),
1075 Some(NestRoute::Titles(vec!["1913".to_string()]))
1076 );
1077
1078 let mut between = Mapping::new();
1079 between.insert("date_of_document".into(), Value::String("1918/1922".into()));
1080 assert_eq!(spec.nest_route(&Value::Mapping(between)), None);
1081 }
1082
1083 /// Reading must not decide where a file lands: a view that groups by year
1084 /// still nests by month if that is what it says, and the route is cut from
1085 /// the *uncut* value.
1086 #[test]
1087 fn nest_route_ignores_how_the_view_reads() {
1088 let spec = ViewSpec::parse(
1089 "daily",
1090 &text(&[("group", "created"), ("by", "year"), ("nest", "month")]),
1091 )
1092 .expect("a view");
1093
1094 let mut doc = Mapping::new();
1095 doc.insert("created".into(), Value::String("2026-07-24".into()));
1096 assert_eq!(
1097 spec.nest_route(&Value::Mapping(doc)),
1098 Some(NestRoute::Titles(vec![
1099 "2026".to_string(),
1100 "2026-07".to_string()
1101 ])),
1102 "grouped by year, filed by month — `by` never reaches the route"
1103 );
1104 }
1105
1106 #[test]
1107 fn a_view_that_does_not_nest_or_cannot_file_has_no_route() {
1108 let no_nest = ViewSpec::parse("daily", &text(&[("group", "created")])).expect("a view");
1109 assert_eq!(no_nest.nest_route(&Value::Mapping(Mapping::new())), None);
1110
1111 let nests = ViewSpec::parse("daily", &text(&[("group", "created"), ("nest", "month")]))
1112 .expect("a view");
1113 assert_eq!(
1114 nests.nest_route(&Value::Mapping(Mapping::new())),
1115 None,
1116 "nothing to file by"
1117 );
1118
1119 // A value that reaches the year but not the month files nowhere rather
1120 // than landing in `2026` and calling it done.
1121 let mut partial = Mapping::new();
1122 partial.insert("created".into(), Value::String("2026".into()));
1123 assert_eq!(nests.nest_route(&Value::Mapping(partial)), None);
1124 }
1125
1126 #[test]
1127 fn a_view_round_trips_through_its_mapping() {
1128 for group in [
1129 Grouping {
1130 keys: vec!["created".into()],
1131 by: Some(Grain::Month),
1132 },
1133 Grouping {
1134 keys: vec!["date_of_document".into(), "created".into()],
1135 by: Some(Grain::Day),
1136 },
1137 Grouping::field("people"),
1138 ] {
1139 let spec = ViewSpec {
1140 name: "daily".into(),
1141 label: Some("Daily".into()),
1142 icon: Some("calendar".into()),
1143 group,
1144 under: Some("[Daily](id:abc1234)".into()),
1145 filter: Some(Condition::Not(Box::new(Condition::Has("draft".into())))),
1146 nest: Some(Nest::Grain(Grain::Year)),
1147 };
1148 let back =
1149 ViewSpec::parse("daily", &Value::Mapping(spec.to_mapping())).expect("a view");
1150 assert_eq!(back, spec);
1151 }
1152 }
1153
1154 /// A one-key chain writes back as a bare string, not a one-element list.
1155 #[test]
1156 fn a_single_key_group_serializes_unwrapped() {
1157 let spec = ViewSpec {
1158 name: "who".into(),
1159 label: None,
1160 icon: None,
1161 group: Grouping::field("people"),
1162 under: None,
1163 filter: None,
1164 nest: None,
1165 };
1166 assert_eq!(
1167 spec.to_mapping().get("group"),
1168 Some(&Value::String("people".into()))
1169 );
1170 }
1171
1172 #[test]
1173 fn views_read_in_declaration_order() {
1174 let mut views = Mapping::new();
1175 views.insert("daily".into(), text(&[("group", "created")]));
1176 views.insert("who".into(), text(&[("group", "people")]));
1177 let mut config = Mapping::new();
1178 config.insert(VIEWS_KEY.into(), Value::Mapping(views));
1179
1180 let specs = views_from(&config);
1181 assert_eq!(
1182 specs.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(),
1183 ["daily", "who"]
1184 );
1185 }
1186
1187 #[test]
1188 fn a_label_falls_back_to_the_humanized_name() {
1189 let spec = ViewSpec::parse("daily_entries", &text(&[("group", "created")])).expect("view");
1190 assert_eq!(spec.display_label(), "Daily entries");
1191 }
1192
1193 #[test]
1194 fn a_non_string_scalar_groups_under_its_text() {
1195 let spec = ViewSpec::parse("stars", &text(&[("group", "rating")])).expect("a view");
1196 let mut doc = Mapping::new();
1197 doc.insert("rating".into(), Value::Int(5));
1198 assert_eq!(spec.group.keys_of(&Value::Mapping(doc)), ["5"]);
1199 }
1200
1201 /// A grouping key is a field path, as a declaration's is: `written.on`
1202 /// reaches into a mapping, `confirmed[].by` into every item of a list.
1203 #[test]
1204 fn a_grouping_key_is_a_field_path() {
1205 let nested = ViewSpec::parse("journal", &text(&[("group", "written.on")])).expect("a view");
1206 let mut doc = Mapping::new();
1207 doc.insert(
1208 "written".into(),
1209 text(&[("on", "/Calendar/2026/09/17.md"), ("at", "09:12")]),
1210 );
1211 assert_eq!(
1212 nested.group.keys_of(&Value::Mapping(doc)),
1213 ["/Calendar/2026/09/17.md"]
1214 );
1215
1216 let each = ViewSpec::parse("who", &text(&[("group", "confirmed[].by")])).expect("a view");
1217 let mut doc = Mapping::new();
1218 doc.insert(
1219 "confirmed".into(),
1220 Value::Sequence(vec![text(&[("by", "Ada")]), text(&[("by", "Grace")])]),
1221 );
1222 assert_eq!(each.group.keys_of(&Value::Mapping(doc)), ["Ada", "Grace"]);
1223 }
1224
1225 /// The generalization of `nest` past grains: the record links to its
1226 /// shelf, and the route is that link. Nothing here knows the target is a
1227 /// day, which is the point.
1228 #[test]
1229 fn nest_ref_files_under_what_the_record_links_to() {
1230 let spec = ViewSpec::parse(
1231 "journal",
1232 &text(&[("group", "written.on"), ("nest", "ref")]),
1233 )
1234 .expect("a view");
1235 assert_eq!(spec.nest, Some(Nest::Ref));
1236
1237 let mut doc = Mapping::new();
1238 doc.insert(
1239 "written".into(),
1240 text(&[("on", "[17](/Calendar/2026/09/17.md)"), ("at", "09:12")]),
1241 );
1242 assert_eq!(
1243 spec.nest_route(&Value::Mapping(doc.clone())),
1244 Some(NestRoute::Link("[17](/Calendar/2026/09/17.md)".into())),
1245 "the link as written — resolving it is the frontend's, from where the record lives"
1246 );
1247 assert_eq!(
1248 spec.nest_link(&Value::Mapping(doc)),
1249 Some("[17](/Calendar/2026/09/17.md)".into())
1250 );
1251
1252 // Two shelves is two homes, exactly as two people is.
1253 let mut two = Mapping::new();
1254 two.insert("written".into(), {
1255 let mut m = Mapping::new();
1256 m.insert("on".into(), seq(&["/a.md", "/b.md"]));
1257 Value::Mapping(m)
1258 });
1259 assert_eq!(spec.nest_route(&Value::Mapping(two)), None);
1260
1261 // Nothing linked, nothing filed.
1262 assert_eq!(spec.nest_route(&Value::Mapping(Mapping::new())), None);
1263 }
1264
1265 /// `ref` is a way to file, not a way to read: `by:` takes grains only, and
1266 /// a grain view never answers with a link.
1267 #[test]
1268 fn ref_is_a_nest_and_not_a_grain() {
1269 assert_eq!(Grain::parse(&text_value("ref")), None);
1270 assert_eq!(Nest::parse(&text_value("ref")), Some(Nest::Ref));
1271 assert_eq!(Nest::parse(&text_value(" ref ")), Some(Nest::Ref));
1272 assert_eq!(
1273 Nest::parse(&text_value("month")),
1274 Some(Nest::Grain(Grain::Month))
1275 );
1276 assert_eq!(Nest::parse(&text_value("reff")), None);
1277 assert_eq!(Nest::Ref.grain(), None);
1278
1279 let by_ref = ViewSpec::parse("x", &text(&[("group", "written.on"), ("by", "ref")]))
1280 .expect("still a view");
1281 assert_eq!(
1282 by_ref.group.by, None,
1283 "an unreadable `by:` is no grain, as before"
1284 );
1285
1286 let grained = ViewSpec::parse("daily", &text(&[("group", "created"), ("nest", "month")]))
1287 .expect("a view");
1288 let mut doc = Mapping::new();
1289 doc.insert("created".into(), Value::String("2026-07-24".into()));
1290 assert_eq!(grained.nest_link(&Value::Mapping(doc)), None);
1291 }
1292
1293 #[test]
1294 fn nest_ref_round_trips_through_its_mapping() {
1295 let spec = ViewSpec::parse(
1296 "journal",
1297 &text(&[("group", "written.on"), ("nest", "ref")]),
1298 )
1299 .expect("a view");
1300 let written = spec.to_mapping();
1301 assert_eq!(written.get("nest"), Some(&Value::String("ref".into())));
1302 assert_eq!(
1303 ViewSpec::parse("journal", &Value::Mapping(written)),
1304 Some(spec.clone())
1305 );
1306 assert_eq!(Nest::Ref.display(), "ref");
1307 assert_eq!(Nest::Grain(Grain::Initial(2)).display(), "initial 2");
1308 }
1309}