Skip to main content

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}